The first time a Python service wedges in production, you learn the limits of cProfile fast. It needs to wrap your program from the start, it slows the target down enough to smear the numbers you care about, and it has nothing to say about a process that’s already running and misbehaving right now. For years the answer was py-spy, an external Rust tool you pip install and point at a PID. Python 3.15 finally puts that capability in the standard library: profiling.sampling, codenamed Tachyon.

TL;DR

profiling.sampling is a statistical profiler that reads a live process’s call stack from the outside, so it adds close to zero overhead and needs no code changes. You can run it against a script or attach it to an already-running PID, then export a flamegraph, a Firefox-Profiler trace, or a live terminal view. It does not give you exact call counts the way cProfile does; sampling trades precision for the ability to profile anything, anywhere, without restarting it. If you’ve been reaching for py-spy, the stdlib now covers the same ground, with async-aware stacks and a --mode gil view that py-spy doesn’t have.

FactValueSourceVerified
Moduleprofiling.sampling (codename Tachyon)Python 3.15 docsSep 22, 2026
First shippedPython 3.15 stdlib (PEP 799)peps.python.orgSep 22, 2026
Default sampling rate1 kHz (reported)Python 3.15 docsSep 22, 2026
Max sampling rate1 MHz (reported)What’s New in 3.15Sep 22, 2026
3.15 final releaseOct 1, 2026python.orgSep 22, 2026

Why sampling, and why now

A deterministic profiler like cProfile hooks every function call and return. That gives you exact call counts, but the instrumentation tax is real: a function called ten million times pays the hook cost ten million times, and the slowdown distorts the ratios you’re trying to read. It also can’t attach after the fact, so you have to have started your program under the profiler.

A sampling profiler works the opposite way. It peeks at the interpreter’s call stack on a timer (a thousand times a second by default) and counts how often each function shows up. If parse_row appears in 40% of the samples, it’s using roughly 40% of the wall-clock time. You lose exact counts and you’ll miss functions that finish faster than the sampling interval, but the overhead is bounded and the target keeps running at full speed. That’s the trade every production profiler makes, and for the question “where is this thing spending its time,” it’s the right one.

The 3.15 release reorganizes the whole profiling story into a profiling package. profiling.tracing is the deterministic profiler you already know (cProfile stays as an alias), and profiling.sampling is the new sampler. The What’s New in 3.15 doc puts the ceiling at up to 1,000,000 Hz, and because the sampler reads memory from outside the target, it can profile a process that’s stuck without so much as a signal.

1 MHz
Max sampling rate (reported)
0
Lines of code to change
3.15
First Python version in stdlib

Getting the 3.15 beta

Everything below runs on the 3.15 beta, which is installable now ahead of the October 1 final release. Do not put this on a production box: the JIT and free-threading builds are still experimental, and I’m using it here purely to profile. 3.15 has other draws too, like lazy imports, but the profiler is what pulled me onto the beta early. The cleanest way to grab a throwaway 3.15 is uv:

# Install a 3.15 beta interpreter without touching your system Python
uv python install 3.15
uv python pin 3.15        # optional: pin it in the current project
python -m profiling.sampling --help

If you’re not on uv, pyenv install 3.15.0b3 or the official installers from python.org work the same way. One thing worth knowing before you get surprised by it: the profiler and the target process have to be the same Python minor version, and for pre-releases they have to match exactly. I learned this the annoying way. My service was on 3.15.0b2, my profiler venv had already rolled to b3, and attach refused to connect until I lined them up. Keep both on the same build.

Profiling a script from the top

The simplest case is a script you can start yourself. There are four subcommands — run, attach, dump, and replay (which re-renders a saved profile into another output format) — and run launches your code under the sampler:

python -m profiling.sampling run slow_report.py

By default you get a pstats-style table sorted by sample count. Here’s the shape of the output from a small report-builder I threw together (a script that reads a CSV, normalizes some rows, and writes JSON):

Profile Stats (12,481 samples @ 1000 Hz, wall mode, 12.4s)

  nsamples  sample%   tottime  cumtime  function
     5,102    40.9%     5.08s    5.08s  normalize_row (report.py:44)
     3,240    26.0%     3.22s    9.90s  build_rows (report.py:31)
     1,988    15.9%     1.98s    1.98s  json.encoder.encode
       910     7.3%     0.90s   12.30s  main (report.py:12)
       ...

