Hermes Wiki
Developer/Languages/Python/PerformanceOptimization/Fundamentals/profiling-python-cprofile-py-spy-and-line-profiler

Profiling Python: cProfile, py-spy, and line_profiler

Concept

"This code is slow" is not a diagnosis, and guessing at the hot path from reading source is unreliable even for experienced engineers — the actual bottleneck is frequently somewhere unexpected (a logging call that serializes a large object on every request, an accidentally-quadratic list operation, a synchronous DNS lookup buried three layers deep in a library). Python has three complementary tools for finding it, each trading completeness for overhead differently:

  • cProfile — Python's built-in deterministic profiler. It instruments every function call/return via the interpreter's call machinery, so it captures 100% of calls and gives exact counts and cumulative time per function. Deterministic tracing has real overhead (function-call interception on every single call), which distorts wall-clock timing on code with many small, cheap calls — the tool itself becomes the confound.
  • py-spy — a statistical sampling profiler written in Rust. Instead of instrumenting every call, it periodically samples the target process's call stack from outside the process (reading its memory directly), which means near-zero overhead and, critically, the ability to attach to an already-running production process without restarting it or changing a line of code.
  • line_profiler — line-by-line deterministic timing, but scoped: it only measures functions explicitly decorated with @profile, trading cProfile's whole-program view for granularity within a function you've already narrowed down.

Tradeoffs

Tool Best for Cost
cProfile Whole-program, function-level view with exact call counts; zero extra dependencies (stdlib) Instrumentation overhead skews results on call-heavy code; output is per-function, not per-line, so a slow function's specific line still needs a follow-up tool
py-spy Production processes — attaches externally with negligible overhead, no code changes, no restart; works on already-running, possibly stuck or deadlocked processes Sampling means rare/fast code paths can be missed entirely if they don't get sampled; less precise for functions called many times very briefly
line_profiler Pinpointing which exact line inside an already-identified slow function is the actual cost Requires modifying source (@profile decorator) and running under kernprof; narrow by design — useless for finding which function to look at in the first place

When to use / when not to

  • Start with cProfile locally (or in a dev/staging environment where restart cost is free) to get the whole-program shape — which functions dominate cumulative time — before diving into any one function.
  • Reach for py-spy the moment the target is a live production process: a web worker that's mysteriously slow under real traffic, a batch job you can't afford to restart, or a process that appears hung (py-spy dump prints the current stack of every thread without stopping the process at all).
  • Drop to line_profiler only after cProfile has already told you which function is expensive — using it as a first-pass tool wastes effort decorating functions that turn out not to matter.
  • Don't trust cProfile's absolute wall-clock numbers on code dominated by many cheap function calls (tight loops calling small helpers) — the instrumentation overhead itself becomes a meaningful fraction of the measured time; use it for relative ranking between functions, and cross-check anything surprising with py-spy's sampling view, which has no such distortion.
  • Don't reach for a profiler at all before establishing that the slowness is CPU-bound in Python itself — I/O-bound waits (a slow downstream API, an unindexed database query) show up as "slow" too, but need tracing/APM or a database EXPLAIN, not a Python-level CPU profiler.

Common pitfall

Profiling in an environment that doesn't match production and drawing conclusions anyway — a local run against a tiny dataset, with no concurrent load, on different hardware, routinely surfaces a different hot path than the one actually dominant under real traffic. A function that's negligible against 100 rows of test data can be the dominant cost against production's millions, and vice versa (a cache that only pays off under real request diversity looks like pure overhead against a benchmark that hits the same key every time). The fix is not "profile harder" locally — it's using a low-overhead, externally-attachable tool like py-spy against the real production process during real load, at least once, before trusting any optimization decision made from a local profile.

Engineering Lens

The instinct to reach for a profiler at the first sign of slowness is right, but the tool choice signals whether the investigation is actually going to find the real bottleneck. A cProfile run against a synthetic local repro is a reasonable first pass for a known-reproducible CPU-bound issue, but "the API feels slow in prod, not sure why" calls for py-spy attached to a live worker — its ability to sample a running process externally, with no restart, is the difference between root-causing an intermittent production issue in minutes versus trying (and often failing) to reproduce it locally first. Treat line_profiler's line-level output as the last step, not the first: it answers "why is this specific function slow," which is only useful once cProfile or py-spy has already told you which function that is.

Sources

Hermes Wiki