AlgoMaster Logo

Performance Tips

Low Priority20 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Python is slower than C, and that's fine. The reason most Python programs feel slow has very little to do with the interpreter and almost everything to do with the wrong data structure, an O(n^2) loop hiding inside something that looked harmless, or work happening that didn't need to happen at all. This lesson covers how to figure out where the time really goes, the small set of habits that pay off in hot code, and what to reach for when pure Python genuinely is the bottleneck.

Measure First, Optimize Second

Donald Knuth's line about premature optimization gets quoted everywhere, but the part people skip is the rest of the sentence: "we should forget about small efficiencies, say about 97% of the time." The point is not to never optimize. It's to find out where the time is actually being spent before changing anything.

Most performance intuition is wrong. A loop that looks slow is often fine. A one-liner that looks tight often hides a quadratic blow-up. The only reliable way to know is to measure.

A normal workflow looks like this:

  1. Write the code in the simple, clear way first.
  2. If it's fast enough, stop.
  3. If it's not, profile to find the hot spot.
  4. Change one thing. Measure again.
  5. Stop when the program meets its target.

Step 4 is the one beginners skip. A change that looks like a speedup can easily make things slower because of cache effects, allocation patterns, or a missed assumption. Always re-measure.

The loop in the diagram is the whole game. Don't guess. Don't optimize the section of code that looks ugly. Optimize the section the profiler says is slow.

Profiling Tools

Python ships with two profilers that cover most needs.

timeit for Microbenchmarks

timeit runs a snippet many times and reports the best total time. It handles the warm-up, the timer resolution, and the noise of garbage collection for you. Use it when comparing two small alternatives.

From the command line, timeit is even shorter:

-s is the setup (run once), and the next argument is the code under test (run many times). Use timeit for any "is A or B faster?" question that fits in a one-liner.

cProfile for Whole-Program Profiling

cProfile measures every function call in a real program. It tells you which functions consumed the most time, how many times each was called, and how much time was spent inside each function versus inside its callees. It's the right tool when you don't know where to look.

The output (trimmed) shows where the time went:

If you'd rather see the report visually, save the stats to a file with profiler.dump_stats("out.prof") and open it with snakeviz out.prof, which renders an interactive flame chart in the browser. For a running production process, py-spy is the usual pick because it attaches without a code change and has very low overhead.

tracemalloc for Memory

tracemalloc is the standard-library way to find which lines of your code allocated the most memory. Turn it on, run the workload, snapshot, sort.

The output points at the exact line that allocated each chunk. Useful when a long-running program's resident memory keeps climbing and you need to find which structure is the culprit.

Algorithmic Complexity Wins

Before any micro-optimization, check the algorithm. A loop with an in check on a list is O(n) per check; doing that inside another loop turns the whole thing into O(n^2). Switching the inner container to a set or a dict makes the lookup O(1) and turns the program from "minutes" into "milliseconds." No micro-tweak comes close.

A common shape:

On 50,000 product names, the slow version takes around 8 seconds on a typical laptop. The fast version finishes in about 6 milliseconds. The interpreter didn't get faster; the data structure changed.

A short table of the choices that come up most often:

NeedWrong choiceRight choiceWhy
Append-only queuelist.insert(0, x)collections.dequeList insert at the front is O(n); deque is O(1) at both ends
Membership test in a hot loopx in some_listx in some_setList is O(n); set is O(1) average
Counting thingsDict with manual if key in dcollections.CounterSingle C-level update per key
Sorted list with frequent insertsRepeated list.sort() after each appendbisect.insortRe-sorting is O(n log n) per insert; bisect insert is O(n) for the shift but skips the sort
Group by keyManual if key in d: d[key].append(x)collections.defaultdict(list)Single line, fewer attribute lookups

Built-in Functions Are C-Optimized

A lot of the functions that look like ordinary Python (sum, min, max, any, all, len, sorted, map, filter) are implemented in C inside the CPython source. Calling them on an iterable does one Python-level call and then runs at C speed. Rewriting the same operation as a hand-rolled for loop pays the Python-level cost on every iteration.

The pattern holds for the rest of them. any(stock > 0 for stock in stocks) is faster than a for loop with an early return True, partly because the generator side is C-driven and partly because any short-circuits inside the C layer.

