Python 3.15's Sampling Profiler Is the Profiling Story You Can Actually Ship

Published on 8/30/2026By Prakhar Bhatia
Python 3.15's Sampling Profiler Is the Profiling Story You Can Actually Ship

Python's built-in profilers have been the wrong tool for production for a long time. cProfile instruments every call. profile is the slow teaching version of the same idea. Both distort the thing you wanted to measure. Teams that actually profile live traffic reached for py-spy, Austin, or Scalene, then argued about whether those binaries were allowed on the box.

Python 3.15 puts a statistical sampling profiler in the standard library. The module is profiling.sampling. The internal name is Tachyon. It can start a script, or it can attach to a PID, read stacks from outside the process, and detach. The 3.15 docs say the target is unaware it was observed.

That last sentence is the shipping story. Not a nicer HTML report. Not another pstats table. A profiler you can point at a running worker without a restart and without wrapping every function.

Python 3.15.0rc1 landed August 4. Release candidate 2 is scheduled for September 1. Final is October 1 (PEP 790). Feature freeze was beta 1 on May 7. If you're waiting for October, that's reasonable. If you run rc1 in a lab, you can learn the CLI now. Real Python's August 26 preview is what pushed this into the "write the playbook" pile rather than another "nice alpha" note.

What 3.15 Actually Shipped

The package rename is the boring part

PEP 799 (Pablo Galindo Salgado and László Kiss Kollár, accepted August 21, 2025) is a housekeeping document that happens to sit on top of a real tool.

ModuleRole in 3.15
profiling.samplingStatistical sampling profiler (Tachyon)
profiling.tracingDeterministic tracer, formerly cProfile
cProfileAlias of profiling.tracing
profileDeprecated; removal targeted for 3.17

The Steering Council rejected a top-level tachyon module and rejected dual names (profiling.sampling plus profiling.tachyon). One path. Tachyon stays a codename in the docs.

Deprecation of profile is staged: import warning in 3.15, broader warnings in 3.16, gone in 3.17. If you still teach import profile in a course, change the slides.

What the docs claim, in their own words

From the 3.15 library docs (updated August 25, 2026):

  • Sampling happens externally. Overhead on the target is described as virtually zero.
  • Sampling rates up to 1,000,000 Hz are mentioned in What's New. Default rate is 1 kHz.
  • You can run a script, run -m a module, attach a PID, dump a single stack, replay a binary profile.
  • Output includes pstats-style tables, flame graphs, line heatmaps, Firefox Profiler (gecko) JSON, a live TUI, opcode breakdowns, GIL frames, GC frames, native frames.

What's New positions this as the fastest sampling profiler available for Python at contribution time. Treat that as a snapshot, not a forever ranking. py-spy still exists. The interesting claim is stdlib plus attach, not a benchmark war.

Why this is different from wrapping cProfile

# the 2012 recipe. still works. still lies under load.
import cProfile
cProfile.run("main()", "out.prof")

Tracing records every call and return. That disables some interpreter tricks (PEP 659 specializations get in the way). It also only watches the main thread in the classic cProfile world. PEP 799 calls that out as a reason the old layout was misleading: the name profile looked canonical, the useful tracer was cProfile, and neither was what you wanted on a gunicorn worker.

Sampling estimates time from how often a stack appears. Short functions can vanish between ticks. Exact call counts do not exist. That's the trade. For "where is this process spending the next 30 seconds," it's the right trade.

Statistical Profiling Without the Lecture

How the estimate is built

At 10 kHz for 10 seconds you get about 100,000 samples. A function in 5% of those samples is estimated at 5% of the window, about 500 ms. The docs put a rough error bar on that: ±0.5% at 100,000 samples, much worse at 1,000.

Runs will disagree by a point or two. Don't file a ticket because handle_request was 12% then 11%. Look at the shape.

When sampling is the wrong instrument

The docs are blunt:

  • Scripts that finish under a second often don't collect enough samples. Loop them, or use profiling.tracing.
  • You need exact call counts: tracing.
  • You are comparing two implementations that differ by 1–2%: timeit or tracing. Sampling noise will eat the signal.

That's not a slight. It's the same reason you don't use a flame graph to decide a micro-benchmark.

Attach by PID Is the Operational Change

