sys.settrace and Tracing Hooks

sys.settrace(func) installs a per-thread global trace function — a plain Python callable the interpreter invokes on significant execution events (entering a frame, advancing to a new source line, returning, raising) so that a debugger, coverage tool, or line profiler can observe a program from the inside, written entirely in Python (sys docs). It is the oldest instrumentation hook in CPython and the engine behind pdb, coverage.py, and tools like trace. Its great virtue is reach — it sees every line of every frame with no per-call-site setup. Its great vice is cost: because the legacy contract demands a Python callback on every line, it historically slowed programs by an order of magnitude. As of CPython 3.12 the hook was re-implemented as a thin shim over the low-overhead sys.monitoring framework (PEP 669), but its semantics — and therefore its overhead profile — remain those of the legacy design.

Mental Model

Think of settrace as registering a single switchboard operator who must be phoned on every notable thing that happens, with no way to say “stop calling me about this spot.” You hand the interpreter one function — the global trace function. Whenever control enters a new scope (a function body, a comprehension, a module top level, an exec’d block), the interpreter calls that global function with the event string 'call'. The function’s return value then becomes the local trace function for that one frame: return a callable and the interpreter will keep phoning that callable for the frame’s 'line', 'return', and 'exception' events; return None and the frame is left untraced. This two-level design — one global gatekeeper that hands out per-frame local handlers — is the heart of the protocol.

flowchart TD
    A["sys.settrace(global_fn)<br/>stored per-thread in tstate->c_tracefunc"] --> B{New frame<br/>entered?}
    B -->|"yes: event='call'"| C["global_fn(frame, 'call', None)"]
    C -->|returns a callable| D["frame.f_trace = local_fn"]
    C -->|returns None| E["frame not traced<br/>(no per-line calls)"]
    D --> F{"f_trace_lines<br/>(default True)?"}
    F -->|True| G["local_fn(frame, 'line', None)<br/>once per source line"]
    F -->|False| H["skip 'line' events"]
    D --> I["local_fn(frame, 'return', retval)"]
    D --> J["local_fn(frame, 'exception',<br/>(exc, val, tb))"]
    G -->|returns local_fn| G

Figure: the two-level trace-function protocol. The global function is consulted once per frame ('call'); whatever it returns becomes that frame’s local function, phoned for every subsequent line/return/exception in the frame. The insight to take away: there is exactly one decision point (“trace this frame or not?”) per frame, but no per-line opt-out — once a frame is traced, every line in it costs a Python call.

The Trace-Function Protocol

A trace function takes three arguments, (frame, event, arg) (sys docs):

  • frame is the frame object currently executing. From it the callback reads frame.f_lineno (the current line), frame.f_code (the code object), frame.f_locals, and frame.f_globals — this is how a debugger inspects state and how a profiler attributes time.
  • event is a string naming what just happened.
  • arg carries event-specific payload.

The documented events for a trace function are:

eventwhenargwho is calledreturn value used for
'call'a new scope is enteredNonethe global functionthe new local function (or None to skip)
'line'about to execute a new source line (or re-test a loop condition)Nonethe local functionthe new local function
'return'the frame is about to returnthe return value (None if exiting via exception)the local functionignored
'exception'an exception occurreda (type, value, traceback) tuplethe local functionthe new local function
'opcode'about to execute a single bytecode instructionNonethe local functionthe new local function

The 'opcode' event is special: per-opcode tracing is not emitted by default and must be explicitly requested by setting frame.f_trace_opcodes = True on the frame (sys docs). This lets a tool single-step at bytecode granularity — useful for instruction-level debuggers — without imposing that cost on every traced frame.

The convention for the local function is that it returns a reference to itself to keep tracing the frame, or returns another callable to swap in a different handler, or None to stop tracing that frame’s remaining lines. A live demonstration on CPython 3.14.5 makes the event order concrete:

import sys
 
