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.

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.