run versus attach versus dump

# from process start
python -m profiling.sampling run app.py
python -m profiling.sampling run -m uvicorn app:app --host 0.0.0.0

# already running
python -m profiling.sampling attach 12345
python -m profiling.sampling attach --flamegraph -d 30 -o /tmp/w.json 12345

# one stack, then exit
python -m profiling.sampling dump 12345

run starts the target as a subprocess, waits for init, then samples. Arguments after the script go to the program.

attach is the production command. No code change. No restart. Collect for -d seconds, write output, leave.

dump is a traceback for a live process: one read, annotated with thread state (has GIL, on CPU, waiting for GIL, exception, idle). Useful when something is wedged and you don't want a 60-second sample loop first.

--blocking on dump pauses threads for a consistent snapshot. Default dump reads while the process runs and can tear a stack. Use blocking when the dump looks like two call chains taped together.

A 30-second production window

Official production notes:

  • Start at 10–30 seconds. Stretch if the picture is noisy.
  • Prefer representative load over a peak you don't understand.
  • Profiler CPU is on the profiler process, not billed as target overhead.
  • Production profiles will not match your laptop. That's often the point.
# worker looks hot. don't restart it.
python -m profiling.sampling attach -d 20 -r 5khz --all-threads \
  --flamegraph -o /tmp/worker-$(date +%s).html "$PID"

Keep the HTML off the public web root. Flame graphs contain function names, file paths, and sometimes request-shaped strings if you were unlucky with frame locals in other tools. Tachyon is sampling stacks, not dumping request bodies, but treat the artifact as sensitive anyway.

Four Modes, Four Questions

Wall clock

Default. Every sample counts, including sleep, I/O, and lock waits. End-to-end latency lives here. If the page is slow because Postgres is slow, wall mode puts time in the await or the driver call.

python -m profiling.sampling run --mode=wall serve.py

CPU

Samples only when the thread is on a core. Sleep disappears. Use this when the box is pegged and you want the compute, not the waits.

import time

def do_sleep():
    time.sleep(2)

def do_compute():
    sum(i**2 for i in range(1_000_000))

if __name__ == "__main__":
    do_sleep()
    do_compute()

Docs' expected split: wall mode is almost all do_sleep; CPU mode is do_compute. If you only ever run wall mode on an I/O service, you'll keep "optimizing" functions that are waiting.

GIL

Samples only while the thread holds the GIL. C extensions that drop the GIL look cheap here even if they burn CPU. Pure Python looks expensive.

import hashlib

def hash_work():
    for _ in range(200):
        hashlib.sha256(b"data" * 250_000).hexdigest()

def python_work():
    for _ in range(3):
        sum(i**2 for i in range(1_000_000))

if __name__ == "__main__":
    hash_work()
    python_work()

Documented pattern: CPU mode splits time between hash_work and python_work. GIL mode collapses hash_work and inflates python_work. That's how you explain "NumPy is using the machine but other threads can't run bytecode."

On free-threaded 3.15 this mode still exists, but the story changes: there isn't one global lock in the same way. Don't cargo-cult GIL profiles onto a python3.15t worker without reading the 3.15 notes for that build.

cpu, gil, and exception modes are incompatible with --async-aware. The docs say so. Don't fight the CLI.

Exception

Samples only with an active exception: propagating, or inside except while exception state is still on the thread. finally after the exception is handled is not captured. If you suspect retry loops or "we raise for control flow," this mode is the microscope. If you don't, skip it. It will look empty on a healthy service.

Visual Output That Isn't a pstats Printout

Flame graphs

python -m profiling.sampling run --flamegraph -o profile.html script.py
python -m profiling.sampling replay --flamegraph -o profile.html profile.bin

Width is time (sample share). Deep stacks are call chains. This is the view you send in a ticket. Binary capture plus replay means you can collect on the box and convert later, including to gecko JSON for Firefox Profiler.

Heatmaps

python -m profiling.sampling run --heatmap script.py

Line-level coloring over source. Useful when the flame graph says process_batch and you need the loop. Opcode mode adds per-line bytecode breakdowns, including specialization names (LOAD_ATTR_INSTANCE_VALUE versus generic LOAD_ATTR). That's a compiler-nerd view. Most app teams never need it. If you're chasing a 3.15 specialization regression, you do.

