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.
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:
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.
Python ships with two profilers that cover most needs.
timeit for Microbenchmarkstimeit 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 ProfilingcProfile 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.
Cost: cProfile adds roughly 30-50% overhead to function call timing. The shape of the bottleneck still shows up correctly, but the absolute numbers run slow. Use timeit for precise comparisons.
tracemalloc for Memorytracemalloc 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.
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:
| Need | Wrong choice | Right choice | Why |
|---|---|---|---|
| Append-only queue | list.insert(0, x) | collections.deque | List insert at the front is O(n); deque is O(1) at both ends |
| Membership test in a hot loop | x in some_list | x in some_set | List is O(n); set is O(1) average |
| Counting things | Dict with manual if key in d | collections.Counter | Single C-level update per key |
| Sorted list with frequent inserts | Repeated list.sort() after each append | bisect.insort | Re-sorting is O(n log n) per insert; bisect insert is O(n) for the shift but skips the sort |
| Group by key | Manual if key in d: d[key].append(x) | collections.defaultdict(list) | Single line, fewer attribute lookups |
Cost: x in some_list is O(n). If the loop body does it once, fine. If it does it inside another loop, the whole thing is O(n*m). Convert the list to a set if the membership test runs more than a handful of times.
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.
Cost: sum(generator) walks the generator without materializing a list, which keeps memory flat for large inputs. sum(list(generator)) builds a throwaway list first, doubling allocation. Prefer the generator form unless you need the list for something else.
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:
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.
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:
| Method | Time (50 runs) | Allocations | Notes |
|---|---|---|---|
+= in a loop | ~0.74s | One per iteration | O(n^2) in output size |
"".join(list) | ~0.018s | One total | Recommended default |
io.StringIO().write then .getvalue() | ~0.045s | Streaming buffer | Good when you build incrementally and don't want a list |
format / f-strings used once | n/a | One per call | Fine 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.
Cost: result = result + extra inside a loop is O(n^2). "".join(parts) is O(n). For 10,000 pieces this is the difference between milliseconds and seconds.
functools.lru_cache and functools.cacheWhen 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:
shipping_cost reads a rate table that changes, caching gives stale answers.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.
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 ClassesA 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.
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:
| Tool | Use it for | Notes |
|---|---|---|
numpy | Numerical arrays, linear algebra, vectorized math | Operations on N elements run in one C call. Often 10-100x faster than a Python loop for the same math |
pandas | Tabular data, joins, group-bys, time series | Built on numpy, plus column-oriented operations |
numba | JIT-compiling numerical Python loops to machine code | @njit decorator, mostly transparent; great when you'd love numpy but the loop has too much logic |
cython | Compiling Python-like code to C, with optional type annotations | Heavier setup; gives you C performance with mostly Python syntax |
C extensions (ctypes, cffi, the C API) | Calling existing C libraries, writing speed-critical extensions | Most overhead is the FFI boundary; useful when libraries already exist in C |
| PyPy | Running the same Python code under a tracing JIT | Drop-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.
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.
Cost: multiprocessing has real overhead per task: spinning up a process, pickling arguments to send to it, and pickling results back. For tasks under a few milliseconds each, the overhead can dominate. Batch small tasks before sending them to a worker pool.
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.
Cost: A generator expression has tiny per-element overhead compared to a list comprehension (it's a generator object with __next__ calls), so for very small inputs the list comprehension is a hair faster. The break-even is usually a few hundred elements; past that, the generator wins on memory and roughly ties on CPU.
10 quizzes