The Nightly Build.
Expert Tools

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.

slow.pyPYTHON
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()
Fig. 1 · A program started with plain 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.

attach to the live PIDCONSOLE
$ 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)
Fig. 2 · 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

The question each subcommand answers, and what it returns.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

Verdict

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

  1. benfred/py-spy · README and project documentation
  2. OneUptime · How to Profile Python Applications with cProfile and py-spy
  3. Wrighters.io · Profiling Python code with py-spy
  4. Ben Frederickson · Profiling Native Python Extensions with py-spy
  5. benfred/py-spy · Release history
  6. PyPI · py-spy 0.4.2 (released 2026-04-24)