Live TUI

python -m profiling.sampling run --live script.py
python -m profiling.sampling attach --live 12345

Press q to quit. Good for "is this the worker I think it is" before you write a 60-second HTML file. Bad as the only record. You will forget what you saw.

--native and GC frames

--native injects <native> frames when Python is in C. Without it, C time is billed to the Python caller. NumPy-heavy code wants --native once, then you decide if the story is your Python or the extension.

GC frames are on by default. --no-gc hides them. If <GC> is fat, you're allocating too hard. Sampling will not give you a heap graph. That's Memray.

Threads, Async, Subprocesses, Free-Threading

Default is the main thread

--all-threads / -a samples everything. Thread pools hide work on default settings. If you use concurrent.futures.ThreadPoolExecutor or a DB pool with worker threads, turn this on or you'll profile the wrong stack.

Async-aware stacks

--async-aware walks the task graph and stitches await chains. dump defaults to showing more of the graph; attach defaults to the running task. Markers separate coroutines waiting on each other.

Incompatible with --native, --no-gc, --all-threads, and CPU/GIL modes. Async servers will have to pick: task-shaped stacks, or thread/GIL/native detail. That's a real limitation, not a docs typo.

Subprocesses

python -m profiling.sampling run --subprocesses train.py

Spawns extra profiler instances for Python children. ProcessPoolExecutor and multiprocessing need this or you only see the parent. Non-Python children are out of scope.

Version and ABI matching

Same minor version. Pre-release must match exactly. Free-threaded profiler cannot attach to a GIL build and the reverse. CI images that mix python:3.15-rc tags will fail attach in confusing ways. Pin the patch.

Permissions: The Part That Blocks the First Demo

Linux

ptrace or process_vm_readv. Root, CAP_SYS_PTRACE, or Yama ptrace_scope. Default scope 1 means parent-only. Same-user attach to an unrelated PID often needs:

# temporary, understand the security trade
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope

Containers: many drop SYS_PTRACE. Your "zero overhead profiler" then fails at attach with a permission error. Sidecar with ptrace is an infra change. Don't discover this during an incident.

macOS

task_for_pid(). Root, debugger entitlement, or SIP off (docs say not recommended). Laptop demos work as root and fail as a normal user. That's expected.

Windows

Admin or SeDebugPrivilege. Fine for a bastion. Awkward for a locked-down IIS worker.

None of this is unique to Tachyon. py-spy has the same fight. Stdlib does not magically get a kernel bypass.

A Playbook That Fits a Real Service

FastAPI worker, attach, replay

# app.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/work")
def work(n: int = 200_000):
    return {"s": sum(i * i for i in range(n))}
# terminal 1
python -m uvicorn app:app --port 8000

# terminal 2
PID=$(pgrep -f "uvicorn app:app" | head -1)
python -m profiling.sampling attach -d 15 -a --mode=wall \
  -o /tmp/api.bin "$PID"
python -m profiling.sampling replay --flamegraph -o /tmp/api.html /tmp/api.bin
python -m profiling.sampling replay --gecko -o /tmp/api.json /tmp/api.bin

Hit /work during the 15 seconds or the profile is "idle main." Sampling a quiet process is valid and useless.

Pair wall and CPU

python -m profiling.sampling attach -d 20 --mode=wall -o /tmp/w.bin "$PID"
python -m profiling.sampling attach -d 20 --mode=cpu  -o /tmp/c.bin "$PID"

Same window is better than sequential, but you only have one attach at a time unless you accept two adjacent windows. Adjacent is good enough if load is steady.

Don't leave --blocking on at 1 MHz

Blocking suspends the target per sample. Docs warn that very high rates with blocking spend more time stopped than running. Default interval can be too aggressive in blocking mode. If you need consistent stacks on a generator storm, drop the rate (1 ms or slower) rather than maxing Hertz.

CI is for regressions, not fishing

# sketch: compare a known hot path on 3.15
- run: python -m profiling.sampling run -d 8 --mode=cpu -o bench.bin tests/perf_hotpath.py

CI profiles need a fixed workload. Production attach is for "this deploy feels wrong." Mixing them produces flame graphs nobody trusts.

