Optimisation techniques for pure Python
Avoiding attribute lookups, caching, __slots__, and when NumPy or Cython is the right escape hatch.
Why this matters
Once profiling has identified the hot path, you have a menu of optimisations: algorithmic fixes (always start here), better data structures, NumPy vectorisation, Cython/Numba/mypyc compilation, calling out to Rust. This lesson walks the ladder from "rewrite the loop" to "ship a Rust extension."
Learning objectives
- Pick the right optimisation level for the problem.
- Apply algorithmic and data-structure fixes first.
- Vectorise with NumPy / Polars.
- JIT with Numba or compile with Cython / mypyc.
- Bind to Rust with PyO3 (overview).
1. The optimisation ladder
Pure Python (idiomatic) โโ fast enough for most code
โ
Algorithm change (O(nยฒ) โ O(n)) โโ free 10-1000x; do this first
โ
Better data structure โโ set vs list, dict, deque, heapq
โ
NumPy / Polars vectorisation โโ 10-100x for numeric / tabular work
โ
Numba JIT (@njit) โโ drop-in for numeric loops; near C speed
โ
Cython / mypyc โโ ahead-of-time C compilation
โ
PyO3 / Rust / C extension โโ ultimate speed; biggest engineering costClimb only as high as needed. Almost no one needs PyO3.
2. Step 1: algorithmic fixes
Membership: list โ set
# BAD โ O(nยฒ)
keepers = [x for x in items if x in valid_ids] # valid_ids: list
# GOOD โ O(n)
valid_ids = set(valid_ids)
keepers = [x for x in items if x in valid_ids]Look-up: list scan โ dict
# BAD โ O(n ร m)
matched = [next(u for u in users if u.id == oid) for oid in order_ids]
# GOOD โ O(n + m)
by_id = {u.id: u for u in users}
matched = [by_id[oid] for oid in order_ids]Repeated computation โ cache
@functools.cache
def expensive(x: int) -> int: ...Repeated regex โ compile once
PATTERN = re.compile(r"...")
PATTERN.search(s) # vs re.search at every callLinear search โ bisect
import bisect
i = bisect.bisect_left(sorted_list, x)O(log n) for sorted arrays.
Priority queue โ heapq
import heapq
heap = []
heapq.heappush(heap, (priority, item))
priority, item = heapq.heappop(heap)3. Step 2: better data structures
deque for FIFO / sliding windows
from collections import deque
window = deque(maxlen=100)list.pop(0) is O(n). deque.popleft() is O(1).
dict over many if/elif
HANDLERS = {"a": handle_a, "b": handle_b, "c": handle_c}
HANDLERS.get(kind, handle_default)(payload)array for homogeneous numeric arrays
import array
nums = array.array("i", range(10**7))10ร less memory than a Python list of ints.
slots for mass instances
@dataclass(slots=True)
class Point: x: float; y: float50% less memory per instance.
frozenset for immutable lookups
ALLOWED: frozenset[str] = frozenset({"a", "b", "c"})Hashable, immutable, fast in.
4. Step 3: vectorise with NumPy
(See Lesson 5.1.) Convert loops to array ops.
Before:
def haversine(lat1, lon1, lat2, lon2):
# ... math on scalars ...
return d
distances = [haversine(*coords[i], *coords[j]) for i, j in pairs]After:
import numpy as np
def haversine_vec(lat1, lon1, lat2, lon2):
# all inputs are numpy arrays; same math, vector-wise
...
return d
coords = np.array(coords) # (N, 2)
distances = haversine_vec(
coords[i_idx, 0], coords[i_idx, 1],
coords[j_idx, 0], coords[j_idx, 1],
)10-100ร speedup for size > 1000.
Polars over pandas
Already covered (5.3). 3-30ร speedup with the same API.
numexpr for big expressions
import numexpr as ne
result = ne.evaluate("a*b + c*d - e**2", local_dict={"a": a, "b": b, ...})Avoids intermediates; uses cache better.
5. Step 4: JIT with Numba
numba compiles Python functions to LLVM at first call. Best for numerical loops where NumPy doesn't naturally express the operation.
uv add numbafrom numba import njit
import numpy as np
@njit
def monte_carlo_pi(n: int) -> float:
inside = 0
for _ in range(n):
x = np.random.random()
y = np.random.random()
if x*x + y*y <= 1.0:
inside += 1
return 4 * inside / n
print(monte_carlo_pi(10_000_000)) # ~100x faster than pure PythonCaveats
- First call compiles โ slow. Use
@njit(cache=True)to persist. - Only supports a subset of Python (mostly numeric).
- No classes, no most stdlib calls.
- For arrays, use NumPy first; Numba shines for loops that NumPy can't express.
@njit(parallel=True) + prange
from numba import njit, prange
@njit(parallel=True)
def f(a):
for i in prange(len(a)):
a[i] = a[i] * 2Multi-threaded loops. Bypasses the GIL inside @njit.
6. Step 5: Cython โ ahead-of-time C
For ~maximal speed on numeric code, with full Python access:
# myext.pyx
def add(int a, int b) -> int:
return a + b
cdef double sum_squares(double[::1] arr):
cdef double s = 0
cdef int i
for i in range(arr.shape[0]):
s += arr[i] * arr[i]
return s
def sum_squares_py(double[::1] arr):
return sum_squares(arr)Build with cython + a build system (setuptools / scikit-build / hatchling).
# pyproject.toml
[build-system]
requires = ["setuptools", "Cython", "numpy"]
[tool.setuptools]
ext-modules = [{name = "myext", sources = ["myext.pyx"]}]uv pip install -e . # builds the .so / .pydCython gives 10-100ร over pure Python on tight loops, with full control over memory layout, types, GIL release (with nogil:). Worth the build complexity for libraries; rarely for app code.
mypyc โ compile typed Python
uv add mypy[mypyc]
mypyc src/myprojectmypyc reads your type hints and generates C code. Much less code change than Cython โ just type your existing Python. 2-10ร speedup typical.
mypy itself uses mypyc to compile itself. Same for some pydantic-v2 components.
7. Step 6: Rust extensions with PyO3
For ultimate performance and CPython interop:
// src/lib.rs
use pyo3::prelude::*;
#[pyfunction]
fn sum_squares(xs: Vec<f64>) -> f64 {
xs.iter().map(|x| x * x).sum()
}
#[pymodule]
fn my_native(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(sum_squares, m)?)?;
Ok(())
}Build with maturin:
uv tool install maturin
maturin develop # builds + installs into the venvpip install from PyPI works for users (pre-built wheels). They don't need a Rust toolchain.
Examples in 2026: pydantic-core (Rust core), ruff, uv, polars are all Rust-backed Python libs.
When to consider:
- You ship a library with hot inner loops used by many.
- You need to release the GIL and use raw threads.
- You need memory layout / lifetime guarantees Python doesn't offer.
Not for app code unless the inner loop genuinely matters.
8. Step 7: don't write Python here
Sometimes the right answer is "use the right tool":
- ML training โ PyTorch / JAX (delegates to CUDA).
- Image processing โ OpenCV / Pillow-SIMD.
- Linear algebra โ NumPy with MKL/Accelerate.
- Database aggregations โ DuckDB / Postgres.
- Compression โ
zstandard,lz4,xxhash(C-backed). - Tokenizers โ
tokenizers(Rust). - Async networking โ
httpx/aiohttp(C event loop via libuv).
These libraries are 1000ร faster than reimplementing the algorithm. Choose the right primitive first.
9. Common Python micro-optimisations (when nothing else helps)
| Slow | Fast |
|---|---|
for i in range(len(a)): | for x in a: or enumerate(a) |
a.append(x) in a loop, then ''.join(a) | precompute, or use io.StringIO |
| Dict lookup of attribute | local-binding (f = self.method) |
| Global lookup in tight loop | bind to local (g = some_func) |
repr(x) in hot logging | guard with log.isEnabledFor(DEBUG) |
Many small dict allocations | reuse one dict, .clear() between iterations |
class X: pass for tiny records | __slots__ or NamedTuple |
try/except on the hot path | check the condition with if first if cheaper |
+ to concatenate many strings | "".join(parts) |
Each is worth ~10-30% in tight loops. Don't reach for these before algorithmic fixes.
10. Caching strategies
In-memory: functools.cache / lru_cache
@cache
def slow(x): ...For pure functions whose arguments are hashable.
Disk: diskcache, joblib.Memory
from diskcache import Cache
cache = Cache("./cache")
@cache.memoize(expire=3600)
def fetch(url): ...For expensive results that survive process restart.
Remote: Redis
import redis
r = redis.Redis()
key = f"feature:{user_id}"
if (v := r.get(key)) is None:
v = compute(user_id)
r.set(key, v, ex=300)For sharing cache across processes / machines.
Application-level memoisation table
For "compute and cache N items at once":
def get_many(ids):
missing = [i for i in ids if i not in CACHE]
if missing:
fresh = bulk_fetch(missing)
CACHE.update(fresh)
return {i: CACHE[i] for i in ids}Avoids the N+1 cost of single-key cache lookups.
11. Worked example: from 60s to 600ms
Suppose process_orders(orders) takes 60s for 1M orders.
- Profile (
py-spy): top function isfind_user(uid)(linear list scan). - Build
users_by_id = {u.id: u for u in users}once. Down to 8s. - Profile again: now
compute_total(order)is hot โ does a regex on each row. - Compile regex once, hoist out of loop. Down to 4s.
- Profile: many small dict allocations. Switch order objects to
dataclass(slots=True). Down to 2s. - The remaining hot loop is purely numeric (sums per group). Switch to NumPy / Polars groupby. Down to 0.6s.
- Stop. 100ร faster, total work: half a day.
You'd be tempted to start at step 6. Profile first โ steps 1-5 were easier and proportionally larger wins.
Hands-on lab (2 hours)
- Take a slow script (your own or a recipe). Profile it. List the top 3 hotspots.
- Replace one
list inmembership check withset in. Re-measure. - Vectorise one numeric loop with NumPy. Re-measure.
- Apply
@functools.cacheto one pure function with hashable args. Re-measure. - Use Numba
@njiton one numeric function; compare first-call vs warm. - (Optional) Compile a small module with
mypyc; measure speedup. - (Optional) Write a tiny PyO3 function (string parsing, math); install with
maturin develop.
Common pitfalls
- Optimising without profiling.
- Rewriting in NumPy when the bottleneck is I/O.
- Numba on functions that hit pure-Python code โ slow fallback.
- Caching impure functions โ wrong results.
- Cython without
# cython: language_level=3(or# distutils: language = c++) โ ancient defaults. - PyO3 / Rust for an app feature that runs 10 times a day.
Self-check
- State the optimisation ladder.
- Why is
set infaster thanlist in? - When use Numba over NumPy?
- What does
mypycdo? - State one library written in Rust that Python users rely on.
References
- High Performance Python, 2nd ed., Gorelick & Ozsvald.
- Numba docs: https://numba.readthedocs.io/.
- Cython docs: https://cython.readthedocs.io/.
- mypyc docs: https://mypyc.readthedocs.io/.
- PyO3 user guide: https://pyo3.rs/.
- Maturin: https://www.maturin.rs/.
Sign in to save your progress and earn badges.