Profiling — cProfile, py-spy, and memory profilers
Statistical vs deterministic profiling, flame graphs, and finding the hot 5% that owns 95% of the time.
Why this matters
Optimising the wrong code wastes weeks. Measure first. Python has excellent profilers: cProfile for CPU, py-spy for sampling without code changes, memray for memory, line_profiler for line-by-line. Knowing how to read profiler output is the difference between "I added caching everywhere" and "I changed 5 lines and got 50× faster."
Learning objectives
- Use
timeitfor micro-benchmarks. - Use
cProfile+snakevizfor CPU profiles. - Use
py-spyfor production sampling. - Use
memray/tracemallocfor memory. - Read flame graphs.
1. The rules of optimisation
- Don't. Build it correctly first.
- Measure. Profile before changing anything.
- Find the hot path. 95% of the time is in 5% of the code.
- Optimise the hot path. Pick the largest cost, not the most interesting.
- Re-measure. Verify improvement.
Donald Knuth: "Premature optimisation is the root of all evil." The corollary: deferred optimisation makes architectures unfixable. Profile early enough to inform design, late enough that you have working code.
2. timeit — micro-benchmarks
import timeit
timeit.timeit("'-'.join(str(n) for n in range(100))", number=10000)
timeit.timeit("'-'.join([str(n) for n in range(100)])", number=10000)
timeit.timeit("'-'.join(map(str, range(100)))", number=10000)In IPython / Jupyter:
%timeit '-'.join(str(n) for n in range(100))
%%timeit
total = 0
for x in big_list:
total += xtimeit runs many iterations and reports the best. Warns about garbage collection and other noise. Use for "is A faster than B for this 1-line operation?"
For larger snippets, use pytest-benchmark:
def test_sort(benchmark):
benchmark(sorted, [random.random() for _ in range(10000)])3. cProfile — deterministic CPU profile
import cProfile, pstats
def main():
...
cProfile.run("main()", "out.prof")
p = pstats.Stats("out.prof")
p.sort_stats("cumulative").print_stats(20) # top 20 by cumulative time
p.sort_stats("tottime").print_stats(20) # top 20 by total time in functionFrom the command line:
uv run python -m cProfile -o out.prof my_script.py
uv run python -c "import pstats; pstats.Stats('out.prof').sort_stats('cumulative').print_stats(30)"Columns:
ncalls: how many times the function was called.tottime: total time spent inside this function, excluding sub-calls.percall: tottime / ncalls.cumtime: cumulative time, including sub-calls.filename:lineno(function): where.
Sort by cumtime first to find "the function that owns most of the wall-clock time."
Visualise with snakeviz
uv add --dev snakeviz
uv run snakeviz out.profOpens a browser with an interactive sunburst — much easier to navigate than text output.
cProfile adds 30-100% overhead. Fine for development; do not run in production.
4. py-spy — sampling profiler for production
uv tool install py-spy # standalone CLI; doesn't pollute project envpy-spy top --pid 12345 # live, like htop
py-spy record -o flame.svg --pid 12345 --duration 60 # capture for 60s
py-spy record -o flame.svg -- python my_script.py # profile a fresh run
py-spy dump --pid 12345 # one snapshot of all threadspy-spy reads stack traces from the running interpreter without any cooperation from your code. Almost no overhead — safe to run on production processes.
Output is a flame graph (SVG). Width = time spent. Wider = more expensive. Stack depth on the y-axis.
Read it: look for the widest bars at the top — that's where time is spent.
5. scalene — CPU + GPU + memory in one
uv add --dev scalene
uv run scalene my_script.pyScalene profiles CPU, memory, and (GPU on supported platforms) at the line level — and uses AI hints to suggest optimisations. Excellent first-stop for "what's slow and what allocates?"
Output is an HTML page. Read it like a profiler with calories: cost per line.
6. line_profiler — line-by-line CPU
uv add --dev line_profiler@profile # injected by kernprof
def slow():
total = 0
for i in range(10**6):
total += i * i
return totaluv run kernprof -l -v my_script.pyOutput:
Line # Hits Time Per Hit % Time Line Contents
=====================================================
3 1 1.0 1.0 0.0 total = 0
4 1000001 320.0 0.0 30.5 for i in range(10**6):
5 1000000 730.0 0.0 69.5 total += i * i
6 1 0.5 0.5 0.0 return totalTells you exactly which lines burn the time. Powerful for tight numeric code.
7. memray — memory profiler
uv add --dev memray
uv run memray run --output mem.bin my_script.py
uv run memray flamegraph mem.bin # opens flame graph
uv run memray summary mem.bin
uv run memray stats mem.binmemray traces every allocation. Identifies:
- Leaks (allocated but never freed).
- Peak memory.
- Hot allocators (which lines allocate the most).
Run with --live for real-time TUI:
uv run memray run --live my_script.pyBloomberg open-sourced this; it's the gold standard for memory profiling in Python.
8. tracemalloc — stdlib memory tracing
For lightweight memory introspection without an external tool:
import tracemalloc
tracemalloc.start()
... your code ...
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
print(stat)Output:
script.py:42: size=12.3 MiB, count=1, average=12.3 MiB
script.py:88: size=1.2 MiB, count=10000, average=125 BUseful for "is memory growing on each iteration?" diagnostics in tests / long-running services.
9. py-spy dump for stuck processes
If a process hangs:
py-spy dump --pid 12345Prints the current Python stack of every thread. The fastest way to find "we're blocked on a lock.acquire() somewhere." No gdb, no source modifications.
For async: py-spy works with asyncio too — see --threads for per-thread output.
10. Profiling async code
cProfile works on async code but the output is harder to read (the event loop appears at the top of every stack).
py-spy handles async cleanly — coroutine frames show up properly.
For per-coroutine timing, instrument with time.perf_counter() around await points, or use aiomonitor / aiodebug for interactive inspection.
For tracing distributed async (across services / tasks), use OpenTelemetry — instrument once, see traces in Datadog / Jaeger / Honeycomb / Arize / Langfuse.
11. Reading flame graphs
A flame graph has:
- X-axis: time / sample count. Wider bar = more time spent.
- Y-axis: call stack depth. Top = the function actually running; below = its callers.
- Colour: usually random (hue is not meaningful).
To find the hot spot, scan the top of the graph for wide bars. That's where CPU is burning. Walk down to see what called it.
In sampling profiles (py-spy, scalene), bar width is proportional to wall clock time spent in that frame.
12. Worked example: speeding up a slow function
Before:
def normalise(rows):
out = []
for row in rows:
total = 0
for v in row.values():
total += v
norm = {k: v / total for k, v in row.items()}
out.append(norm)
return outProfile:
uv run python -c "
import cProfile
from mymod import normalise
data = [{'a': i, 'b': i+1} for i in range(100000)]
cProfile.run('normalise(data)', sort='cumulative')
"Suppose 80% is in the inner loops. Replace with comprehension + sum() + caching:
def normalise(rows):
out = []
for row in rows:
total = sum(row.values())
out.append({k: v / total for k, v in row.items()})
return outStill slow on huge data → switch to NumPy / Polars:
import numpy as np
def normalise_np(rows):
arr = np.array([[row["a"], row["b"]] for row in rows])
sums = arr.sum(axis=1, keepdims=True)
return arr / sums10-100× faster. Profile shows the bottleneck is now I/O.
13. Checklist for "is this slow?"
- Run with
--profile(cProfilefor dev,py-spyfor prod). - Identify top function(s) by cumtime.
- Check time complexity.
in liston big data → switch to set. - Check allocation. Lots of small objects in a loop → batch / NumPy / preallocate.
- Check call count. Calling
len(list)inside a tight loop is fine; callingdb.query()inside is fatal. - Check I/O wait. Is the bottleneck CPU, disk, network, GPU? Different fixes.
- Re-profile after each change. Verify gain.
Hands-on lab (2 hours)
- Write a slow function (e.g., quadratic substring search); profile with
cProfile; identify hot lines. - Run
snakevizon the profile. - Install
py-spy; run a long script; capture a flame graph. - Use
line_profilerto optimise a tight numeric loop. - Use
memrayto find which line allocates most memory in apandas.read_csvof a big file. - Use
tracemallocto verify a function doesn't leak memory between calls. - Profile an async script with
py-spy; identify async hotspots.
Common pitfalls
- Optimising without profiling.
- Using
cProfileon tiny snippets (overhead dominates) — usetimeitinstead. - Forgetting that wall-clock != CPU time on I/O-bound code.
- Assuming the first hot function is the bug — often it's a caller burning lots of time setting up.
- Reading
tottimewhen you wantedcumtime(and vice versa). - Running profilers on hot production paths without sampling (
cProfile/line_profileradd overhead; py-spy is safe).
Self-check
- Why use sampling over deterministic profilers in production?
- What does a flame graph show on x-axis vs y-axis?
- What does
cumtimemean? - When use
memrayvstracemalloc? - When use
timeitvscProfile?
References
- High Performance Python, 2nd ed., Gorelick & Ozsvald.
py-spydocs: https://github.com/benfred/py-spy.memraydocs: https://bloomberg.github.io/memray/.scalenedocs: https://github.com/plasma-umass/scalene.- Brendan Gregg, "Flame Graphs": https://www.brendangregg.com/flamegraphs.html.
- Python
profile/pstatsdocs.
Sign in to save your progress and earn badges.