What Third-Party Profilers Still Own

py-spy

Standalone binary. Works on older CPython. No requirement that the host's python matches the app's, in the Tachyon sense of "same 3.15." For 3.12/3.13 fleets, py-spy remains the attach tool. pydevtools' 2026 handbook still recommends py-spy below 3.15 and profiling.sampling on 3.15+. That's the split I would write in an internal wiki.

py-spy's overhead is "a few percent of CPU" on the sampler in typical writeups, not zero on the box. Tachyon's pitch is the target isn't instrumented. You still pay for a second process.

pyinstrument

Wall-clock statistical profiler that historically ran in-process. Excellent local DX. Weaker "attach to PID 7 on the metal" story than Tachyon or py-spy.

Scalene

CPU plus memory plus GPU attribution. Tachyon does not replace it. If RSS is the incident, don't attach a stack sampler and squint.

Austin

Another external sampler, often paired with Firefox Profiler. Gecko export from Tachyon overlaps that workflow.

Keep the specialist tools. Stop installing a tracer in production "just in case."

How This Sits Next to Free-Threading

3.15 is also a free-threading year. A sampling profiler that understands threads and can filter GIL time is not a coincidence. If you move a worker to 3.15t, you need evidence other than "the GIL is gone so it should scale."

Practical sequence:

  1. Profile the GIL build with --mode=gil and --mode=cpu under production-like load.
  2. Repeat on the free-threaded build with --all-threads.
  3. Compare whether the old GIL monopolist became a real parallel win or just more threads waiting on a lock you wrote.

Don't use Tachyon as a substitute for sys._is_gil_enabled() checks, or for the silent "this C extension re-enabled the GIL" trap. Those are still import-time and runtime facts.

Limits You Should Write Down

Sampling misses functions that live between ticks. Default 1 kHz is coarse for a 50 µs helper. Raise the rate if you must, and accept more profiler CPU.

--async-aware cannot be mixed with several other flags. Async plus native plus all-threads is not one command.

Attach is a privilege. Kubernetes securityContext and distroless images will fight you.

Same-version requirement means you cannot keep a forever python:3.14 debug sidecar against 3.15 app containers.

The docs' "virtually zero" is about the target's instruction stream. Disk, CPU for the profiler, and ptrace policy are still your problem.

Short-lived CLI tools still want tracing or timeit.

Opcode and specialization views are for people already reading bytecode. Showing that HTML to a product manager is a waste of both of you.

Reading Samples: Tables, Workers, and Timing

Interpreting the table, not just the flame graph

Sample count is the confidence interval

If you attached for five seconds at 1 kHz, you have about 5,000 samples on one thread. That's enough to see a 20% hotspot. It is not enough to rank two 3% functions. Extend duration before you raise rate. Duration is usually the cheaper knob.

--realtime-stats prints the achieved rate versus the requested rate. If you asked for 100 kHz and got 8 kHz, the machine couldn't keep up. The profile is still usable; the error bars are wider than you pretended. Don't publish a blog post claiming 1 MHz sampling because the flag accepted the string.

pstats still exists

Default replay prints a table. That's the right format for a log file and the wrong format for a 40-deep Django stack. Generate the table and the HTML. The table is how you grep. The flame graph is how you see middleware eating a third of the width.

Sort by own time first when you think the function itself is hot. Sort by cumulative when you think a caller is assembling a disaster. Sampling's "own time" is "appeared as leaf-ish in this reconstruction," not a tracing profiler's exclusive time. Don't overfit the column names.

Torn stacks look like impossible code

Non-blocking reads can mix frames from two moments. You'll see a function that cannot call the function below it. --blocking is the fix, at the cost of pauses. If you only see torn stacks in --async-aware mode, read the incompatibility list again before you file a CPython bug.

Workers people actually run

Gunicorn / uvicorn

Prefork: each worker is a PID. Attach to the worker that is burning CPU, not the master. ps and your process manager's status beat guessing.

# pick the fat worker
ps -eo pid,pcpu,rss,cmd | awk '/uvicorn|gunicorn/ {print}'
python -m profiling.sampling dump --all-threads "$WORKER_PID"

dump first. If every thread is in epoll / kqueue, you don't have a Python hotspot; you have waiting. Then decide whether a 30-second wall profile is even interesting.