A subtle case is min and max with a key function. The key runs once per element, in Python, so a very expensive key undoes the C advantage. For a simple key like lambda product: product["price"], the built-in is still faster than rolling your own loop.

Local Variable Lookup Beats Global

Python looks up names in a fixed order: local scope, enclosing scope, global scope, built-ins. Local lookup is a fast array index into the function's stack frame. Global and built-in lookups are dict lookups. The cost per call is tiny, but in a hot inner loop that runs millions of times it adds up.

Two tricks help in genuinely hot loops:

  1. Pull globals you need into a local variable at the top of the function.
  2. Bind frequently called methods to a local name.

On a million points, the two versions differ by about 15-25% on a typical CPython build. This is the kind of change to make only when a profiler has already pointed at this exact loop. In cold code it's noise, and it makes the code slightly harder to read.

String Concatenation in Loops

Strings are immutable in Python. result += chunk doesn't extend result; it allocates a new string with the combined contents and reassigns result to it. Inside a loop, that means every iteration allocates a new string whose size grows with the loop count. The total work is O(n^2) in the size of the output.

The fix is to collect the pieces into a list and call "".join(...) once at the end. join walks the list, sums the lengths, allocates a single result string, and copies each piece in. That's O(n) total.

A real timing comparison for building a 10,000-piece string:

MethodTime (50 runs)AllocationsNotes
+= in a loop~0.74sOne per iterationO(n^2) in output size
"".join(list)~0.018sOne totalRecommended default
io.StringIO().write then .getvalue()~0.045sStreaming bufferGood when you build incrementally and don't want a list
format / f-strings used oncen/aOne per callFine outside loops, same trap inside

CPython has an optimization that turns some s += x loops into in-place buffer growth when the string has a refcount of 1, but the optimization is fragile (it breaks under PyPy, under tracing, under any code that takes another reference to s, and so on). Treat it as luck, not a feature, and write join from the start.

Caching with functools.lru_cache and functools.cache

When a function is pure (same inputs always produce the same output) and gets called with repeated inputs, caching the results turns repeated work into a dict lookup. The standard library ships two decorators for this.

functools.cache (Python 3.9+) is an unbounded cache: every call's result is kept forever. functools.lru_cache(maxsize=N) bounds the cache to N entries and evicts the least-recently-used one when full.

The first call computes the result. Every later call with the same arguments returns the cached value. The classic demo is recursive Fibonacci, which goes from exponential to linear after one decorator:

The decorated version computes each fib_fast(k) once for k from 0 to 30 and reuses the result.

Three rules for caching:

  1. The function must be pure. If shipping_cost reads a rate table that changes, caching gives stale answers.
  2. Arguments must be hashable. Lists and dicts can't be cache keys; use tuples or frozensets.
  3. Use lru_cache(maxsize=N) for anything that could see many distinct keys. An unbounded @cache on a function called with random product IDs is a memory leak waiting to happen.

@cache and @lru_cache are full decorators, so they show up under their wrapped function name in tracebacks and in cProfile output. They also expose cache_info() and cache_clear() for inspection.

Avoiding Repeated Attribute Access

Every obj.attr in Python is a lookup. For a regular instance, that's a chain of checks: instance dict, class dict, then the MRO. In a tight loop, doing the same lookup over and over wastes time you can avoid by storing the result in a local name once.

The fast version isn't faster because attribute access is slow in absolute terms. It's faster because the slow version did the same lookup four times per iteration on a structure that never changes. Pulling the lookup outside the loop is the same idea as loop-invariant code motion in a compiler. Python doesn't do it automatically.

The same trick applies to method binding:

This is a hot-loop trick. Don't apply it everywhere; it makes the code harder to read for very little gain in cold paths.

__slots__ for Many-Instance Classes

A regular Python class stores its instance attributes in a per-instance __dict__. That's flexible (you can set any attribute at any time) but expensive. Each instance carries a dict of its own, and a dict has overhead even when it's mostly empty.

If you know the full list of attributes a class will ever have, declaring __slots__ replaces the dict with a fixed array. This makes each instance smaller and attribute access slightly faster, and forbids adding new attributes at runtime.

