py-spy profiles a running Python process without changing a line of it
It reads a running interpreter's memory from outside the process, which is why attaching needs no code change and why Linux makes you clear ptrace first.
cProfile has to be there before the program starts
A worker has been pinning a core for twenty minutes. It is already
running, in production, under a supervisor that will restart it if it
dies. py-spy can attach to that process by its PID and report where it
is spending its time, without restarting it or modifying its code in
any way.1 The profiler you
would otherwise reach for, cProfile, cannot: it traces
every function call for complete coverage, but it has to be wrapped
around the program before that program starts.2
To use it here you would edit the entry point, redeploy, and hope the
slow path shows up on the next run. If the process is wedged right now
and you cannot restart it, the deterministic profiler is not slow to
reach for. It is unreachable.
Nothing py-spy does gets imported into the target.
py-spy reads the interpreter's memory from a separate process
The reason it needs no cooperation is that it does not run inside the
program it profiles. py-spy is a separate process, written in Rust, and
it reconstructs the target's Python call stack by reading that
process's memory directly: process_vm_readv on Linux,
vm_read on macOS, ReadProcessMemory on
Windows.1 It walks the
CPython interpreter state in the target's address space, follows the
chain of frame objects, and turns raw bytes into function names and
line numbers on its own side of the boundary.
Two properties follow from that design. The profiler adds no instrumentation to the target, so there is no per-call bookkeeping to slow the program down and no observer effect baked into the numbers. And because the work happens out of process in compiled code, the overhead the target pays is only the brief pause while a sample is read. The project describes the result as extremely low overhead.1 That is a self-report, not an independent benchmark, but the structure behind it is sound: reading many stacks a second from another process is cheap in a way that wrapping every call cannot be.
Attach to a running program and read its hotspot off the stack
The value shows up the moment you point it at a process you did not start under a profiler. Take a program whose cost is not where it appears to be. It looks like it serves requests; the time is actually in a recursive call.
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
def serve_request():
# reads like I/O, costs like arithmetic
return fib(34)
def main():
served = 0
while True:
served += serve_request()
main()
python slow.py, with no profiler and no instrumentation.
Start it the ordinary way in one terminal:
python slow.py. It is now a running, uninstrumented
process. Find it with pgrep -f slow.py, then ask py-spy
what its stack looks like right now.
$ py-spy dump --pid 48213
Thread 48213 (active): "MainThread"
fib (slow.py:6)
fib (slow.py:6)
fib (slow.py:6)
serve_request (slow.py:10)
main (slow.py:15)
<module> (slow.py:17)
dump prints the current call stack of each thread, resolved from the target's memory.1
The stack names the culprit without a single edit to the source. The
program is deep in fib, called from
serve_request, not blocked on a socket. Swap
dump for top and the same read repeats many
times a second into a live, sorted view, so a function that dominates
only intermittently still rises to the top. Swap it for
record and py-spy collects a window of samples and writes
a flame graph:
py-spy record -o slow.svg --pid 48213 --duration 20,1
where the width of each box is how often that frame was on the stack.
An engineer profiling a live
market-data feed pointed py-spy at the running PID and found the
time sitting in a WebSocket client's _listen method, and
caught logging inside an unneeded helper showing up high in the
profile, all read off a process that was already running.3
top, dump, and record answer three different questions
The three subcommands are not three renderings of one output. Each exists for a distinct question, and knowing which one you are asking is most of the skill.1
| Command | Question it answers | What you get |
|---|---|---|
| top | What is it spending time on, right now and continuously? | A live, sorted view that updates as samples arrive. |
| dump | Where is every thread stuck this instant? | One current call stack per thread, printed once. |
| record | Where did a whole window of the run go? | A flame graph over the sampling period. |
The one worth internalizing is dump. A single snapshot of
every thread's stack is exactly what you need when a program is hung
and you just have to see where it is wedged.1
A deadlock, a blocking call that never returns, a request that hangs
under load: dump turns each into one command against the
live PID, and it answers a question a deterministic profiler cannot
reach, because the deterministic profiler was never started.
The outside read is also where the costs come from
The same design that makes py-spy instrument-free is what forces its price, and the honest way to weigh the tool is to read each cost off the way it works.
Reading another process's memory is a privileged operation. On Linux,
attaching to a process py-spy did not itself start usually requires
root, or a system that has relaxed the ptrace_scope
sysctl; you avoid the requirement only when py-spy launches the
program for you.1 macOS
requires root either way. Inside a container the same call is blocked
by default, and the fix is to grant the
SYS_PTRACE capability to the container, with
--cap-add SYS_PTRACE under Docker or the equivalent in a
Kubernetes security context.1
None of this is exotic, but it is a permission you must plan for, and
it is the direct consequence of reading memory from outside.
Sampling has its own blind spots. Because py-spy records the stack a
fixed number of times per second rather than tracing every call, a
function that is short-lived or rarely on the stack can be missed,
where cProfile's exhaustive tracing would count it.2
The default read also pauses the target briefly for each sample; the
--nonblocking flag skips the pause but trades it for
occasional sampling errors, so the low overhead is very low, not
literally nothing.1 And by
default the stacks are pure Python. Time spent in a C or Cython
extension shows up as time in the Python line that called it, not in
the native code, unless you pass --native to unwind the
C and C++ frames alongside the Python ones.4
Since a large part of the Python ecosystem is native
extensions, a profile without --native can point at a
call site while the real cost hides one layer below it.4
The release feed reads bursty, not abandoned
A tool you attach to production is a tool you have to trust, so its maintenance is a fair question. The release history is uneven. A cluster of 0.3.x releases landed in mid-2022, then the feed goes quiet for roughly twenty-six months until v0.4.0 in November 2024, before a fresh pair, v0.4.1 and v0.4.2, arrived together in April 2026.5 Anyone hoping for a monthly cadence should adjust that expectation.
The recency argues the other way, though. The current release is v0.4.2 from April 2026,6 and py-spy supports CPython through 3.14,1 which is the test that matters for a profiler: it is being kept in step with the interpreter whose memory it reads, and a stale reader of a new interpreter would simply fail to resolve stacks. Installation is a prebuilt wheel across the common platforms, so adopting it does not drag in a Rust toolchain.6
py-spy earns a place in the kit for one job it does that the deterministic profilers cannot: reading a running, uninstrumented process. Keep cProfile for exhaustive, deterministic timing during development, and reach for py-spy when the process is already live, already wedged, or cannot be restarted. Trust rests on it tracking CPython 3.14 in a 2026 release;1 the assessment would change if a future interpreter shipped and the feed stayed silent.
Sources
- benfred/py-spy · README and project documentation
- OneUptime · How to Profile Python Applications with cProfile and py-spy
- Wrighters.io · Profiling Python code with py-spy
- Ben Frederickson · Profiling Native Python Extensions with py-spy
- benfred/py-spy · Release history
- PyPI · py-spy 0.4.2 (released 2026-04-24)