Read sample% first: sampling estimates time from sample counts, so the percentage is the honest signal. Here normalize_row is eating 40% of wall time on its own, which is exactly the kind of hot spot you want a profiler to hand you in one line. Sort by a different column when you need to:

python -m profiling.sampling run --sort=cumtime --limit=30 slow_report.py

A text table is fine for a quick look, but the moment the call graph gets deep you want a flamegraph. The sampler writes a self-contained interactive HTML file, no external flamegraph.pl step:

python -m profiling.sampling run --flamegraph -o report.html slow_report.py
python -m profiling.sampling run --flamegraph --browser slow_report.py   # auto-open

For the Firefox Profiler UI, export a Gecko trace and drop it into profiler.firefox.com:

python -m profiling.sampling run --gecko -o report.json slow_report.py

Attach to a running process

Attaching to a live process is what changes day-to-day debugging. When a worker is pegged at 100% CPU or hung on something and you can’t reproduce it locally, you attach to the running process and watch:

# Live htop-style view of an already-running worker, by PID
python -m profiling.sampling attach --live 48213

The --live view is a terminal dashboard that refreshes in place, with single-key controls: s cycles the sort order, / filters to functions matching a pattern, t toggles between an all-threads roll-up and per-thread views, and q quits. It reads like top for your call stack:

 profiling.sampling — live — PID 48213 — 2,043 samples/s — wall

  sample%   function
   61.2%    _read_body (uvicorn/protocols/http/h11.py:210)
   18.4%    json.loads
    9.1%    Model.validate (pydantic/main.py)
    ...
   [s]ort  [/]filter  [t]hreads  [p]ause  [q]uit

If you’d rather capture and walk away, record a fixed window straight to a flamegraph:

# Sample PID 48213 for 30 seconds, write an interactive flamegraph
python -m profiling.sampling attach --flamegraph -d 30 -o worker.html 48213

Two flags matter here. By default the sampler follows only the main thread — for a threaded server you almost always want -a/--all-threads. And if you just want a one-shot snapshot of what every thread is doing right now (the “why is this hung” question), skip sampling entirely and dump the stacks once:

python -m profiling.sampling dump --all-threads 48213

A note on permissions, because the first attach will probably fail without them. Reading another process’s memory needs privilege: ptrace on Linux (either run as root or set ptrace_scope, or have a parent-child relationship), task_for_pid on macOS (usually sudo), and SeDebugPrivilege on Windows. On a Linux dev box the quickest unblock is:

# Allow attaching to non-child processes for this session (dev only)
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

Wall vs CPU vs GIL: read the right clock

The --mode flag is what makes the sampler more than a py-spy clone. Same process, three questions:

  • --mode wall (default) counts every sample, working or waiting. This is the one that shows I/O: if a request handler is stuck in _read_body, wall mode surfaces it.
  • --mode cpu counts only samples where the thread is actually executing. Compare it against wall mode and the gap is your waiting time. A function that’s huge in wall and tiny in CPU is blocked on I/O rather than burning cycles.
  • --mode gil counts only samples where the thread holds the Global Interpreter Lock. This is the one I keep coming back to on threaded code — if two workers are fighting over the GIL, this view makes the contention obvious in a way nothing else does.
python -m profiling.sampling attach --mode cpu -d 20 -o cpu.html 48213
python -m profiling.sampling attach --mode gil -a -d 20 -o gil.html 48213

If you’ve been chasing whether a workload actually benefits from threads, the GIL view pairs naturally with the free-threading build — profile under --mode gil first, and if the lock is the bottleneck, that’s your case for testing a no-GIL interpreter.

Async-aware stacks

Sampling an asyncio program the naive way gives you garbage: you catch the event loop mid-spin and the stack points at selector.select, not the coroutine that’s actually slow. The --async-aware flag reconstructs the logical stack across await boundaries, so you see which coroutine chain owns the time:

python -m profiling.sampling attach --async-aware -d 30 -o async.html 48213

One limitation to file away: --async-aware can’t be combined with --all-threads, --native, --no-gc, or --mode cpu/gil. For a single-loop service that’s fine, but if you run multiple event loops across threads you’ll have to profile them one at a time. That restriction bit me on the first try and the error message is clear enough, but it’s the kind of thing you’d rather read here than discover at 2am.