On CPython 3.12, ProductDict instances take roughly 296 bytes each (after __dict__ and the dict's keys are counted), while ProductSlots instances take around 64 bytes. For a million products in memory, that's the difference between 280 MB and 60 MB.

For now, the rule is: if you're going to allocate hundreds of thousands of instances of a class, declare __slots__ and measure the memory drop. Edge cases include inheritance interactions, weakref support, the __dict__ slot, and trade-offs like losing dynamic attribute creation and breaking some pickling patterns.

When Pure Python Is the Bottleneck

Sometimes the algorithm is right, the data structures are right, the loops are tight, and the program is still too slow. The interpreter overhead per operation has a floor, and numerical or array-heavy work can hit it. At that point, the answer is to drop out of pure Python for the hot section.

A short tour of the options:

ToolUse it forNotes
numpyNumerical arrays, linear algebra, vectorized mathOperations on N elements run in one C call. Often 10-100x faster than a Python loop for the same math
pandasTabular data, joins, group-bys, time seriesBuilt on numpy, plus column-oriented operations
numbaJIT-compiling numerical Python loops to machine code@njit decorator, mostly transparent; great when you'd love numpy but the loop has too much logic
cythonCompiling Python-like code to C, with optional type annotationsHeavier setup; gives you C performance with mostly Python syntax
C extensions (ctypes, cffi, the C API)Calling existing C libraries, writing speed-critical extensionsMost overhead is the FFI boundary; useful when libraries already exist in C
PyPyRunning the same Python code under a tracing JITDrop-in for many programs, often 4-10x faster for long-running pure-Python workloads. Compatibility varies for C extensions

For numerical work, numpy is usually the first stop and often the last:

The same calculation as a Python loop is about 50x slower on most laptops. The difference is not the math; it's that numpy's * and sum are one Python call each, with the work happening in C over a contiguous buffer.

numba is the fallback when the loop has branches or accumulators that don't vectorize cleanly:

The first call compiles, the rest run at near-C speed. The compatibility surface is smaller than full Python (no arbitrary objects, no dynamic dispatch), but for numerical inner loops it's a clean fit.

C extensions and Cython are the heavier tools. Reach for them when you've measured, none of the above worked, and the hot path is small enough to rewrite in a more careful language.

GIL, I/O Bound, and CPU Bound

The Global Interpreter Lock (GIL) is the rule inside CPython that says only one thread can execute Python bytecode at a time. For performance work, what matters is the consequence: spinning up multiple Python threads to run pure-Python computation does not give you more throughput, because the GIL forces them to take turns.

This makes the difference between I/O-bound and CPU-bound work the central question for any "should I add concurrency?" decision.

I/O-bound work spends most of its time waiting: for a network response, a disk read, a database query, a subprocess. While one thread waits, it releases the GIL, which lets another thread run. Threads (and asyncio) are the right fit here. Ten threads doing HTTP requests can run roughly in parallel because they're all waiting for sockets most of the time.

CPU-bound work spends most of its time computing inside Python: parsing, image transforms, numerical loops written in pure Python, regex on large strings. Threads don't help, because the GIL serializes them. The right answer is multiprocessing (separate Python processes, each with its own interpreter and its own GIL) or moving the hot section to numpy / Cython / numba / a C extension, which releases the GIL during the heavy C work.

A simple decision flow:

The diagram is a shorthand. The first question to ask before adding any kind of parallelism is "what is this thread or process going to do while it's running?" If the answer is "wait," threads work. If the answer is "compute in Python," they won't, and the fix is either processes or escaping to native code.

Generators for Large Data

Materializing a list when you only need to iterate once wastes memory. Generators (and generator expressions) yield one item at a time and never hold the whole sequence in memory. For large inputs, this is the difference between fitting in memory and not.

The second form drops the square brackets and becomes a generator expression. sum consumes it lazily and never builds the intermediate list.

The same pattern applies anywhere a function takes an iterable: any, all, max, min, "".join, and most things that loop. The exception is when you need the list for indexing, multiple passes, or len. Then a list is the right structure.

Quiz

Performance Tips Quiz

10 quizzes