Performance Basics
11 examples to get you started with Performance - 8 basic and 3 intermediate.
Prerequisites
- Python 3.14.0
- Standard library modules:
timeit,cProfile,tracemalloc - Optional:
uv add numpyfor vectorization examples
Basic Examples
1. Measure Before Optimizing
Never optimize without a baseline measurement.
import time
def slow_sum(n: int) -> int:
return sum(range(n))
start = time.perf_counter()
result = slow_sum(1_000_000)
elapsed = time.perf_counter() - start
print(f"Result: {result}, Time: {elapsed:.4f}s")time.perf_counter()is monotonic and high resolution- Record baseline before any optimization attempt
- Compare after changes to verify improvement
- If the measurement is noisy, run multiple iterations
Related: timeit & Microbenchmarks - precise timing
2. timeit for Microbenchmarks
The timeit module runs code in tight loops for stable timing.
import timeit
elapsed = timeit.timeit("sum(range(1000))", number=10000)
print(f"Average: {elapsed / 10000 * 1e6:.2f} μs")numbercontrols loop iterations- Isolates the statement from setup overhead
- Use for comparing two approaches on small code paths
- Not suitable for I/O-bound operations
Related: timeit & Microbenchmarks - methodology
3. cProfile for CPU Hotspots
Find which functions consume the most CPU time.
import cProfile
import pstats
def work():
sum(i * i for i in range(100_000))
cProfile.run("work()", "profile.out")
stats = pstats.Stats("profile.out")
stats.sort_stats("cumulative").print_stats(10)cumulativesorts by total time including subcalls- Top entries are optimization candidates
- Profile real workloads, not toy examples
- Save stats to file for comparison across runs
Related: cProfile & Profilers - flame graphs
4. tracemalloc for Memory
Track memory allocations to find leaks and bloat.
import tracemalloc
tracemalloc.start()
data = [bytearray(1024) for _ in range(10_000)]
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")[:5]
for stat in top:
print(stat)
tracemalloc.stop()tracemallocis stdlib - no install needed- Shows which line allocated the most memory
- Compare snapshots before/after to find leaks
- Lighter weight than memray for quick checks
Related: Memory Profiling - memray deep dive
5. List Comprehension vs Loop
Comprehensions are faster for simple transforms.
# Faster
squares = [x * x for x in range(100_000)]
# Slower
squares = []
for x in range(100_000):
squares.append(x * x)- Comprehensions avoid repeated
list.appendlookups - For complex logic, readability may outweigh the speed gain
- Profile before assuming comprehension wins
- Generator expressions save memory for large sequences
6. functools.lru_cache
Cache pure function results to avoid redundant computation.
from functools import lru_cache
@lru_cache(maxsize=256)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # instant with cache- Only works for functions with hashable arguments
maxsizebounds memory usage- Thread-safe in CPython
- Use
.cache_info()to inspect hit rate
Related: Caching & Memoization - cache strategies
7. Generator vs List
Generators yield items lazily, saving memory on large datasets.
def read_lines(path: str):
with open(path) as f:
for line in f:
yield line.strip()
# Processes one line at a time - constant memory
for line in read_lines("huge.log"):
if "ERROR" in line:
print(line)- Lists load everything into memory
- Generators process one item at a time
- Use generators for file I/O and pipeline stages
sum(1 for _ in gen)still consumes the generator once
8. Avoid Repeated Attribute Lookup
Bind frequently accessed attributes to local variables in hot loops.
# Faster in tight loops
append = results.append
for item in data:
append(transform(item))
# Slower - repeated attribute lookup
for item in data:
results.append(transform(item))- Local variable access is faster than attribute lookup
- Matters only in innermost hot loops
- Readability trade-off - apply only where profiled
- Modern Python optimizes many cases automatically
Intermediate Examples
9. NumPy Vectorization
Replace Python loops with array operations for numeric work.
import numpy as np
data = np.arange(1_000_000, dtype=np.float64)
result = np.sqrt(data) * 2.0 + 1.0 # vectorized
# vs: [math.sqrt(x) * 2.0 + 1.0 for x in data] # 10-100x slower- NumPy operations run in C/Fortran under the hood
- Requires contiguous numeric data
- pandas and Polars build on similar vectorization
- Conversion overhead matters for small arrays
Related: Vectorization with NumPy - full guide
10. asyncio for I/O-Bound Work
Parallelize waiting on network/disk without threads.
import asyncio
import httpx
async def fetch_all(urls: list[str]) -> list[int]:
async with httpx.AsyncClient() as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [r.status_code for r in responses]
asyncio.run(fetch_all(["https://example.com"] * 10))- asyncio helps when code waits on I/O, not CPU
- Does not bypass the GIL for CPU-bound work
- Use
asyncio.gatherfor concurrent coroutines - For CPU parallelism, use
multiprocessingorconcurrent.futures
Related: Async & Concurrency for Throughput - patterns
11. Performance Audit Workflow
A systematic pass before claiming something is "optimized."
# 1. Measure baseline
# 2. Profile (cProfile or py-spy)
# 3. Fix the top hotspot only
# 4. Re-measure
# 5. Document the before/after numbers
baseline_ms = 450
optimized_ms = 120
improvement = (baseline_ms - optimized_ms) / baseline_ms * 100
print(f"Improvement: {improvement:.0f}%") # 73%- Optimize the measured bottleneck, not a guess
- One change at a time for clear attribution
- Keep a log of optimization decisions
- Stop when performance meets requirements
Related: Performance Audit Checklist - full checklist
Stack versions: This page was written for Python 3.14.0, FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.