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.
| Fact | Value | Source | Verified |
|---|---|---|---|
| Module | profiling.sampling (codename Tachyon) | Python 3.15 docs | Sep 22, 2026 |
| First shipped | Python 3.15 stdlib (PEP 799) | peps.python.org | Sep 22, 2026 |
| Default sampling rate | 1 kHz (reported) | Python 3.15 docs | Sep 22, 2026 |
| Max sampling rate | 1 MHz (reported) | What’s New in 3.15 | Sep 22, 2026 |
| 3.15 final release | Oct 1, 2026 | python.org | Sep 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.
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 cpucounts 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 gilcounts 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
| Capability | cProfile (profiling.tracing) | profiling.sampling (Tachyon) | py-spy |
|---|---|---|---|
| Method | Deterministic (traces every call) | Statistical (samples the stack) | Statistical |
| Attach to running PID | No | Yes | Yes |
| Ships in stdlib | Yes | Yes (3.15+) | No (pip/cargo) |
| Overhead on target | High | Near zero | Near zero |
| Built-in flamegraph | No (needs external tool) | Yes | Yes |
| Exact call counts | Yes | No | No |
| Wall / CPU / GIL modes | Wall-ish | Yes, explicit | CPU / wall |
| Async-aware stacks | No | Yes | Limited |
| Extra install | None | None | Rust 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
profiling.sampling— Statistical profiler, Python 3.15 docs — the official CLI and API reference- PEP 799 – A dedicated profiling package — the profiling reorg that added Tachyon and moved
cProfileunderprofiling.tracing - What’s New in Python 3.15 — sampling-rate ceiling and release timeline
- py-spy on GitHub — the external sampling profiler the stdlib now mirrors
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.