What does sys.settrace() install in CPython, and which tools rely on it?
answer
- A hook the interpreter calls back into
- Line-by-line, not a report afterwards
- Debuggers and coverage sit on it
- sys.settrace and sys.gettrace
- bdb.Bdb dispatches its events
basics
~20 ssys.settrace installs a Python callback that the interpreter invokes on execution events in the calling thread: a Python function being entered, a line executed, a return, an exception. Debuggers built on bdb.Bdb and coverage tools are built on it.
solid answer
~40 s`sys.settrace(func)` sets a **global trace function** for the current thread. The interpreter calls it with `(frame, event, arg)` whenever a new Python frame is entered, and the object that callback returns becomes the frame-local callback that receives `line`, `return` and `exception` events for that frame. `sys.gettrace()` reads back whatever is installed, which matters because there is exactly one slot per thread: installing yours silently displaces a debugger's. This is the hook the standard library's own debugger base class `bdb.Bdb` dispatches from, and the mechanism line-coverage tools historically used. It is a Python-level call per event, so it is a development and test-time instrument, not something you leave running under production traffic. Since 3.12 the interpreter also exposes `sys.monitoring`, the event-set API that new tooling targets instead.
code
python · 21 linesimport sys
def local_trace(frame, event, arg):
if event == "line":
print("line", frame.f_lineno)
return local_trace
def global_trace(frame, event, arg):
if event == "call" and frame.f_code.co_name == "work":
return local_trace
return None
def work():
total = 0
total += 1
return total
sys.settrace(global_trace)
work()
sys.settrace(None)
print("installed now:", sys.gettrace())go deeper
Be ready to say in one sentence what sys.settrace does: it registers a callback the interpreter invokes on execution events in the current thread. Knowing that debuggers and coverage tools are built on it is enough at this level.
Explain the mechanics: the callback signature (frame, event, arg), the event names, and that the global callback fires on frame entry while its return value handles the per-line events for that frame. Mention sys.gettrace() and the single slot per thread.
Show that you know the operational consequences: per-event Python calls make this a test-time instrument, it covers only the installing thread, and an exception inside the callback silently uninstalls it. Say when you would reach for a coarser hook instead.
Own the policy question: whether deep instrumentation ships in the product at all, who arbitrates the single per-thread slot between a debugger, coverage and any home-grown tracer, and whether new in-house tooling should target the older callback hook or the newer event-set API.
## The interpreter calling back into you Most of the time Python code runs and nothing watches it. `sys.settrace` inverts that: it hands the interpreter a Python callable and asks to be told about execution as it happens. That single hook is the foundation under the standard library's debugger and under the line-coverage tooling generations of Python projects have used. The signature the interpreter uses is always the same three arguments: ```python def tracer(frame, event, arg): ... ``` * `frame` is the live frame object for the code being executed - it exposes the current line number, the code object, and the local and global namespaces. * `event` is a short string naming what just happened: `'call'`, `'line'`, `'return'`, `'exception'` (and `'opcode'` if a frame explicitly opts in). * `arg` carries the payload for the event: the returned value on `'return'`, the `(type, value, traceback)` triple on `'exception'`, `None` for the others. ## Two levels, not one The function you pass to `sys.settrace` is the **global** trace function, and the interpreter calls it for one thing only: a new Python frame being entered, i.e. the `'call'` event. What it returns decides what happens next inside that frame. Return a callable and that callable becomes the frame's **local** trace function, receiving the fine-grained `'line'`, `'return'` and `'exception'` events for that frame alone. Return `None` and the interpreter simply does not instrument that frame further - no per-line callbacks at all. That design is deliberate: a debugger stepping through one function should not pay per-line cost for every frame in the process. The global handler is the filter, the local handler is the microscope. ## Reading back what is installed `sys.gettrace()` returns the current global trace function for the calling thread, or `None`. There is a single slot per thread and no stacking or registry: whoever calls `sys.settrace` last wins, and the previous hook is gone without a warning. In practice that is why a coverage tool and a debugger fight over the same process, and why polite tooling reads `sys.gettrace()` first and either refuses to install or chains to what it found. The hook is also **per-thread state**. Calling `sys.settrace` from the main thread instruments the main thread; a thread started afterwards begins its life untraced. `threading.settrace` exists precisely to close that gap for threads started through the `threading` module. ## The parallel profiling hook `sys.setprofile` (read back with `sys.getprofile`) installs a related but coarser callback: it fires on function call and return, and additionally on calls into C functions, but never per line, and it has no local-callback level. The standard library's pure-Python profiler is built on it. Trace when you need statement granularity; profile when call granularity is enough. ## Who builds on it `bdb.Bdb` is the standard library's debugger base class. It installs a trace function, decodes each event into a `user_call` / `user_line` / `user_return` / `user_exception` method call, and manages breakpoints so those methods only fire where they should. The interactive debugger in the standard library is a subclass of it, and third-party debuggers historically followed the same shape. Line-coverage measurement is the other classic consumer: record `(filename, lineno)` for every `'line'` event, subtract from the set of executable lines, and you have a coverage report. ## Why it is a development-time tool Every event is a real Python function call made by the evaluation loop, on top of the work the traced program is doing. Per-line tracing routinely makes code several times slower, and instrumented code cannot take the specialized fast paths the adaptive interpreter would otherwise use. It is superb under a test suite and a poor fit for a hot request path. One more sharp edge worth carrying from day one: if the trace callback itself raises, the exception surfaces at the traced location **and** the interpreter unsets the trace function, exactly as if `sys.settrace(None)` had been called. If the surrounding code catches broadly, your instrumentation vanishes and nothing says so - `sys.gettrace()` quietly returns `None` from then on. ## The smallest useful consumer It is worth seeing how little code a line-coverage recorder is, because it makes the shape of the API obvious. A global handler returns a local handler for frames whose file you care about; the local handler stores `(filename, line number)` into a set on every `'line'` event and returns itself. Run the program, call `sys.settrace(None)`, and the set is the executed-lines report - everything else a real coverage tool does is working out which lines *could* have executed, reporting, and staying out of other tools' way. Understanding that the hook itself is this thin is what tells you where the cost and the complexity actually live. ## Turning it off `sys.settrace(None)` clears the hook for the calling thread and takes effect at once: even the frame that made the call reports no further `'line'` events. What it does *not* do is reach other threads - each one holds its own hook and has to clear it itself. Arm and disarm in a `try`/`finally` so an exception on the traced path cannot leave instrumentation running for the rest of the process.
- How would you check whether another tool already installed a trace function before you call sys.settrace()?Call `sys.gettrace()` first. It returns the current global trace function for this thread, or `None`. There is one slot per thread and no stacking, so installing yours silently displaces a debugger's or a coverage tool's. Well-behaved instrumentation either declines to install when something is already there, or keeps the old callable and calls through to it from its own.
- What happens to tracing if your sys.settrace callback itself raises an exception?The exception propagates at the traced location, and the interpreter unsets the trace function - the same state as `sys.settrace(None)`. That is a nasty failure mode: if the traced code catches exceptions broadly, the error is swallowed, instrumentation stops for that thread, and the only symptom is that events stop arriving. Guard the callback body and log from it defensively.
It is a wiretap on the interpreter rather than a report you ask for afterwards: the interpreter phones you on every step, and how much it phones you is exactly how much slower the program runs.
saying these in an interview costs you the question
- Thinks sys.settrace instruments every thread in the process
- Believes the callback receives source text rather than a frame
- Says trace hooks are cheap enough to leave on in production
- Assumes several tools can install trace functions side by side
- Confuses sys.settrace with sys.setprofile and expects line events from both