Celery

The child is a Python process. --subprocesses on the parent only helps if you start from the parent and the children are Python. For a long-lived worker pool, attach to a child PID directly. Task names will show up if they're on the stack. If the work is in a C codec, --native or you will blame the wrong Python wrapper.

Django request path

Wall mode on a sync worker often puts time in the database driver. That's correct. CPU mode then tells you whether you also have a Python serializer problem. People skip the second profile and rewrite SQL that was fine.

Template rendering that looks huge in wall mode and tiny in CPU mode is usually I/O or waiting on context. The opposite pattern is a template that does Python work per row. Sampling is good at that distinction. Tracing would have told you call counts you didn't need.

Observability versus sampling

OpenTelemetry spans answer "which dependency, which status, which trace id." Sampling answers "inside this process, which stack." They overlap in the worst incident reports because someone dumps a flame graph with no trace id and a trace with no stacks.

A workable split:

  • Metrics page says CPU is 90% on the web Deployment.
  • Pick one pod, one PID, 20 seconds, wall + CPU.
  • If the flame graph is grpc / psycopg / httpx, go back to traces.
  • If the flame graph is your for loop, don't open Datadog first. Fix the loop.

Do not run the sampler 24/7 as a replacement for a profiler product with continuous profiling, storage, and user-level aggregation. Tachyon is an attach tool. Continuous profiling is a different budget (Parca, Pyroscope, vendor agents). You can export gecko and get a timeline for one window. That is not a fleet-wide profile store.

A slightly more honest FastAPI example

# slow.py — synthetic, for local practice
import asyncio
import hashlib
from fastapi import FastAPI

app = FastAPI()

def cpu_bound(n: int) -> str:
    h = hashlib.sha256()
    for i in range(n):
        h.update(str(i).encode())
    return h.hexdigest()

@app.get("/cpu")
def cpu():
    return {"h": cpu_bound(80_000)}

@app.get("/wait")
async def wait():
    await asyncio.sleep(0.2)
    return {"ok": True}

@app.get("/mix")
async def mix():
    await asyncio.sleep(0.05)
    return {"h": cpu_bound(20_000)}

Load /cpu and attach with --mode=cpu and --mode=gil. hashlib should drop in GIL mode. Load /wait with wall mode and --async-aware if the stack looks like an event loop with no task. Load /mix with both modes so you see sleep versus hash on the same route.

This is a toy. Production will add middleware, JSON, and an ORM. The toy is still how you learn which flag answers which lie.

python -m profiling.sampling attach -d 25 -a --mode=cpu \
  --flamegraph -o /tmp/cpu.html "$PID"
python -m profiling.sampling attach -d 25 --async-aware --mode=wall \
  --flamegraph -o /tmp/async.html "$PID"

Second command omits -a because async-aware forbids it. If you need threads and async reconstruction, you take two captures. Write that on the runbook. People will try to combine flags and call the tool broken.

Security and compliance side notes

Reading another process's memory is a privileged operation. In a PCI or HIPAA review, "we attached a profiler" is a change. Log who attached, to which PID, for how long. Don't put flame graphs in Slack channels that include vendors.

Function names can leak business logic (bill_customer, hash_ssn_last4). Treat HTML the way you treat a coredump: restricted bucket, expiry, no public CDN.

The PEP says security implications of the package rename are none. That's not a statement about ptrace.

Upgrade timing

rc1 is a candidate, not a toy alpha. Only reviewed bugfixes after rc1. If Tachyon is missing a flag you wanted, it is probably not landing in 3.15. Plan playbooks on the documented CLI.

October 1 is the final on PEP 790. Distros lag. Fedora's 3.15 change tracks rc1/rc2 into their freeze. Your company pin might be December. The playbook still belongs in the 3.15 upgrade ticket, not a separate "we'll look at profiling later" ticket that never happens.

If you cannot run 3.15 yet, py-spy is the stand-in, not cProfile in gunicorn. Switching to Tachyon later is a command change, not a rewrite.

What I'd Put in a Team Doc

Install 3.15 rc or wait for October. Teach python -m profiling.sampling attach the same week you upgrade the runtime, not six months later.

