Server

Profiling Python Code

By Prabath Thalangama· October 24, 2026· 4 min read
#python#performance#profiling

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

  1. py-spy or cProfile → which function dominates (tottime / flame width).
  2. If it's your code → line_profiler on that function.
  3. If it's a library call → is it called too often (ncalls)? Batch it. Or is each call slow? Different approach / library.
  4. Common findings: N+1 queries, JSON parsing in a loop, a regex compiled per call, list.insert(0, ...) in a loop, unnecessary .copy(), a for loop that should be a vectorised/set operation, 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 / --pid with --full-filenames), or it's a stripped/statically-linked build, or you need sudo / 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.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.