How to Make Python Code Faster: A Performance Checklist
Speed up Python in order: fix the algorithm, pick the right data structure, push loops into C built-ins, batch I/O, then tune the profiled hot loop.
- Course: Python study plan
- Module: Memory, performance and the interpreter
- Kind: Lesson
- Reading time: 14 min
- Runtime: CPython 3.11
How do you make Python code run faster?
To make Python code run faster, work down a fixed order: fix the algorithm first, then choose the right data structure, then move loops into C-level built-ins and comprehensions, then batch input and output, and only then tune the profiled hot loop by hoisting lookups and avoiding allocations. Each level dwarfs the next, so profile before each step and stop once the target is met.
Lesson
Making Python fast is a sequence of questions asked in a fixed order, because each level dwarfs the next: is the algorithm right, is the data structure right, is the work happening in C, is the hot loop lean, and only then the micro-optimisations that shave constants. A change at the first level turns hours into seconds; a change at the last turns a second into 0.8 of one. This lesson is the checklist, with the idioms at each level — precomputation and memoisation, the right container, built-ins and comprehensions, fast I/O, local binding, __slots__, early exit — and the judgement about when to stop.
1. The algorithm
Count the operations as a function of n before writing code. A nested loop over the same data is n²; a sort is n log n; a single pass is n. Most "Python is slow" problems are quadratic algorithms that would be slow in C too. The classic fixes: precompute what is asked repeatedly (prefix sums answer any range sum in O(1) after an O(n) pass; a dict from key to row replaces a search per query), memoise overlapping subproblems (@functools.cache on a recursive function turns exponential into polynomial, or write the table iteratively), sort once and binary-search, hash for membership and grouping, early exit when the answer is known.
prefix = [0]
for x in xs:
prefix.append(prefix[-1] + x)
range_sum = lambda i, j: prefix[j] - prefix[i] # O(1) per query
@functools.cache
def paths(r, c):
if blocked[r][c]: return 0
if r == c == 0: return 1
return (paths(r - 1, c) if r else 0) + (paths(r, c - 1) if c else 0)
2. The data structure
From the cost model: set/dict for membership and lookup, deque for a queue, heapq for repeated minimum, bisect for sorted queries, Counter for tallies, defaultdict(list) for grouping, tuples for fixed records, array/NumPy for numbers. Choosing the structure is choosing the complexity of every operation the rest of the program performs.
3. Do the work in C
Built-ins and C-implemented methods run the whole loop without the interpreter: sum, min, max, any, all, sorted with a key, str.join, str.split, str.count, list.sort, map/filter with a built-in or C function, set operations, dict.update, itertools, Counter, bytes methods, re on a whole string. A comprehension is faster than an explicit loop with append (its own code object, no method lookup per iteration), and a generator expression inside sum avoids the intermediate list. The general form: express the computation as a pipeline of built-ins over whole collections rather than as element-by-element statements.
4. Fast I/O
import sys
data = sys.stdin.buffer.read().split() # one read, one split: bytes tokens
n = int(data[0]); xs = list(map(int, data[1:n + 1]))
out = []
for row in rows:
out.append(f"{row.name} {row.total}")
sys.stdout.write("\n".join(out) + "\n") # one write, or print("\n".join(out))
input() per line and print per line each cost a system call and a decode/encode; for a hundred thousand lines that is most of the run time. Read everything once, split, convert with map(int, …); build output in a list and write it once. sys.stdin.readline is the middle ground when lines must be processed as they arrive.
5. The hot loop
Once the profiler names the loop that matters:
- Hoist everything loop-invariant out of it: attribute lookups (
append = out.append), global and built-in names (_len = len), constant expressions, are.compile. - Avoid allocations per iteration — slicing,
strconcatenation, tuple packing where not needed, creating a small object that is immediately discarded. - Keep types stable so 3.11's specialiser stays specialised.
- Use
try/exceptfor the rare case instead of a check per iteration. - Replace a Python-level function call per element with an inline expression, or the whole loop with a comprehension or C-level call.
__slots__on classes instantiated in the millions: less memory and faster attribute access.
def count_matches(words, vocab):
contains = vocab.__contains__ # hoisted bound method
return sum(1 for w in words if contains(w))
Each of these is worth 10–40 % on a loop that is already the bottleneck, and nothing on a loop that is not.
6. The escape hatches
When the algorithm is optimal and the loop is lean and it is still too slow: NumPy for numeric arrays; multiprocessing for CPU-bound work that splits; functools.lru_cache for repeated calls; a C extension, Cython, Numba or Rust via PyO3 for the innermost kernel; PyPy for a whole program that is pure Python. Each is a real project decision, not a first resort.
When to stop
The target is a number — under a second, under 256 MB, ten thousand requests a second — and the checklist runs until it is met, no further. Every optimisation trades readability, and the readable version is what gets maintained. Profile, apply the highest-level fix that applies, measure, and if the target is met, leave the rest alone. The -O flag, .pyc files and "avoid function calls" folklore are not on the list because their effect is negligible next to the algorithm and the data structure.
A worked pass
A script that reads 200 000 records, finds duplicates and prints summaries takes 40 s. Profile: 38 s in if record in seen_list. Level 2: seen becomes a set — 1.2 s. Profile again: 0.8 s in print per line. Level 4: collect and write once — 0.5 s. Target was 2 s; stop. The two changes were four lines; no loop was hand-tuned.
Pitfalls
- Micro-optimising before profiling.
- Tuning a loop that is 3 % of the run time.
input()/print()per line at scale.- A memoised recursive function without
sys.setrecursionlimiton deep inputs. - Rewriting readable code into a comprehension nobody can read for 5 %.
- Reaching for C before checking the algorithm.
Key takeaways
- Order of attack: algorithm, data structure, work in C, I/O, the hot loop, escape hatches — each level dwarfs the next.
- Precompute, memoise, sort-and-bisect, hash, early-exit are the algorithmic moves; prefix sums and
@cacheare the two to know cold. - Built-ins, comprehensions and
joinkeep loops in C; read input once and write output once. - In the hot loop only: hoist lookups, avoid allocations, keep types stable,
try/exceptfor the rare case,__slots__for many instances. - Stop when the target is met; readability is the default.
Common questions
Why is input() slow for large inputs in Python?
Each input() call reads and decodes one line, and for a hundred thousand lines those calls can take most of the run time. Read everything once with sys.stdin.buffer.read().split(), convert the tokens with map(int, ...), and collect output lines in a list to write in one call.
What is memoisation in Python?
Caching a function's results by its arguments so a repeated call returns at once. @functools.cache on a recursive function with overlapping subproblems turns exponential time into polynomial; deep inputs may also need sys.setrecursionlimit raised.
Are list comprehensions faster than for loops in Python?
Usually, yes. A comprehension compiles to its own tight loop with no append method lookup per iteration, and a generator expression inside sum avoids building a list at all. The gain is modest, so a readable loop still beats an unreadable comprehension.
What are prefix sums used for?
Answering range-sum queries in O(1) each after one O(n) pass: with prefix = [0, *itertools.accumulate(xs)], the sum of xs[i:j] is prefix[j] - prefix[i]. They replace a loop per query with one subtraction.
When should you stop optimising Python code?
When the target, such as a time limit, a memory limit or a throughput figure, is met, or when the remaining hot spot is essential work. Every optimisation costs readability, so profile, apply the highest-level fix, measure, and leave the rest alone.
Exercises
Grid paths with @cache
Read n seed p. Build an n × n grid where cell (r, c) is blocked when rng.randrange(100) < p (draw the cells row by row, but the start (0, 0) and the end (n-1, n-1) are never blocked — still draw for them, then force them open). Count the paths from the start to the end moving only right or down through open cells with a recursive function decorated with functools.cache — without the cache the hidden case is exponential. Print blocked <count of blocked cells> and paths <count>.
Input: n seed p. Output: two lines.
4 5 30
prints
blocked 3
paths 9Prefix sums for range queries
Read n q seed. Generate xs = [rng.randrange(-1000, 1000) for _ in range(n)], then q queries each drawn as i = rng.randrange(n) followed by j = rng.randrange(i, n) (inclusive range i..j). Answer every query in O(1) with a prefix-sum array built once — a fresh sum(xs[i:j + 1]) per query is O(n·q) and will not finish the hidden case. Print queries <q>, sum of answers <total>, max answer <largest> and min answer <smallest>.
Input: n q seed. Output: four lines.
6 3 1
prints
queries 3
sum of answers 2278
max answer 1207
min answer 336In this module: Memory, performance and the interpreter
- The object model — objects, references, reference counting and the cycle collector
- The cost model — what the built-in operations really cost
- Bytecode and the interpreter — code objects, dis, name lookup and the 3.11 specialiser
- Measuring — timeit, perf_counter, cProfile, tracemalloc and benchmarking hygiene
- Numeric performance — boxed numbers, array, bytes and NumPy vectorisation
- Writing fast Python — the checklist, from algorithm to micro-optimisation (this lesson)
- Checkpoint — Memory, performance and the interpreter
← Numeric performance — boxed numbers, array, bytes and NumPy vectorisation · Checkpoint — Memory, performance and the interpreter →