Profiling a Live Python Process with py-spy
The instance that is misbehaving right now is the one worth measuring, and it is the one that a restart destroys. py-spy reads a running process's stacks from outside it, which means no restart, no import, and no change to the application. This page covers getting access to the process, the two modes worth knowing, and reading what comes back. It is a task article under CPU profiling Python services, part of the Python profiling and performance observability section.
Prerequisites
pip install "py-spy>=0.3.14,<0.5.0"
# a debug container sharing the target pod's process namespace
kubectl debug -it checkout-7d9f8c5b6-xk2lm \
--image=ghcr.io/example/pyspy:0.4.0 --target=app --profile=general -- sh
Implementation
Step 1 — Get into the right namespace and find the process. The profiler must be able to see the target in its process table, which means sharing the process namespace. Inside an ephemeral debug container targeting the application container, the application is usually process one, but confirming rather than assuming saves a confusing minute.
py-spy dump --pid 1 2>/dev/null | head -5 || ps aux | grep -m1 python
Step 2 — Dump when the process appears stuck. A dump prints every thread's current stack once and exits. For a process that has stopped responding, this single command usually ends the investigation: the stack names the call it is waiting in, which is normally a lock acquisition, a socket read, or a native call that does not release the interpreter lock.
py-spy dump --pid 1 --locals
Expected Output: every thread, with the one that matters immediately obvious.
Process 1: gunicorn: worker [checkout]
Python v3.12.4
Thread 0x7f2a (active): "MainThread"
_recv_bytes (multiprocessing/connection.py:216)
recv (multiprocessing/connection.py:250)
get (multiprocessing/queues.py:103)
consume (app/worker.py:88)
Thread 0x7f31 (idle): "otel-export"
wait (threading.py:331)
_export (opentelemetry/sdk/trace/export/__init__.py:355)
Step 3 — Record when the process is slow rather than stuck. A recording samples over a window and aggregates. Sixty seconds at a hundred hertz is six thousand samples, which resolves anything consuming more than about one percent of the process's time and costs one or two percent of a core while it runs.
py-spy record --pid 1 --duration 60 --rate 100 \
--format speedscope --output /tmp/checkout-oncpu.json
Step 4 — Take the wall-clock version too. Without the idle option, only samples where a thread was executing are counted, so a service blocked on a database produces a nearly empty profile. Including idle threads turns the same window into a picture of where wall-clock time went, and the difference between the two profiles is the blocking.
py-spy record --pid 1 --duration 60 --idle \
--format speedscope --output /tmp/checkout-wallclock.json
Step 5 — Enable native frames when the Python frames stop explaining things. A process that is busy while its Python frames look idle is executing inside a C extension — a parser, a compression library, a database driver, a numerical routine. Native frames show what it is doing there, at the cost of a slower and more fragile capture.
py-spy record --pid 1 --duration 30 --native --output /tmp/native.svg
Step 6 — Write a format you can diff. A rendered flame graph is readable and not comparable. Speedscope or folded-stack output can be diffed against a stored baseline, which is what turns "this frame is eight percent" into "this frame was three percent last month".
Reading a dump during an incident
The dump output repays a little familiarity, because it is the fastest diagnostic available for a process that has stopped making progress and it is read under pressure.
The thread marked active is the one executing. In a process with a dozen threads, most will be idle in a wait or a poll. The active ones are where the work is, and if none is active while the service is unresponsive, the process is blocked on something outside itself.
A stack ending in a lock acquisition is a contention problem. Several threads whose innermost frame is the same acquire call, with one thread inside the critical section, is a lock bottleneck rendered as plainly as it can be. The frame below the acquisition names the lock's purpose.
A stack ending in a socket read is a dependency problem. The frames above it name which dependency. If the same read appears in a dump taken thirty seconds later, the dependency is not merely slow but unresponsive, and the timeout configuration on that call is the thing to look at.
A stack ending in native code, with no Python frame above it, is a C extension. This is where the native option earns its cost. Common cases are a regular expression with catastrophic backtracking, a compression or serialisation routine over a large object, and a database driver processing a very large result set.
Two dumps are better than one. Taking a second dump a few seconds after the first turns a snapshot into a comparison: frames that appear in both are where the process is stuck, and frames that change are where it is progressing slowly. That distinction is worth the extra command.
Making it available before it is needed
The recurring failure with this tool is not technical. It is that the first attempt to use it happens during an incident, discovers a missing capability, and turns into a conversation with a platform team while the service is degraded. Three preparations remove that entirely and take an hour between them.
Build and publish a profiler image. A small image containing the profiler and nothing else, versioned and available from the registry the cluster can pull from. Doing this during an incident means waiting for a build, and pulling a public image may be blocked by policy.
Agree and test the elevated debug profile. Attach to a healthy pod, take a dump, confirm it works, and write down the exact command. This both proves the arrangement and produces the one line somebody will need to paste at three in the morning.
Decide what is acceptable to capture. A profile contains file names, function names and — with the locals option — variable values, which can include data that should not leave the cluster. Deciding in advance whether profiles may be copied out, and where they may be stored, avoids an awkward conversation after the fact. In practice, function-level profiles are almost always fine and the locals option deserves more thought.
A fourth preparation is worth considering for services that are profiled regularly: a small endpoint, protected and internal, that triggers a recording and returns the path. It removes the container attachment step entirely, at the cost of the profiler being present in the application image, which is a trade some teams will take and others will not.
Configuration options
| Flag | When | Effect |
|---|---|---|
dump |
the process appears hung | one stack per thread, immediately |
record |
the process is slow | aggregate over a window |
--rate 100 |
default | resolves events above ~1% of the window |
--duration 60 |
default | long enough to be representative |
--idle |
latency investigations | includes blocked threads |
--native |
Python frames look idle | shows C extension frames |
--subprocesses |
prefork servers | follows workers from the master |
--format speedscope |
always worth it | comparable and diffable output |
Verification
Confirm the profiler is seeing the process you think it is, which is the step most often skipped and most often wrong in a multi-container pod.
# confirm the target's identity before trusting the profile
py-spy dump --pid 1 | head -3
cat /proc/1/cmdline | tr '\0' ' '; echo
Expected Output: the command line matching the application, not a shell, an init wrapper, or the wrong container's process.
Process 1: gunicorn: master [checkout]
Python v3.12.4
gunicorn --workers 4 --bind 0.0.0.0:8000 checkout.wsgi:application
A master process here means the recording should use the subprocess option, or it will describe a process whose only job is supervision.
Common mistakes
Profiling the master of a prefork server. Error signature: a profile containing only accept and supervision frames. Root cause: the work happens in worker processes. Remediation: add the subprocess option, or attach to a worker directly.
Recording without the idle option on a latency problem. Error signature: a nearly empty profile of a service that is demonstrably slow. Root cause: the threads are blocked, not executing. Remediation: record both views and compare, as in step 4.
Missing permission, discovered during an incident. Error signature: a permission denied error from the profiler while the service is down. Root cause: the capability is dropped by default and was never arranged. Remediation: agree the debug container profile in advance and test it once while nothing is wrong.
Interpreting a dump as an aggregate. Error signature: a conclusion drawn from one instant that a recording contradicts. Root cause: a dump is a single sample. Remediation: take two dumps, or record, when the question is about proportions rather than about where a process is stuck.
Frequently Asked Questions
Does py-spy need to be installed in the application image?
No, and it is better if it is not. It reads the target process's memory from outside, so it can run in a separate debug container that shares the process namespace, which keeps the application image free of tooling.
What permission does it need?
The ability to read another process's memory, which on Linux means the process trace capability or running as the same user with the appropriate kernel setting. In Kubernetes this usually means an ephemeral container with an elevated security profile, agreed with the platform team in advance.
Does it pause my service?
Briefly, for each sample, while it reads the target's memory. At a hundred samples per second the accumulated pause is well under a percent of wall-clock time, and in practice the overhead is not distinguishable from noise.
Why does the output show no Python frames?
Usually because the interpreter is a build py-spy cannot introspect, or the process is executing inside a native extension and native frames are not enabled. Adding the native option shows the C frames, which is often exactly where a mysteriously busy process turns out to be.