Default recipe: 20 seconds, wall mode, all threads, flame graph, binary kept for replay. Second recipe: CPU mode on the same symptom. Third: GIL mode only if the question is interpreter lock or C-versus-Python.

Keep py-spy until the last 3.14 box dies. Keep Scalene for memory. Deprecate import profile in linters when you flip the version pin.

Don't wrap every request in profiling.tracing in production because 3.15 made profiling feel official. The official tool for that environment is the sampler.

If you only remember four commands, remember these:

python -m profiling.sampling dump --all-threads "$PID"
python -m profiling.sampling attach -d 20 -a --mode=wall --flamegraph -o /tmp/w.html "$PID"
python -m profiling.sampling attach -d 20 -a --mode=cpu --flamegraph -o /tmp/c.html "$PID"
python -m profiling.sampling replay --gecko -o /tmp/p.json /tmp/capture.bin

Dump tells you if the process is stuck in the kernel. Wall versus CPU tells you if you are waiting or computing. Replay means you can capture binary on a box that should not render HTML and convert on your laptop. That is the whole shipping story, compressed.

profiling.tracing still belongs in unit tests where you assert a hot function was called. Mixing that into a gunicorn worker "for visibility" is how you get a 2x slowdown and a false sense of production coverage. The 3.15 docs recommend sampling for most analysis. Believe the split.

On a laptop, SIP and ptrace will waste an afternoon. Do the permission dance once, write the one-liner in the runbook, and stop rediscovering Yama. The profiler is not mysterious. The kernel policy is.

Sources for this piece: Python 3.15 profiling.sampling docs and What's New, PEP 799, PEP 790, Python Insider rc1 (August 4, 2026), Real Python's 3.15 sampling preview (August 26, 2026), CPython Doc/library/profiling.rst, pydevtools' sampling-vs-tracing handbook, ELCSoftware's Tachyon technical guide (May 2026), and the Python 3.15.0rc1 download page. Details can still move between rc2 and final; if a flag disappears in October, trust the version you actually run.


FAQs

Is Python 3.15's sampling profiler ready to use?

3.15.0rc1 shipped August 4, 2026. Feature freeze was beta 1 in May. Final is scheduled for October 1. The profiler API is in the 3.15 docs as profiling.sampling. You can try it on rc1 today; production should wait for the October final unless you already run 3.15 in a controlled environment.

Does attaching to a live process add overhead?

The official docs describe target overhead as virtually zero because Tachyon reads stacks from another process via OS memory APIs. The profiler process uses CPU. The target is not instrumented and is not paused unless you pass --blocking.

When should I still use cProfile or py-spy?

Use profiling.tracing (the old cProfile) when you need exact call counts. Keep py-spy for Python older than 3.15 or when you need a standalone binary with no matching interpreter on the host. Keep Scalene when the question is memory or GPU, which Tachyon does not answer.

Can I attach from Python 3.14 to a 3.15 process?

No. Profiler and target must share the same minor version. Pre-releases must match exactly. Free-threaded and GIL builds cannot attach to each other.

What permissions does attach need?

Linux typically needs ptrace rights or Yama ptrace_scope adjusted. macOS needs task_for_pid via root or a debugger entitlement. Windows needs admin or SeDebugPrivilege. Same-user attach is not automatic on locked-down kernels.

Wall mode vs CPU mode vs GIL mode?

Wall records every sample including sleep and I/O. CPU keeps samples only when the thread is on a core. GIL keeps samples only while the thread holds the interpreter lock. Pair wall and CPU to tell waiting from computing.

Will this replace observability?

No. Sampling tells you where stacks sit. It does not replace traces, metrics, or logs. Use a 10 to 30 second attach during representative load, then go back to your usual dashboards.

What happened to the profile module?

PEP 799 deprecates the pure-Python profile module in 3.15, warns more in 3.16, and removes it in 3.17. cProfile stays as an alias of profiling.tracing.

🚀

Work with us

Let's build something together

We build fast, modern websites and applications using Next.js, React, WordPress, Rust, and more. If you have a project in mind or just want to talk through an idea, we'd love to hear from you.

Related Articles


Nandann Creative Agency

Crafting digital experiences that drive results

© 2025–2026 Nandann Creative Agency. All rights reserved.

Live Chat