stealthrocket.tech
Spanruntime

Runtime

How CPython Prints a Python Stack Trace and Builds the Traceback

The lines that appear on stderr after an unhandled exception look like a transcript. They are not. CPython builds that text from a linked list it grew on the way out of the python call stack, and the difference explains several confusing gaps.

A flat schematic: four outlined rectangles stacked vertically with increasing left indentation, linked by short connectors, the deepest one outlined in violet with a short violet underline segment beneath it.
Bodyruntime.log

Cause an error in a Python program and something reasonable appears:

Traceback (most recent call last):
  File "settle.py", line 61, in <module>
    run_batch(rows)
  File "settle.py", line 44, in run_batch
    post(row)
  File "settle.py", line 30, in post
    return ledger[row.account] + row.amount
           ~~~~~~^^^^^^^^^^^^^
KeyError: 'acct_9931'

It reads like a recording. It is closer to a reconstruction, and knowing which it is turns out to matter the first time the trace is missing something. A Python stack trace is assembled during the unwind, from parts that are gathered at different moments, and two of those parts are read only when something finally prints them.

Nothing writes the Python stack trace at the moment of failure

When ledger[row.account] raises, CPython does not walk the stack and take a snapshot. It does something much cheaper: it creates the exception, attaches a one-element traceback naming the current frame, and starts unwinding. As each frame gives up, that frame prepends itself to the traceback object. Three frames deep, three prepends.

So the structure you eventually see is a singly linked list, built from the inside out, one link per frame the exception escaped through. Each node (a traceback object) carries a pointer to the frame (tb_frame), the instruction offset it was at (tb_lasti), a resolved line number (tb_lineno), and the next node (tb_next).

That construction order is the source of the first confusion. The list is built newest-first via prepend, which means by the time it is complete it reads oldest-first, which is exactly the order you want to print, and why “most recent call last” needs no reversal step anywhere.

The important consequence: the traceback contains the frames the exception passed through, not the frames that ran. A helper that was called and returned before the failure is not in it. A frame that swallowed one exception and raised another appears at the head of a different traceback. Nothing is being hidden; there was simply never a record of the full history to hide.

The call stack you can print on demand

The live Python call stack is a separate thing, and worth separating in your head. sys._getframe() hands you the current frame; frame.f_back walks outward one caller at a time. That chain is the call stack as it exists this instant. traceback.print_stack() formats it and traceback.extract_stack() gives you the structured form.

Both are useful and neither is a traceback. Asking for the python call stack this way gives you the frames that are live now, in caller order, whether or not anything has gone wrong. You can print the call stack in Python at any point, with no exception involved, and get a complete picture of how you got here. A traceback, by contrast, is the fossil of an unwind that already finished. Reaching for one when you wanted the other is a common half-hour.

What the traceback print step adds

traceback.print_exception is where the plain-text form gets built, and it does noticeably more work than the unwind did.

For each node it needs a filename and a line number, which come from the code object, the same frame internals a runtime touches when it serialises a paused coroutine. Then it wants the source text of that line, which is not in the code object at all, so it goes to linecache, which opens the file and caches it by name. This is why printing a deep traceback in a large codebase can be slow, and why a stack trace in Python can show <source not available> for a frame whose file has since been edited or was never on disk (an exec of a string, a REPL entry, a Jupyter cell). Every line of source text in a stack trace in Python arrives this way, from the file system, at print time, not from the exception.

Since 3.11 there is a second lookup, part of the same interpreter rework that moved the frame layout under every coroutine runtime. PEP 657 added per-instruction column information to code objects, so the printer can resolve tb_lasti to a start and end column and draw those ~~~^^^ markers under the failing subexpression. That is why the chained-subscript example above points at row.account specifically instead of shrugging at the whole line. It also means the marker depends on tb_lasti being accurate, which is why a traceback built by hand, or one whose frames were reconstructed instead of captured, can underline the wrong thing.

Aside: __notes__, which almost nobody uses

Python 3.11 also added BaseException.add_note(). You can attach arbitrary strings to an exception as it passes through a frame, and the printer will emit them after the exception line. It is a good fit for the case where an outer layer knows something the inner raise site did not — which account, which batch, which retry attempt — and the usual alternative is to catch, wrap in a new exception and lose the original traceback. Whether teams should be reaching for it more than they do is a separate argument; it is mentioned here because it is the one part of the traceback machinery that was designed for exactly this problem and still gets ignored.

Reading a trace with the mechanism in mind

Two habits follow from all of the above.

First, when a Python stack trace is shorter than expected, ask what caught and re-raised rather than what failed to log. That is the single most useful habit, because a truncated Python stack trace is almost never a formatting problem. Concretely: raise X from err preserves the original as __cause__ and both get printed. A bare raise X inside an except block sets __context__ and both still get printed. What loses the original is except: raise SomeError() in code that then catches that and discards it, and no amount of traceback configuration recovers what was thrown away.

Second, treat the source lines in a trace as advisory. They were read from the file at print time, not at raise time. In a long-running service that was deployed over, or in any environment where the file on disk has moved on, the line numbers are right and the text beside them may be from a different version of the program. That mismatch has cost more debugging time than any missing frame, and no setting anywhere in the traceback module protects you from it: the python call stack knows line numbers, and only the file on disk claims to know what is on those lines.

Clarifications6 entries

Questions this raises

Why is a Python stack trace sometimes missing frames I know ran?

The traceback only records frames the exception actually propagated through. A frame that caught the exception and re-raised a new one starts a fresh traceback; a frame that returned normally before the failure was never on the stack when the exception was raised. Neither appears.

Why does the traceback print oldest frame first when the list is built newest first?

Each frame prepends itself as the exception unwinds, so the linked list ends up in oldest-to-newest order by the time anything prints it. The printer walks tb_next from the head, which is the outermost frame. No sorting happens.

What added the little carets under the failing expression?

PEP 657, in Python 3.11. Code objects gained per-instruction column positions, so the traceback machinery can underline the exact subexpression that raised rather than just naming the line.

How do I get the call stack without an exception?

traceback.print_stack() or traceback.extract_stack() walk the live frames from the current one outward. That is the call stack as it is right now, which is a different object from a traceback: a traceback is a record of an unwind that already happened.

Why can printing a traceback be slow?

Because it reads source. For each frame the printer opens the file named in the code object and seeks to the line, going through linecache. On a cold cache in a large codebase, or with source on a network mount, that file I/O dominates the cost of printing.

What is the difference between __context__ and __cause__ in chained tracebacks?

__cause__ is set by an explicit raise ... from ... and prints as 'The above exception was the direct cause'. __context__ is set automatically when an exception is raised while another is being handled, and prints as 'During handling of the above exception, another occurred'. Both are attributes on the exception, not on the traceback.

Nextindex

Keep reading

The index orders every article from the widest subject to the narrowest; these are the two neighbours on that path.

All articles