~/blog/profiling-python.md

Profiling Python Before You Guess

· #python #performance

Someone filed an issue against a scraper API I maintain: “batch endpoint is slow for large lists.” No numbers, no repro size, just a vibe. My first instinct was to rewrite the parsing loop with a comprehension instead of appends, because that felt like the obvious win. I didn’t touch it. I profiled first, and the comprehension would have saved nothing — the actual cost was a compiled regex being recompiled on every single call.

That’s the whole argument for profiling before guessing: your intuition about what’s slow in Python is wrong more often than it’s right, because the interpreter’s costs don’t line up with what “looks expensive” to a human reading the code.

cProfile: the blunt instrument, first

cProfile instruments every function call and gives you a full picture in one run. It’s the right first step precisely because it’s dumb — it doesn’t require you to already know where to look.

python -m cProfile -o out.prof -m myapp.batch_endpoint sample_input.json
python -c "
import pstats
p = pstats.Stats('out.prof')
p.sort_stats('cumulative').print_stats(15)
"

The output for that “slow” batch endpoint looked like this, trimmed:

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    10000    1.812    0.000    1.812    0.000 re.py:251(compile)
    10000    0.041    0.000    1.853    0.000 parser.py:88(extract_id)
        1    0.003    0.003    1.856    1.856 batch_endpoint.py:12(run)

Ten thousand items, ten thousand calls to re.compile, 1.8 seconds spent compiling the same pattern every time. The comprehension rewrite I almost did instead would have touched tottime of 0.041s — noise next to the actual problem.

The fix, obvious once you see it

import re

# WRONG: recompiled on every call
def extract_id(text: str) -> str | None:
    m = re.search(r"video/(\d+)", text)
    return m.group(1) if m else None

# RIGHT: compiled once at import time
_VIDEO_ID_RE = re.compile(r"video/(\d+)")

def extract_id(text: str) -> str | None:
    m = _VIDEO_ID_RE.search(text)
    return m.group(1) if m else None

re does cache compiled patterns internally, but that cache has a small default size and gets evicted under enough distinct patterns — relying on it instead of compiling once yourself is exactly the kind of “should be fine” assumption profiling exists to check.

py-spy for the case cProfile can’t touch: production

cProfile adds real overhead — 20-30% isn’t unusual — and it needs you to run the code under it, which you often can’t do against a live process handling real traffic. py-spy attaches to a running PID from outside the process and samples the call stack, so you can point it at production without restarting anything or shipping instrumented code.

sudo py-spy top --pid 41213
Total Samples 1400
GIL: 0.00%, Active: 94.00%, Threads: 5

  %Own   %Total  OwnTime  TotalTime  Function (filename)
 61.00%  61.00%   8.54s     8.54s   json.loads (json/decoder.py)
 18.00%  18.00%   2.52s     2.52s   decompress (gzip.py)
  9.00%   9.00%   1.26s     1.26s   extract_id (parser.py)

That’s a live worker, sampled while it was actually serving traffic, no restart, no code change. It told me a different service was CPU-bound on JSON decoding of a response we didn’t need most fields from — a py-spy dump for a full stack trace on a stuck worker has also saved me from restarting a hung service blind more than once.

What I learned

Profile before you change anything, even when you’re confident. The confident guess is usually confidently wrong, because Python’s cost model is unintuitive: object allocation, attribute lookup, and regex compilation are expensive in ways that don’t match how “heavy” the code looks on the page. cProfile for a controlled run, py-spy for a process you can’t stop — that two-tool loop has replaced every “this looks slow” instinct I used to trust.

cd .. cd ~ (back to terminal)