events = []
def tracer(frame, event, arg):
    events.append((event, frame.f_code.co_name, frame.f_lineno))
    return tracer            # local fn returns itself → keep tracing this frame
 
def foo(n):
    x = n + 1
    return x * 2
 
sys.settrace(tracer)         # install the GLOBAL trace function for this thread
foo(3)
sys.settrace(None)           # uninstall
for e in events:
    print(e)

Running this prints, in order:

('call',   'foo', 9)        # global fn invoked as foo's frame is entered
('line',   'foo', 10)       # about to run  x = n + 1
('line',   'foo', 11)       # about to run  return x * 2
('return', 'foo', 11)       # foo about to return; arg would be 8

Line-by-line: sys.settrace(tracer) stores tracer as this thread’s global function. Calling foo(3) causes the interpreter to invoke tracer(frame, 'call', None); because tracer returns itself, it becomes foo’s frame-local function, so it is then called for each line and for the return. Note the global function is not called for the current frame at the moment of settrace — only for frames entered afterward; activating tracing on the already-running frame requires assigning frame.f_trace directly (sys docs).

Frame attributes that tune tracing

Three writable frame attributes control event delivery for a single frame (datamodel reference):

  • frame.f_trace — the frame’s local trace function. Assigning it directly is the documented way to start tracing the current frame (which settrace itself does not do) and to do “fine-grained” tracing without relying on the 'call' return value.
  • frame.f_trace_lines — defaults to True. Set it to False to suppress 'line' events for that frame while still receiving 'return'/'exception'. This is how a profiler that only cares about call/return avoids paying for line events.
  • frame.f_trace_opcodes — defaults to False. Set it True to receive 'opcode' events.

Verified live on 3.14.5: a fresh frame reports f_trace_lines == True and f_trace_opcodes == False, matching the documentation.

sys.setprofile — the profiler sibling

sys.setprofile(func) installs a profile function with the same (frame, event, arg) signature but a different, smaller event set tuned for profiling rather than line-level debugging (sys docs). Crucially it is not called for each executed line — only on call and return — which is why it is far cheaper than a trace function. Its events are 'call', 'return', 'c_call', 'c_return', and 'c_exception'. The three c_* events fire around calls into C functions (built-ins and extension functions), with arg set to the C function object — information a trace function never receives. This is exactly what cProfile is built on. Two consequences flow from the design and are stated outright in the docs: a profile function’s return value is ignored (there is no per-frame local-function indirection — one function handles the whole thread), and because it has no way to learn about thread context switches, “it does not make sense to use this in the presence of multiple threads.”

Both hooks are thread-specific: settrace/setprofile affect only the calling thread, and a debugger that wants to follow every thread must install the hook on each one — threading.settrace() / threading.setprofile() register a function that the threading module installs into every thread it spawns (sys docs). To avoid infinite recursion, tracing is suspended while the trace function itself runs; sys.call_tracing(func, args) exists precisely to re-enable tracing for a nested call from inside a debugger checkpoint.

How it actually works in CPython 3.14 — a shim over sys.monitoring

Here is where the common mental model is out of date, and it matters for understanding the cost. Pre-3.12, the evaluation loop carried an explicit per-instruction check of tstate->c_tracefunc: a non-NULL trace function flipped the interpreter onto a slow dispatch path that called the function on line boundaries. That code path no longer exists. Reading the CPython 3.14.5 source confirms it: c_tracefunc does not appear anywhere in Python/ceval.c or in the generated opcode handlers (Python/generated_cases.c.h) — the only thing left there is the INSTRUMENTED_LINE opcode handler, which calls _Py_call_instrumentation_line(...) (verified by grep over the v3.14.5 tag).