One afternoon I had a toy FastAPI service under uvicorn that got slow under load for no reason I could see from the logs. Attaching with --async-aware --live while hammering it with hey, the live view parked ~60% of samples in a synchronous json.loads inside a request handler that should have been trivial. The payloads were just bigger than I’d assumed; that’s the kind of decode hot path where swapping Pydantic for msgspec pays off. No restart, no added logging, no redeploy. That loop from “it’s slow” to “here’s the line” took under two minutes, and most of that was me remembering the ptrace_scope incantation.

How it stacks up against py-spy and cProfile

CapabilitycProfile (profiling.tracing)profiling.sampling (Tachyon)py-spy
MethodDeterministic (traces every call)Statistical (samples the stack)Statistical
Attach to running PIDNoYesYes
Ships in stdlibYesYes (3.15+)No (pip/cargo)
Overhead on targetHighNear zeroNear zero
Built-in flamegraphNo (needs external tool)YesYes
Exact call countsYesNoNo
Wall / CPU / GIL modesWall-ishYes, explicitCPU / wall
Async-aware stacksNoYesLimited
Extra installNoneNoneRust toolchain or wheel

The practical read: py-spy is still excellent and isn’t going anywhere, but the argument for it used to be “the stdlib can’t do this.” That argument is gone in 3.15. The stdlib sampler matches py-spy’s attach-and-flamegraph workflow, adds a GIL mode and first-class async reconstruction, and, for locked-down environments where you can’t just install a Rust binary, needs nothing that isn’t already on the box. cProfile keeps its niche: when the question is specifically how many times a function ran, or you want per-line attribution inside a short benchmark, the deterministic tracer is still the right tool.

Gotchas worth knowing before you rely on it

  • Statistical wobble. Two runs of the same workload won’t produce identical numbers. Short sessions collect few samples and are noisy — profile for tens of seconds, not two.
  • Version lock-step. Profiler and target must share the same Python minor version; for pre-releases, the exact build. Mismatches refuse to attach.
  • Native code attribution. Time spent inside a C extension is billed to the Python line that called it, not the native function — unless you pass --native, which pulls in <native> frames.
  • It’s a beta. 3.15 is release-candidate stage as of late September 2026, with a final release dated October 1. Profile with it freely; don’t ship production on the beta interpreter itself.

FAQ

How do I profile a Python process that’s already running?

Use python -m profiling.sampling attach <PID> on Python 3.15 or newer. Add --live for a real-time terminal view, or --flamegraph -d 30 -o out.html <PID> to record a 30-second window to an interactive flamegraph. You’ll need OS-level debug privileges (ptrace on Linux, sudo on macOS/Windows). No code changes or restart are required, since the sampler reads the stack from outside the process.

What’s the difference between cProfile and a sampling profiler?

cProfile is deterministic: it instruments every function call and return, giving exact call counts at the cost of high overhead and no ability to attach to a running process. A sampling profiler like profiling.sampling peeks at the call stack on a timer and estimates time from how often each function appears — much lower overhead, works on live processes, but no exact counts and it can miss very short functions.

Is Python’s built-in sampling profiler better than py-spy?

For most workflows they’re now equivalent: both attach to a PID and export flamegraphs with near-zero overhead. The stdlib profiling.sampling adds a --mode gil view and async-aware stack reconstruction, and it needs nothing installed beyond Python 3.15. py-spy still wins if you’re on an older Python or want a single static binary you can drop onto any host.

What Python version has the built-in sampling profiler?

Python 3.15. The profiling.sampling module (codename Tachyon) landed as part of the profiling reorganization in PEP 799, alongside profiling.tracing (the new home for cProfile). It is not available in 3.14 or earlier.

Can I use the sampling profiler in production?

The profiler itself is safe to point at a production process — that’s the whole design, near-zero overhead reading stacks externally. What you should not do is run your production service on the 3.15 beta interpreter. Wait for the October 1 final release before upgrading the runtime; until then, use it to profile services running on a matching-version staging box.

Sources

Bottom line

The stdlib catching up to a beloved third-party tool usually lands with a shrug. This time the shrug didn’t come. Being able to type python -m profiling.sampling attach --live <PID> on a stock interpreter (no Rust toolchain, no pip install on a hardened host, no restart of the thing you’re trying to diagnose) quietly removes one of the most annoying gaps in Python’s tooling. I still keep py-spy around, and cProfile when I need exact counts. But the next time a worker wedges, the first command I reach for is already installed.