Never optimize by guessing β measure the bottleneck, then prove the fix.
orjson, ships it⦠and p99 doesn't move. The real cost was an N+1 query
doing 300 round-trips per request. Intuition about performance is wrong more often than it's
right β and it's most wrong exactly where it matters, under load.
For a single CPU-bound function, wall β CPU and a microbenchmark tells the whole story. For anything that waits (I/O, DB, locks, the GIL), wall time is dominated by waiting β and under concurrency the metric that matters isn't one function's time at all, it's throughput and tail latency of the whole system.
import time
t0_wall = time.perf_counter() # real elapsed time (includes waiting)
t0_cpu = time.process_time() # CPU time actually burned (excludes waiting)
result = do_work()
wall = time.perf_counter() - t0_wall
cpu = time.process_time() - t0_cpu
print(f"wall={wall:.3f}s cpu={cpu:.3f}s")
# wall >> cpu => I/O-bound (waiting): fix with async/threads/pooling/batching
# wall ~= cpu => CPU-bound (computing): fix with multiprocessing/C ext/algorithm
wall / cpu β tells you which half of this whole tutorial applies before
you profile anything. A concurrent system is only "faster" if throughput went up or
tail latency went down; a lower single-call time proves nothing.
timeit
timeit runs a snippet many times, disables the cyclic GC for the run, and reports the best
loops β good for comparing two implementations of a tiny hot function.
import timeit
setup = "data = list(range(1000))"
loops = timeit.timeit("[x*x for x in data]", setup=setup, number=100_000)
gener = timeit.timeit("list(x*x for x in data)", setup=setup, number=100_000)
print(f"listcomp {loops:.3f}s genexpr {gener:.3f}s")
# Prefer .repeat() and take the MIN β it filters out scheduler/GC noise spikes
best = min(timeit.repeat("[x*x for x in data]", setup=setup, repeat=5, number=100_000))
# CLI form β quick A/B without writing a file:
python -m timeit -s "data=list(range(1000))" "[x*x for x in data]"
timeit, so allocation-heavy code looks cheaper than in production.cProfile
cProfile instruments every call and counts exactly where time goes. Sort by
cumulative time (cumtime) to find the expensive call tree, or
tottime for time in a function excluding its callees.
import cProfile, pstats, io
profiler = cProfile.Profile()
profiler.enable()
run_request_handler() # the code you want to dissect
profiler.disable()
s = io.StringIO()
stats = pstats.Stats(profiler, stream=s).sort_stats("cumulative")
stats.print_stats(15) # top 15 by cumulative time
print(s.getvalue())
# CLI: profile a whole script and dump a binary stats file
python -m cProfile -o out.prof myscript.py
python -c "import pstats; pstats.Stats('out.prof').sort_stats('cumtime').print_stats(20)"
# Reading the output β the columns that matter:
# ncalls tottime percall cumtime percall filename:lineno(function)
# 300 0.012 0.000 4.812 0.016 db.py:44(get_user) <- 300 calls = N+1!
# High CUMTIME + high NCALLS on a DB/HTTP call => you found your bottleneck.
out.prof into snakeviz for a visual call tree.
py-spy
py-spy reads another process's stacks from the outside at a fixed sample rate. Zero
code changes, near-zero overhead, no restart β the right tool for a live high-load process
that's slow right now.
# Live top-like view of where CPU is going, by function (great first look):
py-spy top --pid 12345
# Record a flamegraph over 60s of real traffic β open the SVG in a browser:
py-spy record --pid 12345 --duration 60 --output flame.svg
# Process is HUNG / stuck? Dump every thread's current stack instantly:
py-spy dump --pid 12345
# β shows exactly which line each thread is parked on (a lock? a socket recv?)
py-spy dump on
a wedged worker is often a 10-second diagnosis of a deadlock or a blocking call that never returns.
Run it in a container with --cap-add SYS_PTRACE.
line_profiler
Once cProfile points at a function, line_profiler tells you which line
inside it costs the time.
# Decorate the target function with @profile (injected by kernprof, no import needed)
@profile
def score_batch(rows):
features = [extract(r) for r in rows] # suspect line
matrix = np.array(features) # suspect line
return model.predict(matrix)
# Run under kernprof, then read the per-line report:
kernprof -l -v score.py
# Output columns: Line # Hits Time Per Hit % Time Line Contents
# The "% Time" column points straight at the one line eating the function.
tracemalloc & memory_profiler
tracemalloc ships with CPython and attributes allocations to source lines. Snapshot,
run, diff β the top deltas are your leak or your buffer bloat (ties straight to
memory optimization).
import tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
handle_1000_requests() # the workload you suspect grows RSS
snap2 = tracemalloc.take_snapshot()
top = snap2.compare_to(snap1, "lineno") # biggest allocation growth first
for stat in top[:10]:
print(stat) # e.g. cache.py:88: size=412 MiB (+412 MiB), count=1.2M (+1.2M)
# memory_profiler: per-line RSS, like line_profiler but for memory
from memory_profiler import profile
@profile
def build_report(rows):
data = [transform(r) for r in rows] # watch the "Increment" column jump here
return summarize(data)
# run: python -m memory_profiler report.py β Mem usage / Increment per line
tracemalloc snapshots N minutes apart, diffed, name the exact line that
keeps growing β usually an unbounded cache, an accumulating list, or a logger holding references.
In asyncio, "slow" usually means something blocked the event loop β a sync call
(CPU work, requests.get, a blocking DB driver) stalls every coroutine. Turn on
debug mode; it logs any callback that hogs the loop.
import asyncio
# Debug mode: warns on slow callbacks, un-awaited coroutines, loop-blocking calls
asyncio.run(main(), debug=True)
# or: PYTHONASYNCIODEBUG=1 python app.py
# Also set the threshold explicitly:
loop = asyncio.get_running_loop()
loop.slow_callback_duration = 0.1 # warn if any callback blocks the loop > 100ms
py-spy dump --pid is gold here too: it shows whether the single loop thread is parked in
select() (healthy, waiting on I/O) or stuck inside a synchronous function (the bug β
move it to run_in_executor). For CPU-bound async, sample with py-spy record;
cProfile's call overhead distorts loop timing badly.
Everything above measures one process. Under load you need the system's behavior:
throughput and the latency distribution, generated by concurrent clients. locust
gives you a Python-defined workload; wrk/ab are quick one-liners.
# locustfile.py β a runnable load scenario
from locust import HttpUser, task, between
class ApiUser(HttpUser):
wait_time = between(0.1, 0.5) # think-time between requests per user
@task(3) # weight 3: hit this 3Γ as often
def read(self):
self.client.get("/api/items?page=1")
@task(1)
def write(self):
self.client.post("/api/items", json={"name": "x"})
# Ramp to 500 concurrent users, spawning 50/s, headless, for 2 minutes:
locust -f locustfile.py --host http://localhost:8000 \
--users 500 --spawn-rate 50 --run-time 2m --headless
# Quick alternatives (no scenario file):
wrk -t8 -c200 -d30s --latency http://localhost:8000/api/items # 200 conns, 30s
ab -n 10000 -c 200 http://localhost:8000/api/items # 10k reqs, 200 conc
| Percentile | Meaning | Why it matters under load |
|---|---|---|
| p50 (median) | Typical request | Baseline feel; hides the pain |
| p95 | 1 in 20 requests | Starts to bite when concurrency climbs |
| p99 / p99.9 | Slowest 1% / 0.1% | What users actually complain about; explodes first at the ceiling |
| Tool | What it's for | Overhead |
|---|---|---|
timeit | Micro A/B of a tiny snippet | None (it is the measurement) |
cProfile + pstats | Deterministic call-tree hotspots | High (2β5Γ); dev/staging only |
py-spy | Live sampling / flamegraph / stuck-thread dump in prod | Negligible; no code change |
line_profiler | Which line inside a hot function | High per-line; targeted use |
tracemalloc | Allocation attribution & leak diffs | Moderate (tracks every alloc) |
memory_profiler | Per-line RSS growth | High; targeted use |
| asyncio debug mode | Loop-blocking / slow callbacks | Lowβmoderate; not for prod |
locust / wrk / ab | Whole-system throughput + p50/p95/p99 | Runs against the system, not in it |