Instead, sys.settrace is implemented entirely on top of the PEP 669 instrumentation machinery. The header comment of Python/legacy_tracing.c (v3.14.5) states the design in one line: “Support for legacy tracing on top of PEP 669 instrumentation. Provides callables to forward PEP 669 events to legacy events.” The mechanism, traced through that file:

  1. sys.settrace(func) stores func in tstate->c_tracefunc (still used — but now only as storage read inside the forwarding shims, not as a per-line eval-loop branch). Line 656–657 of legacy_tracing.c: int delta = (func != NULL) - (tstate->c_tracefunc != NULL); tstate->c_tracefunc = func;.
  2. It registers internal C callbacks against a reserved tool IDPY_MONITORING_SYS_TRACE_ID, which is 7 in Include/internal/pycore_instruments.h. (Its sibling PY_MONITORING_SYS_PROFILE_ID is 6.) These are above the six tool IDs (0–5) exposed to Python code via sys.monitoring, so user tools and the legacy hooks never collide — PY_MONITORING_TOOL_IDS is 8 total internally.
  3. It then calls _PyMonitoring_SetEvents(PY_MONITORING_SYS_TRACE_ID, events) with the bitmask RAISE | LINE | PY_RETURN | PY_START | … — turning the relevant monitoring events on globally. Those internal C shims (sys_trace_line_func, sys_trace_call_func, …) translate each PEP 669 event into a (frame, event, arg) call to the user’s Python trace function.

The slow path, in other words, is now implemented as monitoring instrumentation — but with the legacy semantics bolted on. That distinction is the crux of the next section.

Why it is expensive

The cost of settrace has two layers, and naming them precisely is what makes the contrast with sys.monitoring sharp.

1. A Python callback on every line. The legacy contract guarantees a 'line' event for every source line of every traced frame, delivered to a Python function. Each delivery is a full Python-to-Python call: build an args tuple, push a frame, run the callback, unwind. On a tight loop this dominates — the loop body’s real work is dwarfed by the per-line callback. PEP 669 quantifies the historical effect bluntly: “Slowdowns by an order of magnitude are common” under a profiler or debugger (PEP 669).

2. Instrumentation defeats the specializing adaptive interpreter. To deliver those events, CPython must replace ordinary opcodes with their INSTRUMENTED_* variants in the affected code objects. An INSTRUMENTED_* opcode cannot also be a specialized opcode (e.g. BINARY_OP_ADD_INT), so enabling tracing forces de-specialization and invalidates JIT executors — the source’s instrumentation routines call _Py_Executors_InvalidateDependency() / _Py_Executors_InvalidateAll() when monitoring state changes. The interpreter loses the type-specialized fast paths it had learned and reverts to generic, branchy handlers. So even the non-callback opcodes in a traced region run slower. (For how specialization normally accelerates the loop, see The Specializing Adaptive Interpreter and The CPython Evaluation Loop.)

Why settrace cannot fix this even on 3.14’s faster engine. sys.monitoring’s cheapness comes from the DISABLE sentinel: a callback can return it to permanently disable instrumentation at a single (code, offset) location, so cold code stops costing anything after one event. The legacy hook can’t lean on that broadly. sys.settrace’s contract says it must report every line of every frame, every time, so it subscribes to LINE globally and (for the most part) keeps re-firing. The C shims do return DISABLE in narrow situations (e.g. an instrumented instruction that coincides with a line start defers to the dedicated INSTRUMENTED_LINE handler — legacy_tracing.c lines ~395–405), but they cannot disable a line just because the user’s tool saw it once, because the semantics require seeing it every time. The result: a settrace-based tool on 3.14 is somewhat faster than the same tool on 3.11 (it rides the faster instrumentation core), but it is still dramatically slower than a sys.monitoring-native tool that uses DISABLE to pay only for the locations it cares about.

Resolved (2026-06-01)

Measured on this 3.14.5 build with a controlled microbenchmark (a 200k-iteration arithmetic loop): a global no-op sys.settrace line tracer ran the loop at ~2–3x the untraced baseline across repeated runs, while an equivalent sys.monitoring LINE callback that returns DISABLE settled to ~1.0x baseline (≈0.9–1.2x) — i.e. effectively free once each line has fired once. The settrace-to-monitoring ratio was ~2–3x for this workload; it widens sharply on hotter loops because DISABLE permanently silences each (code, offset) after a single hit whereas settrace must keep re-firing every line every iteration. These figures are workload-dependent (a single loop is favourable to monitoring’s per-location disable; a flat script with no repetition narrows the gap) and should be read as confirmation of the direction and order of PEP 669’s design claims rather than a universal multiplier.

