Profiling — cProfile, py-spy, and memory profilers

Statistical vs deterministic profiling, flame graphs, and finding the hot 5% that owns 95% of the time.

⚡ Module 7 9 min read Not started

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

  1. Use timeit for micro-benchmarks.
  2. Use cProfile + snakeviz for CPU profiles.
  3. Use py-spy for production sampling.
  4. Use memray / tracemalloc for memory.
  5. Read flame graphs.

1. The rules of optimisation

  1. Don't. Build it correctly first.
  2. Measure. Profile before changing anything.
  3. Find the hot path. 95% of the time is in 5% of the code.
  4. Optimise the hot path. Pick the largest cost, not the most interesting.
  5. 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

python
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:

python
%timeit '-'.join(str(n) for n in range(100))
%%timeit
total = 0
for x in big_list:
    total += x

timeit 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:

python
def test_sort(benchmark):
    benchmark(sorted, [random.random() for _ in range(10000)])

3. cProfile — deterministic CPU profile

python
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 function

From the command line:

powershell
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

powershell
uv add --dev snakeviz
uv run snakeviz out.prof

Opens 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

powershell
uv tool install py-spy           # standalone CLI; doesn't pollute project env
powershell
py-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 threads

py-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

powershell
uv add --dev scalene
uv run scalene my_script.py

Scalene 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

powershell
uv add --dev line_profiler
python
@profile                                # injected by kernprof
def slow():
    total = 0
    for i in range(10**6):
        total += i * i
    return total
powershell
uv run kernprof -l -v my_script.py

Output:

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 total

Tells you exactly which lines burn the time. Powerful for tight numeric code.


7. memray — memory profiler

powershell
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.bin

memray 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:

powershell
uv run memray run --live my_script.py

Bloomberg 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:

python
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 B

Useful for "is memory growing on each iteration?" diagnostics in tests / long-running services.


9. py-spy dump for stuck processes

If a process hangs:

powershell
py-spy dump --pid 12345

Prints 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:

python
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 out

Profile:

powershell
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:

python
def normalise(rows):
    out = []
    for row in rows:
        total = sum(row.values())
        out.append({k: v / total for k, v in row.items()})
    return out

Still slow on huge data → switch to NumPy / Polars:

python
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 / sums

10-100× faster. Profile shows the bottleneck is now I/O.


13. Checklist for "is this slow?"

  1. Run with --profile (cProfile for dev, py-spy for prod).
  2. Identify top function(s) by cumtime.
  3. Check time complexity. in list on big data → switch to set.
  4. Check allocation. Lots of small objects in a loop → batch / NumPy / preallocate.
  5. Check call count. Calling len(list) inside a tight loop is fine; calling db.query() inside is fatal.
  6. Check I/O wait. Is the bottleneck CPU, disk, network, GPU? Different fixes.
  7. Re-profile after each change. Verify gain.

Hands-on lab (2 hours)

  1. Write a slow function (e.g., quadratic substring search); profile with cProfile; identify hot lines.
  2. Run snakeviz on the profile.
  3. Install py-spy; run a long script; capture a flame graph.
  4. Use line_profiler to optimise a tight numeric loop.
  5. Use memray to find which line allocates most memory in a pandas.read_csv of a big file.
  6. Use tracemalloc to verify a function doesn't leak memory between calls.
  7. Profile an async script with py-spy; identify async hotspots.

Common pitfalls

  1. Optimising without profiling.
  2. Using cProfile on tiny snippets (overhead dominates) — use timeit instead.
  3. Forgetting that wall-clock != CPU time on I/O-bound code.
  4. Assuming the first hot function is the bug — often it's a caller burning lots of time setting up.
  5. Reading tottime when you wanted cumtime (and vice versa).
  6. Running profilers on hot production paths without sampling (cProfile/line_profiler add overhead; py-spy is safe).

Self-check

  1. Why use sampling over deterministic profilers in production?
  2. What does a flame graph show on x-axis vs y-axis?
  3. What does cumtime mean?
  4. When use memray vs tracemalloc?
  5. When use timeit vs cProfile?

References

Sign in to save your progress and earn badges.