Profiling Python Code
Introduction¶
Optimising without profiling wastes effort on code that isn't the bottleneck. Python has good tools for both CPU and memory profiling; pick based on whether you can restart the process and whether you're chasing time or RAM.
CPU: cProfile (a script you control)¶
python -m cProfile -o profile.out -m myapp
# or in code:
import cProfile, pstats
with cProfile.Profile() as pr:
run_the_thing()
stats = pstats.Stats(pr).sort_stats("cumulative")
stats.print_stats(20)
Reading the output:
tottime— time in the function itself, excluding subcalls. The real hot spot.cumtime— time including everything it calls. High cumtime + low tottime = "this function is a bottleneck because of what it calls".ncalls— call count. A cheap function called 10M times (an N+1, a per-row operation) shows up here.
Visualise:
pip install snakeviz
snakeviz profile.out # interactive icicle graph in the browser
CPU: py-spy (a running process, no code change, no restart)¶
pip install py-spy
py-spy top --pid 4242 # live, like `top` but for Python frames
py-spy dump --pid 4242 # current stack of every thread
py-spy record -o flame.svg --pid 4242 --duration 30 # flame graph
py-spy samples the process from outside (reads its memory), so near-zero overhead and it works on production. The flame graph shows where wall-clock time actually goes — wide frames are the cost. This is usually the fastest way to diagnose "the server is slow right now".
Line-level¶
pip install line_profiler
@profile # line_profiler injects this
def hot_function():
...
kernprof -l -v script.py
Per-line timing — use once cProfile/py-spy has pointed you at the function.
Memory: tracemalloc (stdlib)¶
import tracemalloc
tracemalloc.start()
# ... run ...
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
print(stat)
Shows which lines allocated the most currently-live memory. Take two snapshots
and compare_to to find a leak between two points.
Memory: memray (richer, for the hard cases)¶
pip install memray
memray run -o out.bin script.py
memray flamegraph out.bin # -> out.html, allocation flame graph
memray tree out.bin
Tracks every allocation (including in C extensions), shows peak memory, and produces flame graphs by allocation site. The tool for "why does this use 4GB".
Microbenchmarks: timeit¶
import timeit
timeit.timeit("'-'.join(str(n) for n in range(100))", number=100_000)
python -m timeit -s "data=list(range(1000))" "sum(data)"
For comparing two implementations of a small piece. pytest-benchmark if you
want it in the test suite with statistics.
Reading a profile — the workflow¶
- py-spy or cProfile → which function dominates (
tottime/ flame width). - If it's your code → line_profiler on that function.
- If it's a library call → is it called too often (
ncalls)? Batch it. Or is each call slow? Different approach / library. - Common findings: N+1 queries, JSON parsing in a loop, a regex compiled per
call,
list.insert(0, ...)in a loop, unnecessary.copy(), aforloop that should be a vectorised/setoperation, logging with f-strings at a disabled level.
Verification and troubleshooting¶
- Profile time doesn't add up to wall time — the process is I/O-bound (waiting on network/disk/locks), not CPU-bound. cProfile shows CPU; a py-spy flame graph shows wall time including waits. If it's I/O, profiling CPU won't help — look at query counts, concurrency.
- cProfile makes the program 2–3× slower — its overhead; the relative numbers are still valid. Use py-spy for realistic absolute timing.
- py-spy: "Failed to find python interpreter" — the target is in a container
(run py-spy in the same container /
--pidwith--full-filenames), or it's a stripped/statically-linked build, or you needsudo/SYS_PTRACE. - tracemalloc shows small numbers but RSS is huge — memory held by C extensions (numpy, a database driver), fragmentation, or memory freed to the allocator but not the OS. Use memray.
- Optimised the hot function, no overall speedup — it wasn't actually the bottleneck (re-profile), or you're now bound by something else. Profile again after every change.
- timeit numbers vary wildly — background load, GC, or too few iterations.
Raise
number, run several times, take the min.
Related tools and reading¶
- On-site: Timestamp converter.
- Related posts: pytest fixtures and parametrize, asyncio for I/O-bound scripts.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Python
asyncio for I/O-Bound Scripts
Async Python shines for hundreds of concurrent network calls and nothing else. Here's a correct pattern for a bulk-fetch script, with concurrency limits and error handling.
October 24, 2026Python Type Hints That Actually Pay Off
Type hints earn their keep at function boundaries and data models. Here's a pragmatic subset, a CI setup, and how to adopt them on an untyped codebase.
October 24, 2026Packaging a Python CLI
Turn a script into a `pip install`-able command: the pyproject.toml, the entry point, the src layout, and how users should install it.
October 24, 2026