What it powers

  • pdb — the standard debugger uses sys.settrace (via bdb.Bdb) to step line-by-line, set breakpoints, and inspect frames. Breakpoint semantics map naturally onto 'line' events: at each line, check whether a breakpoint matches frame.f_code and frame.f_lineno. See The pdb Debugger.
  • coverage.py — its classic measurement core (ctrace, a C trace function) records which lines/branches executed. Notably, on Python 3.14+ coverage.py’s default core is now sysmon, built on sys.monitoring rather than settrace, precisely because DISABLE lets it stop paying for already-covered lines — Ned Batchelder reports branch-coverage measurement becoming dramatically cheaper once BRANCH_LEFT/BRANCH_RIGHT landed (Batchelder 2025).
  • Line profilers — tools that attribute time per source line install a trace function and timestamp each 'line' event.
  • ProfilerscProfile/profile use setprofile (call/return only), not settrace; see Profiling with cProfile.

Failure Modes and Common Misunderstandings

  • “The global trace function is called for every line.” No — the global function is called only on 'call'; the per-line 'line' events go to the local function it returned. Returning None from the global function (or forgetting to return the local function from the local function) silently stops line tracing for that frame.
  • settrace traces the current frame.” It does not. It only arms frames entered after installation. To trace the running frame, assign frame.f_trace directly.
  • An error in the trace function silently disables tracing. The docs are explicit: “If there is any error occurred in the trace function, it will be unset, just like settrace(None) is called.” A bug in your tracer doesn’t raise loudly — it just stops tracing.
  • Reentrancy. Tracing is disabled while the trace function runs; calling traced code from within a tracer needs sys.call_tracing or it won’t be traced.
  • Multithreading blind spots for profilers. A setprofile profiler can’t observe thread context switches, so per-thread wall-clock attribution is unreliable in threaded programs.
  • Implementation-specificity. settrace/gettrace are flagged “CPython implementation detail” — other implementations (PyPy, GraalPy) may behave differently or not support 'opcode' events. See CPython vs PyPy and Alternative Implementations.

Alternatives and When to Choose Them

NeedUseWhy
Line-level debugging in pure Python, broad compatibilitysys.settraceSimple, ubiquitous, sees every line
Low-overhead production-grade instrumentationsys.monitoringDISABLE → near-zero cost on cold code; subscribe to only the events you need
Call/return profilingsys.setprofile / Profiling with cProfileNo per-line cost
Sampling without instrumenting the targetSampling Profilers and py-spyReads another process’s stacks; zero in-process overhead
Attach to a running processRemote Debugging in CPython (PEP 768)No need to pre-install a hook

The rule of thumb: choose settrace when you genuinely need to observe every line and you’re targeting code where a 10× slowdown is acceptable (interactive debugging, a test-suite coverage run). For anything where overhead matters — always-on instrumentation, large coverage runs, production profilers — reach for sys.monitoring.

Production Notes

The clearest real-world signal that settrace has been superseded is coverage.py’s migration: after years on a C trace function, it added a sys.monitoring core (sysmon) and made it the default on Python 3.14+, where branch coverage via BRANCH_LEFT/BRANCH_RIGHT is finally both correct and fast (Batchelder 2025). The caveat coverage.py ships with is instructive about the API’s youth: the sysmon core does not yet support every configuration (plugins, dynamic contexts, some concurrency libraries), and on 3.12/3.13 it could not do branch coverage at all — so it silently falls back to the ctrace (settrace-based) core. That fallback is the pragmatic reality for the next few years: sys.monitoring is the future, but settrace remains the universal, always-available baseline.

See Also