◈ python mapVol 2 · Ch 25/30
Volume 2 Python, from the metal up · chapter 25

25Debugging — the traceback is the unwound stack

In Chapter 24 a green test bar certified that our code did exactly what we predicted. This chapter is about the other moment. The one where it doesn't, and the terminal fills with red. We take it slowly, because there's a question most tutorials sprint straight past. When Python prints a traceback, is it printing a message about the crash, or the crash itself? Here's the plan. First we read a traceback as what it physically is: the call stack you built in Volume 1, unwound and printed one frame at a time. Then we watch an exception stop being an instant. It becomes a process that climbs the stack, popping frame after frame, asking each one whether it has a rescuer. Then we meet pdb. Not some external program reaching in from outside, but ordinary Python the interpreter itself calls on every single line, through a hook that was in the language the whole time. And we turn debugging from panicked flailing into a binary search. It converges in about log₂(N) steps on the one line where your prediction and reality first split. By the end, the scariest wall of red in Python reads like a map.

iolinked · chapter 25 — the checkpoints6 steps
$ sections covered in Debugging — the traceback is the unwound stack
01A traceback is the call stack, printed
02Exceptions unwind the stack
03Read it bottom-up
04pdb rides the trace hook
05The scientific loop
06Post-mortem: inspecting the dead frames

01A traceback is the call stack, printed

Let's start with the thing everyone flinches at. You pressed Enter, and the terminal answered with a wall of red. Fifteen lines of File this and line that, arrows and indentation, and a word you half-recognise at the very bottom. The reflex is to flinch, scan for a keyword, and paste the whole thing into a search box. Kill that reflex now. That wall is not the machine yelling at you. It is the machine handing you its own X-ray: a traceback, already developed, free of charge. Every line in it is a hard fact about where your program was standing at the instant it fell over. Learn to read it and you diagnose in two seconds what print-debugging takes twenty minutes to chase.

To read it you need exactly one thing you already own. In Volume 1 you built the call stack. Every time one function calls another, Python pushes a frame: a small workspace holding that call's local variables and its place in the code. It pops that frame when the call returns. Now watch the reframe. A traceback is that stack. Not a description of it, not a summary, but the stack itself, unwound and printed one frame per block. That single sentence is the whole chapter. Everything else is consequence.

Here is the machinery, exactly. CPython keeps a per-thread record, the PyThreadState, holding a pointer to the frame currently running. Each frame carries a field f_back pointing at the frame that called it. So the frames form a singly-linked chain from the innermost call all the way back to the module that started everything. (Since 3.11 the hot per-call data lives in a lean _PyInterpreterFrame on the interpreter's own data stack. The heavier PyFrameObject you can poke at from Python is materialised lazily, only when something needs to hold onto it. A crash is exactly such a something.) When an exception reaches a frame that does not catch it, the interpreter calls a C function named PyTraceBack_Here. It allocates one PyTracebackObject recording three fields. tb_frame is a pointer to that very frame. tb_lineno is the line number. And tb_next is a link to the traceback node already built for the frame inside this one. The result is a second linked list running parallel to the f_back chain: a faithful shadow of the stack that was open at the moment raise fired.

Where does that line number come from? The frame never stored "I am on line 12." It stored f_lasti: the byte offset of the last bytecode instruction it ran. Python recovers the source line by looking that offset up in a compact table the compiler baked into the code object, co_linetable. In 3.11+, co_positions() adds exact column spans, which is how modern Python underlines the precise sub-expression that failed. The filename and function name in each block come straight off the frame's code object, co_filename and co_name. Nothing in a traceback is invented. Every field is copied or decoded from a frame that genuinely existed.

Now watch a three-deep chain fall over. Four calls are open: module, then load_config, then read_field, then to_int. So four frames are on the stack when int("x") refuses:

config.pypython
def to_int(raw):
    return int(raw)             # line 2 — int() raises here on 'x'

def read_field(row, key):
    return to_int(row[key])     # line 5

def load_config(rows):
    return read_field(rows[0], "port")   # line 8

load_config([{"port": "x"}])           # line 10
what the terminal printstraceback
Traceback (most recent call last):
  File "config.py", line 10, in <module>
    load_config([{"port": "x"}])
  File "config.py", line 8, in load_config
    return read_field(rows[0], "port")
  File "config.py", line 5, in read_field
    return to_int(row[key])
  File "config.py", line 2, in to_int
    return int(raw)
ValueError: invalid literal for int() with base 10: 'x'

Count them. Four calls were open, so four frames were on the stack, so the traceback has exactly four blocks. They appear outermost-first, innermost-last, in the same order as the f_back chain read from the module end. Block one is the <module> frame, since co_name is literally "<module>" for top-level code. Block four is to_int, the crash site, its line 2 decoded from that frame's f_lasti. There is no fifth block for int itself, because int is C code with no Python frame to capture. The mapping is total and mechanical. Frame N of the stack becomes block N of the text.

Step back, because this is the whole volume in miniature. The call stack is a graph of live objects: frames pointing at frames, each holding real Python values at real RAM addresses. To get it out of the process and onto your screen, Python had to flatten that graph into a flat stream of characters. That means one File …, line …, in … block per frame, in order. That is a serialization, the exact move this whole volume studies, whether the far side is a disk file, a socket, or a pickle. A traceback is the call stack serialized to text so it can cross the boundary out of the process. What crosses a boundary is always bytes, never objects. A traceback is the stack, flattened.

THE ONE IDEA TO CARRY FORWARD
A traceback is not a message about the stack — it is the stack, serialized. Every File …, in … block is one live frame that was open when raise fired, printed innermost-last. Read it as data, and it stops being scary: it is a free, complete snapshot of exactly where the machine was standing.
Wait — crash a function that calls itself five times before failing, and the traceback shows the same function name printed five times in a row. That is not a bug in the printer. Each of those identical lines is a distinct frame — five separate workspaces, five separate copies of the locals, stacked one on the next. It is the cleanest possible proof that a traceback block means "one frame," not "one function."
live frame stack (in memory)printed traceback4to_int()innermost — crash site3read_field()2load_config()1<module>f_back1File "app.py", line 40, in <module> main()2File "app.py", line 9, in load_config read_field(cfg)3File "app.py", line 15, in read_field return to_int(raw)4File "app.py", line 21, in to_int return int(s)ValueError: invalid literal 'x9'tb_frame→co_name / co_filename · f_lasti→lineno
Fig — Each traceback line is one live frame reversed: the interpreter walks f_back and prints co_name, co_filename, and the f_lasti line for every frame.

You can now map a traceback back to the stack that made it. But that stack didn't just sit there — it unwound, frame by frame, and the exception was moving the whole time. Next: watch it travel. →

02Exceptions unwind the stack

"An exception happened" sounds like a single instant: a switch flips, the program stops. That picture will fail you the moment you try to catch something and it slips past your except anyway. An exception is not an instant. It is a motion. It starts at the frame where raise fired and it travels up the stack, one frame at a time. It stops at each to ask a single question: does anyone here rescue me? If not, that frame is torn down and the exception rises to the caller, and asks again. Understanding this search is what lets you predict, before you run anything, exactly where a try has to sit to catch a given error. It also shows why a finally or a with block still runs on the way out.

The moment raise executes, it does not jump anywhere. It sets the pending exception on the thread state, the exc_info slots, and hands control back to the eval loop, _PyEval_EvalFrameDefault. Now comes the Volume-2 upgrade over the story you may have heard. Since Python 3.11, entering a try block costs nothing at runtime. This is what "zero-cost exceptions" means. There is no SETUP_FINALLY instruction pushing a block onto a runtime block-stack anymore. The happy path executes zero extra instructions. Instead, the compiler bakes a side table into each code object. That table is co_exceptiontable, a compact map from ranges of bytecode offsets to the handler offset that covers them.

So here is what actually happens when the exception is pending. The interpreter takes the current instruction offset in this frame and binary-scans the exception table. There are two outcomes, and only two. Suppose some row covers this offset. That means the failing instruction lies inside the byte-range of a try in this frame. Then the interpreter jumps to that row's handler offset, pushes the exception object, and runs your except or finally or __exit__ logic. The search is over. But suppose no row covers this offset. Then the frame is finalized. Its locals are decref'd, and any pending finally clauses and with-statement __exit__ methods fire as part of the unwind. Then PyTraceBack_Here records this frame into the growing traceback, section 1's list, being built one node per pop. Control returns to the caller frame, where the identical table lookup repeats against that frame's exception table. Pop, search, pop, search, up the f_back chain. This continues until either a frame catches it or the module frame is reached with nothing caught. At that point PyErr_PrintEx hands the finished traceback to sys.excepthook to print. "Zero-cost" means the whole price is paid once, at throw time, as table lookups during unwinding, never on the code that doesn't throw.

Here is the consequence that trips everyone: placement is a range test. A handler catches an exception only if the failing instruction's offset falls inside the byte-range the compiler recorded for that try. Put the try around the wrong line and the offset misses the range. The row doesn't match, and the exception sails straight past a handler that looks like it should apply. Watch it, and watch a with block clean up on the way out:

unwind.pypython
def parse(raw):
    with open("cache.tmp", "w") as f:   # f.__exit__ will still run on the way out
        f.write(raw)
        n = int(raw)                    # raises ValueError on 'x' — NO try in this frame
        return n * 2                     # never reached

def run():
    try:
        return parse("x")          # the covered range lives HERE, one frame up
    except ValueError:
        return -1                       # the rescuer

print(run())                          # -> -1, and cache.tmp was closed during the unwind

Trace the motion. int("x") raises inside parse. The interpreter scans parse's exception table for a row covering that offset — and there is none, because parse has no try. So parse is finalized: on the way out, the with block's f.__exit__ fires and the file is flushed and closed — cleanup rides the unwind, guaranteed, whether you exit normally or by exception. The frame pops, is recorded into the traceback, and the exception rises into run. Now the scan is against run's table, and the failing call site parse("x") is inside the covered range, so the row matches: jump to the except, bind the exception, return -1. The search stops. Move that try to wrap the wrong line and the offset misses; the handler never fires. Catching is geometry, not magic.

SYNTAX · assert — the tripwire that starts the unwind on purposetwo expressions, one comma, and a command-line flag that deletes every one of them
assert CONDITION, MESSAGE -- the whole grammar. one comma. no parentheses. # it compiles to exactly this, and to nothing else: if __debug__: if not CONDITION: raise AssertionError(MESSAGE) # the three places it belongs -- all of them inside YOUR code def add_track(library, title, secs): assert isinstance(secs, int), f"secs was {secs!r}" -- 1. PRECONDITION library[title] = secs assert len(library) >= 1 -- 2. POSTCONDITION assert total == sum(parts), f"{total} != {sum(parts)}" -- 3. INVARIANT, mid-loop $ python app.py -- __debug__ is True. every assert runs. $ python -O app.py -- __debug__ is False. every assert is GONE. # which is why an assert may never be the thing standing between a user and damage: assert age > 0, "age must be positive" -- WRONG. -O deletes this line. if age <= 0: raise ValueError("age must be positive") -- right. always runs.
the commaTwo expressions side by side, not a call. assert(cond, msg) builds a non-empty tuple, which is always truthy — so it never fires.
MESSAGEOptional, and you should never omit it. A bare AssertionError gives the next reader a line number and nothing else.
__debug__A compile-time constant, True by default. The compiler reads it at compile time, not run time.
-OFlips __debug__ to False and the whole statement vanishes from the bytecode. That is the feature, not a bug.
AssertionErrorAn ordinary exception. It unwinds exactly as Section 2 described: pop, search, pop, until a frame catches it.
vs Ch 24's assertsSame keyword, different job. A test asserts about one run you chose; these assert about every run you never see.
never for inputValidation has to survive -O. Anything a user, a file, or a network can cause needs raise, not assert.
what it buysThe crash moves closer to the cause. A bad value dies where it was born, not forty lines downstream.
you type
# invariants.py -- a tripwire that names what it caught
def add_track(library, title, secs):
    assert isinstance(secs, int), f"secs must be int, got {type(secs).__name__} {secs!r}"
    assert secs > 0, f"secs must be positive, got {secs}"
    library[title] = secs
    return library


lib = {}
add_track(lib, "Titanium", 245)
print("after one good add :", lib)
add_track(lib, "Levels", "340")          # a string sneaks in from a CSV
print("after one bad add  :", lib)


# ---------- the terminal ----------
$ python invariants.py
$ python -O invariants.py
you see
$ python invariants.py
after one good add : {'Titanium': 245}
Traceback (most recent call last):
  File "C:\tmp\ch25\invariants.py", line 12, in <module>
    add_track(lib, "Levels", "340")          # a string sneaks in from a CSV
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\tmp\ch25\invariants.py", line 3, in add_track
    assert isinstance(secs, int), f"secs must be int, got ...
           ^^^^^^^^^^^^^^^^^^^^^
AssertionError: secs must be int, got str '340'

$ python -O invariants.py
after one good add : {'Titanium': 245}
after one bad add  : {'Titanium': 245, 'Levels': '340'}
where beginners trip
  • Read the second run twice. Under -O the bad row was stored — the guard was not weakened, it was deleted.
  • Prove it yourself: python -c "import dis; dis.dis(compile('assert x, 1', '<s>', 'exec'))" shows nine instructions, and python -O -c ... shows two.
  • assert (cond, msg) with parentheses is always true. Python 3.12 warns: assertion is always true, perhaps remove parentheses?
  • An assert is not error handling. It says "this can never happen", so catching AssertionError to recover is a contradiction.
  • Asserts document your assumptions to the next reader. That is half their value, and it survives even when -O removes them.
WHY finally AND with ALWAYS RUN
They are not a promise Python politely keeps — they are welded into the unwind itself. Finalizing a frame is the step that runs its pending finally and __exit__. An exception cannot leave a frame without tearing it down, and tearing it down is what fires the cleanup. That is why a with open(...) closes the file even on a crash: closing is not skipped by the exception, it is caused by it.
Wait — if try blocks are truly zero-cost now, how does the interpreter know the newline positions and ranges without any setup instruction executing? It doesn't compute them at runtime at all — the co_exceptiontable is data, frozen into the compiled code object at compile time. You can even read it: dis.dis(func) in 3.11+ prints an ExceptionTable section listing each start..end -> target range. The handler was decided before your program ever ran; the throw just looks it up.
unwinding: the exception rises frame by frameto_int() — raises hereexc table: no rowMISS → popread_field() with open(...) as fexc table: no row__exit__ fires → MISS → popload_config() finally: log()exc table: no rowfinally fires → MISS → popmain() try/except ValueErrorrow covers pc ✓HIT → jump to except, token landsexception token risestraceback grows on each pop+ tb entry: to_int, line 21+ tb entry: read_field, line 15+ tb entry: load_config, line 9+ tb entry: main, line 3one linked tb_next node appended perframe the exception passed through.
Fig — Raising doesn't search functions — it pops frames: each frame's co_exceptiontable is checked, finally/__exit__ run on the way out, and only a covering row stops the rise.

The exception has landed and the traceback is printed. Now you have to read it — and the order it prints in is the deliberate reverse of the order it searched. Next: the one habit that turns forty lines of red into a two-second diagnosis. →

03Read it bottom-up

The instinct is to read a traceback like English, top to bottom, first line first. Follow that instinct and you march straight into someone else's framework, three libraries deep, past files you have never opened. You give up before you reach the part that concerns you. Reverse it. Go to the bottom first. The last line tells you what broke and why. The block directly above it tells you where. Everything above that is just the road that led there, so skim it only if you need to. This one habit is the single highest-leverage debugging skill there is, and it takes thirty seconds to learn.

The reason it works is structural, not stylistic. In section 1 you saw the traceback printed outermost-frame-first, innermost-frame-last. That print order is the deliberate inverse of the search order. The exception searched from the inside out. The printer lists from the outside in, so that the last block is always the frame nearest the crash. And beneath that final block sits one more line, of the form ExceptionType: message. That line is not a frame at all. It is type(exc).__name__ followed by str(exc): the actual failure, the type of thing that went wrong and the human-readable detail. So bottom-up reading is a fixed three-step protocol. (1) The last line is the diagnosis, what and why. (2) The frame block just above it is the crash site, the exact line of code where raise fired, recovered from that frame's f_lasti. (3) Scan upward only far enough to find the first frame you own rather than library code. Nine times out of ten you never reach step three.

SYNTAX · the last two lines — a three-error drillthe bottom line names the disease; the block above it names the file and the line
# the bottom TWO lines. everything above them is only the road. File "drill_name.py", line 7, in welcome -- WHERE: file, line, function print(build_greting(u)) -- the exact statement that ran ^^^^^^^^^^^^^ -- 3.11+: the sub-expression that failed NameError: name 'build_greting' is not defined -- WHAT: the disease, then the detail # the type is the diagnosis. read it before you read one line of your code. NameError -- this name is not bound HERE. a typo, or a missing import. TypeError -- right name, WRONG KIND of object for this operation. ValueError -- right kind, wrong CONTENT. int("x") AttributeError -- no such member. it is not the type you assumed it was. KeyError -- that key is not in the dict. IndexError -- that index is past the end of the sequence. ZeroDivisionError -- a denominator reached 0. look for an empty collection. ImportError -- the module loaded, the name inside it did not exist.
the typeThe first word of the last line, before the colon. It is a category of fault, and it already halves your search.
the messageEverything after the colon — str(exc). It usually names the offending value outright.
the caret rowSince 3.11, co_positions() underlines the exact sub-expression. On a long line it is the whole answer.
File …, line …The block directly above the message. That is where, decoded from the frame's f_lasti.
your file or theirsIf the bottom block sits in site-packages, walk up to the first block you own. That line passed the bad value in.
"Did you mean"3.10+ suggests near-miss names from the frame's own namespace. When it appears, it is almost always right.
the drillOne look, three facts: disease, file, line. Say them out loud before you open the editor.
you type
# ---------- drill_name.py ----------
def build_greeting(user):
    return f"Hello, {user['name']}!"

def welcome(users):
    for u in users:
        print(build_greting(u))

welcome([{"name": "Ajai"}])


# ---------- drill_type.py ----------
def total_minutes(tracks):
    total = 0
    for title, secs in tracks:
        total += secs
    return total / 60

LIBRARY = [("Blinding Lights", 200), ("Titanium", "245"), ("Levels", 340)]
print(total_minutes(LIBRARY))


# ---------- drill_index.py ----------
def third_track(titles):
    return titles[2]

def show(titles):
    print("the third track is", third_track(titles))

show(["Blinding Lights", "Titanium"])
you see
$ python drill_name.py
  File "drill_name.py", line 7, in welcome
    print(build_greting(u))
          ^^^^^^^^^^^^^
NameError: name 'build_greting' is not defined. Did you mean: 'build_greeting'?

  -> a NAME that is not bound.  drill_name.py, line 7.  I typed "greting".

$ python drill_type.py
  File "drill_type.py", line 5, in total_minutes
    total += secs
TypeError: unsupported operand type(s) for +=: 'int' and 'str'

  -> right names, WRONG KINDS.  drill_type.py, line 5.  one row holds "245".

$ python drill_index.py
  File "drill_index.py", line 3, in third_track
    return titles[2]
           ~~~~~~^^^
IndexError: list index out of range

  -> index past the end.  drill_index.py, line 3.  only two titles were passed.
where beginners trip
  • Reading the top block first. It is the oldest frame — your entry point — and it is innocent by construction.
  • Pasting the whole wall into a search box before reading the message. The message often names the exact bad value.
  • Confusing TypeError with ValueError. Wrong kind of object versus wrong content inside the right kind.
  • Fixing the crash line when the bad value was born earlier. The crash site is where it surfaced, not always where it started.
  • Ignoring the caret row on a chained call like a(b(c(x))) — it tells you which of the three actually failed.

There is one structure you must learn to recognise, because it stacks multiple tracebacks in a single dump: chained exceptions. When code raises a new exception while it is already handling one, Python links them so you don't lose the original. There are two flavours, and the wording tells them apart. An implicit chain prints During handling of the above exception, another exception occurred:. Python set this up automatically, storing the first exception in the second one's __context__ attribute the moment a new raise ran inside an except block. An explicit chain prints The above exception was the direct cause of the following exception:. You asked for it with raise NewError(...) from original, which sets __cause__ and signals deliberate intent: "I am translating that low-level error into this meaningful one." Each chained segment is a complete traceback of its own. One rule survives all the structure. The lowest exception line in the entire dump is still the one that ultimately crashed the program, and you read its cause chain by walking upward through the segments.

chain.pypython
def get_port(cfg):
    return int(cfg["port"])            # KeyError if 'port' is missing

def start(cfg):
    try:
        return get_port(cfg)
    except KeyError:
        raise RuntimeError("config is missing a port")   # implicit chain via __context__

start({})
the dump — read the LOWEST exception firsttraceback
Traceback (most recent call last):
  File "chain.py", line 6, in start
    return get_port(cfg)
  File "chain.py", line 2, in get_port
    return int(cfg["port"])
KeyError: 'port'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "chain.py", line 10, in <module>
    start({})
  File "chain.py", line 8, in start
    raise RuntimeError("config is missing a port")
RuntimeError: config is missing a port

Two segments, one story. The bottom line, RuntimeError: config is missing a port, is the exception that actually reached the top and crashed the program. That is your diagnosis. The bridge line tells you it did not appear from nowhere. It was raised while handling the KeyError in the top segment, which is the real root cause: a missing "port" key. The implicit "During handling…" wording is Python's honest confession that the second error happened because the first one did. Suppose get_port had instead written raise RuntimeError("config is missing a port") from err. Then the bridge would read The above exception was the direct cause…: same shape, but now stating that the translation was intentional. And raise ... from None suppresses the chain entirely, hiding the KeyError. That is occasionally kind to a user, often cruel to the next debugger.

THE THREE-STEP READ, EVERY TIME
(1) Jump to the bottom lineType: message — that is what and why. (2) Read the block just above it — that is where, the exact crash line. (3) On a chained dump, the lowest exception is what crashed; walk up through "During handling…" / "direct cause…" bridges to find the root. You will solve most bugs without ever reading the top of the traceback.

✗ The myth

"A traceback should be read from the top — that's where it starts, so that's the first clue."

✓ The reality

The top is the oldest frame — usually your entry point or a framework's, the least specific thing in the dump. The crash lives at the bottom, and the one-line verdict is the very last line. Reading top-down is reading the story in reverse of where the answer is.

Wait — "most recent call last" is printed right there in the header, and almost nobody reads it. It is a literal instruction: the calls are ordered oldest-to-newest, so the most recent — the one that broke — is last. Python has been telling you to read bottom-up in the first line of every traceback you have ever ignored.
read a traceback bottom-upTraceback (most recent call last): File "app.py", line 40, in <module> main() File "app.py", line 9, in load_config read_field(cfg) File "app.py", line 21, in to_int return int(s)ValueError: invalid literal for int(): 'x9'1WHAT & WHYread this line first2WHEREyour crash sitethe path that gothere — skim onlychained exceptionsKeyError: 'timeout' ← original contextValueError: bad config ← what surfaced"During handling of the above, another exception occurred"__cause__ / __context__ bridge = true crash below
Fig — Read the last line for the error, the frame right above it for the location, skim the rest — and for chained tracebacks follow the cause bridge to the segment that actually crashed.
NOW WRITE IT YOURSELFthree real tracebacks — name the disease, the file and the line, in one look each
Cover the solution and set a timer for ninety seconds. Three programs crashed. For each dump below, write down exactly three things before you think about a fix: the disease (the exception type, and what that type accuses in plain words), the file, and the line. Do not read the top block of any of them. Then, for each, answer one more question. Is the crash line the place you would actually edit, or did the bad value arrive from somewhere further up? Say which frame you would open first and why. Traceback 1. AttributeError: 'str' object has no attribute 'clock', with the block above it reading File "ex_a.py", line 12, in listing and the source line return [f"{t.title} ({t.clock()})" for t in tracks]. Above that sits File "ex_a.py", line 16, in <module> running print(listing(["Titanium"])). Traceback 2. KeyError: 'repeat', above it File "ex_b.py", line 6, in setting running return CONFIG[name], and above that File "ex_b.py", line 10, in describe with the caret row under setting('repeat'). Traceback 3. ZeroDivisionError: division by zero, above it File "ex_c.py", line 4, in average_length running return total / len(tracks), and above that File "ex_c.py", line 12, in <module> running print(report([])). One hint, and no more. In exactly one of the three, the exception type by itself tells you that a collection was empty, without you reading a single variable name.
show the solution
# ---------- 1 ----------
# disease : AttributeError -- this object has no member named clock
# file    : ex_a.py
# line    : 12, inside listing()
# the fix : the caller passed a bare string, not a Track.
#           print(listing(["Titanium"]))  ->  print(listing([Track("Titanium", 245)]))
#
# the tell: "'str' object has no attribute" names the type you ACTUALLY had.
#           Whenever the message names a type you did not expect, the bug is
#           upstream -- at the call that built the wrong thing, not at the dot.
#           Here the first block you own, line 16, is where the wrong list was made.


# ---------- 2 ----------
# disease : KeyError -- 'repeat' is not a key in CONFIG
# file    : ex_b.py
# line    : 6, inside setting()
# the fix : either add the key, or ask for it safely.
#           return CONFIG[name]        ->  return CONFIG.get(name, DEFAULTS[name])
#
# the tell: a KeyError's message IS the missing key, with no other text at all.
#           Note that the crash block is setting(), but the DECISION was made in
#           describe(), line 10 -- the caret row there points straight at
#           setting('repeat'). Two frames, one mistake, and the caret names it.


# ---------- 3 ----------
# disease : ZeroDivisionError -- a denominator reached 0
# file    : ex_c.py
# line    : 4, inside average_length()
# the fix : an empty library has no average. Say so instead of dividing.
#           if not tracks: return 0.0
#
# the tell: ZeroDivisionError almost never means "someone typed a zero". It means
#           a COLLECTION WAS EMPTY -- len(tracks) was 0. The first block you own,
#           line 12, shows report([]) and the empty list is right there in the text.


# ---------- the pattern in all three ----------
# Three tracebacks, three diseases, and not once did you need to read the top block.
# Bottom line -> type and detail. Block above it -> file, line, function.
# Then, and only then, walk up to the first frame you own to see who passed the bad value.

Reading a traceback tells you where a crash landed — after the fact. But sometimes you need to stop the machine mid-stride and look around while it's still alive. That means a debugger. And a debugger is not what you think it is. →

04pdb rides the trace hook

The first time you type breakpoint() and the program freezes and a (Pdb) prompt appears, it feels like sorcery. Something reached inside your running code, stopped time, and is now letting you walk around in the frozen machine. It feels like an external program with special powers over the interpreter. It is nothing of the kind. Demystifying it completely is worth more than a hundred pdb commands, because the same trick underneath powers coverage tools, profilers, and line tracers. And you can build a working one yourself in fifteen lines.

The foundation is a hook that has been in the language the whole time. CPython's eval loop can call back into Python between operations. You arm it with sys.settrace(fn), which stores fn as the thread's global trace function. Once tracing is active, _PyEval_EvalFrameDefault invokes that function on four well-defined events. 'call' fires when a frame is entered. 'line' fires when execution crosses into a new source line. 'return' fires when a frame exits. And 'exception' fires when one is raised. The 'line' event is the interesting one. The interpreter detects it by comparing the current instruction offset against the line boundaries decoded from co_linetable, the very same table section 1 used to build the traceback, gated by the frame's f_trace_lines flag. So a debugger does not "watch" your code from outside. It is a function the interpreter calls, from the inside, at every line boundary.

One more piece makes it precise. The trace function returns a value, and that return value becomes the per-frame local trace function. The interpreter stashes it in the new frame's f_trace and calls it for that frame's subsequent line events. Return None and that frame stops being traced. Return the function itself and you keep getting its lines. This is how a debugger follows one specific frame instead of drowning in every line of every library. Prove the whole thing is real and public with a tracer you can write yourself:

tiny_tracer.pypython
import sys

def tracer(frame, event, arg):
    print(f"{event:<9} line {frame.f_lineno:>3}  {frame.f_code.co_name}")
    return tracer          # return per-frame tracer so we keep getting 'line' events

def total(xs):
    s = 0
    for x in xs:
        s += x
    return s

sys.settrace(tracer)
total([1, 2, 3])
sys.settrace(None)
the event stream it printsoutput
call      line   8  total
line      line   9  total
line      line  10  total
line      line  11  total
line      line  10  total     # loop back — the 'for' line fires again each iteration
line      line  11  total
...
return    line  12  total

There is the machine, exposed. A 'call' at entry, a 'line' for every source line the interpreter crosses, and a 'return' at the end. The loop body reappears once per iteration, because it genuinely re-executes that line. Nothing is hidden. Now the reveal: pdb is just a well-dressed version of exactly this. It is built on the standard library's bdb.Bdb base class, which implements those four callbacks. On each 'line' event, bdb asks a single question: is there a breakpoint here, or am I single-stepping? If yes, it calls user_line, which fires up pdb's Cmd-based interactive prompt via cmdloop(). That prompt is a REPL. It reads commands (n, s, c, p, l…) from stdin, mutates the debugger's stepping state, and then returns so the eval loop continues. The (Pdb) prompt is a Python REPL running inside the interpreter's own per-line callback. Typing next simply tells bdb to stop again at the next line event in this frame.

And breakpoint()? It is the ergonomic front door and nothing more. It calls sys.breakpointhook(), which by default imports pdb and calls pdb.set_trace(), which calls sys.settrace to arm the hook starting at the current frame. Three hops from a builtin to the same primitive you just used by hand.

SYNTAX · breakpoint() and the five commands that matterone builtin freezes the machine; five letters walk it. The rest of pdb can wait.
breakpoint() -- put it on the line BEFORE the one you doubt # the essential five. everything else in pdb is a convenience. (Pdb) n next -- run this line; stop on the next one IN THIS FRAME (Pdb) s step -- run this line; step INTO any call it makes (Pdb) c continue -- run on to the next breakpoint, or to the end (Pdb) p expr print -- evaluate expr in THIS frame and show its repr (Pdb) q quit -- stop the program now # three more you will want inside a week (Pdb) l list -- show the source around where you are standing (Pdb) pp obj pretty -- pprint, for dicts and lists too big for one line (Pdb) w where -- the stack: every frame open right now $ PYTHONBREAKPOINT=0 python app.py -- every breakpoint() becomes a no-op $ python -m pdb app.py -- no source change at all; stop at line 1
breakpoint()A plain builtin, added in 3.7. It calls sys.breakpointhook(), which by default runs pdb.set_trace().
n vs sn treats a whole call as one step. s walks inside it. Reach for s only when you doubt the callee.
pEvaluates in the frozen frame. It is reading frame.f_locals — the real objects, not copies or strings.
the bare lineAnything pdb does not recognise as a command is run as Python. p just makes the intent unambiguous.
c in a loopContinue lands you at the same breakpoint() on the next lap. That is how you watch a value evolve.
qRaises bdb.BdbQuit, which prints a short traceback through bdb.py. Expected, not a crash.
PYTHONBREAKPOINTSet it to 0 and every breakpoint() in the program is disabled, with no source edit at all.
the prompt itselfA real REPL positioned inside one frame, opened by the trace hook and closed when it returns. Section 4's machinery, wearing a face.
you type
# five.py -- one stop, five commands
def as_clock(seconds):
    minutes, secs = divmod(seconds, 60)
    return f"{minutes}:{secs:02d}"


def report(tracks):
    total = sum(secs for _title, secs in tracks)
    breakpoint()
    label = as_clock(total)
    return f"{len(tracks)} tracks, {label}"


print(report([("Titanium", 245), ("Levels", 340)]))


# ---------- the terminal ----------
$ python five.py
you see
$ python five.py
> c:\tmp\ch25\five.py(10)report()
-> label = as_clock(total)
(Pdb) p total
585
(Pdb) s
--Call--
> c:\tmp\ch25\five.py(2)as_clock()
-> def as_clock(seconds):
(Pdb) p seconds
585
(Pdb) n
> c:\tmp\ch25\five.py(3)as_clock()
-> minutes, secs = divmod(seconds, 60)
(Pdb) n
> c:\tmp\ch25\five.py(4)as_clock()
-> return f"{minutes}:{secs:02d}"
(Pdb) p minutes, secs
(9, 45)
(Pdb) c
2 tracks, 9:45
where beginners trip
  • Asking for a value one line too early. Stop on line 3 and type p minutes and pdb answers *** NameError: name 'minutes' is not defined — that line has not run yet.
  • The arrow shows the line about to run, never the line just finished. Everything on it is still untouched.
  • A local named n, s, c, l or p is shadowed by the command. Type p n, or !n to force Python.
  • s into a stdlib call drops you inside CPython's own source. Type r (return) to finish that frame and come back.
  • A breakpoint() left in committed code will hang a server with an invisible prompt. Grep for it before you push.
TRACING IS SLOW — AND THAT'S WHY 3.12 CHANGED IT
settrace dispatches back into Python on every single line, so a traced program can run many times slower. That is fine for stepping through a bug, ruinous for profiling a hot loop. Python 3.12's sys.monitoring (PEP 669) offers a lower-overhead event API that tools can attach to specific events without paying the per-line tax. Classic pdb still rides settrace — which is exactly why you don't leave a tracer armed in production.
Interactive · the trace taxdrag the slider
a tracer's hook fires once per source line — arm one on a loop that runs: 1,000,000 lines NATIVE RUNTIME 25 ms WITH A LINE TRACER ARMED 1.1 s ≈ a whole second — you feel it THE SAME CODE — ONE PYTHON CALL PER LINE — RUNS YOU WAIT ~44× slower than the very same loop with the hook removed. native A 25 ms loop became a 1-second stall — every millisecond of it the interpreter phoning your tracer.
1M
Drag from a thousand lines up to a billion. The multiplier barely moves — the tax is a near-constant ~44×, one Python call per line — but the wall-clock crosses from “instant” to “go to lunch.” Stepping one line in pdb is free; a tracer does that same stop at every line, automatically. That is the whole reason you step with it and never profile with it — and why 3.12’s sys.monitoring exists to skip the per-line toll.
Fig — A line tracer isn’t slow by accident: settrace makes the eval loop call back into Python on every line, so a 25 ms run costs ~1.1 s traced — same code, ~44× the time, pure per-line tax.
Wait — breakpoint() is not a keyword and not magic — it is a plain builtin that calls a hook you can replace. Set the environment variable PYTHONBREAKPOINT=0 and every breakpoint() in the program becomes a silent no-op; set PYTHONBREAKPOINT=web_pdb.set_trace and they all open a browser debugger instead. You never touched the source. The call was always just sys.breakpointhook() in disguise.
the debugger is a trace hook on the eval loopfetch opcodeexecutenext instructioneval loop(one frame)c_tracefunc slotevents: calllinereturnexceptionon line boundarypdb.user_line() cmdloop() — REPLread command (n/s/c/p) …return → loop resumesarming the slotbreakpoint() → sys.breakpointhook() → pdb.set_trace() → sys.settrace(fn) fills slot
Fig — pdb isn't magic: sys.settrace installs a C-level hook the eval loop calls on every line, and stepping is just that hook opening a REPL and returning.
TYPE THIS · save it, then run itdebug_session.py — four commands find a bug that never raises anything
# debug_session.py -- which track is the longest? the answer is wrong.
TRACKS = [
    ("Blinding Lights", 200),
    ("Titanium",        245),
    ("Levels",          340),
    ("Clarity",         271),
]


def longest(tracks):
    best_title, best_secs = "", 0
    breakpoint()                       # freeze here and look around
    for title, secs in tracks:
        if secs > best_secs:
            best_title = title
    return best_title, best_secs


if __name__ == "__main__":
    print("longest:", longest(TRACKS))
$ python debug_session.py
> c:\tmp\ch25\debug_session.py(13)longest()
-> for title, secs in tracks:
(Pdb) p best_title, best_secs
('', 0)
(Pdb) n
> c:\tmp\ch25\debug_session.py(14)longest()
-> if secs > best_secs:
(Pdb) p title, secs
('Blinding Lights', 200)
(Pdb) n
> c:\tmp\ch25\debug_session.py(15)longest()
-> best_title = title
(Pdb) n
> c:\tmp\ch25\debug_session.py(13)longest()
-> for title, secs in tracks:
(Pdb) p best_title, best_secs
('Blinding Lights', 0)
(Pdb) c
longest: ('Clarity', 0)


# ---- now delete the breakpoint() and fix line 15 ----
#         best_title = title
#         best_title, best_secs = title, secs

$ python debug_session.py
longest: ('Levels', 340)
Type the file, run it, and answer the prompt one command at a time. This bug has no traceback, which is exactly why it is worth walking. The program is quietly, confidently wrong. Start with p best_title, best_secs: ('', 0), the honest starting state, nothing suspicious. Then n once and ask for the loop's own variables. ('Blinding Lights', 200) — the first track is in hand, and 200 is plainly bigger than 0. Two more n presses take you through the if, through the assignment, and back around to the for. That is one complete lap. Now repeat your very first question, and read the answer slowly: ('Blinding Lights', 0). There it is. The title moved. The number did not. A whole lap ran with secs equal to 200, and best_secs came out the other side still holding 0. Look back at line 15 and the reason is unmissable once you have seen the evidence: the if updates best_title and simply never touches best_secs. So the comparison is forever secs > 0, every track passes, and best_title ends up holding whichever track happened to come last. Press c and watch it print ('Clarity', 0) — the wrong answer, now fully explained. Fix line 15 to best_title, best_secs = title, secs, delete the breakpoint(), rerun, and you get ('Levels', 340). Two things to try. First, put the breakpoint() inside the loop instead and press c four times, printing best_secs at each stop — you will watch the same zero refuse to move, once per track. Second, comment the breakpoint() out and add print(f"{title=} {secs=} {best_secs=}") as the first line of the loop body. Both find it. The print version costs you one rerun and a line you must remember to delete; the debugger version cost you nothing but four keystrokes, and it let you ask a question you had not thought of in advance.

Now you can stop the machine anywhere and inspect it. But where should you stop? Poking at random is just slow flailing with a nicer prompt. Next: the method that guarantees progress — treat the bug as an experiment. →

05The scientific loop

Under pressure, almost everyone debugs the same bad way. Change a line that looks suspicious, sprinkle print everywhere, rerun, squint, change something else, rerun again. It feels like work, since you're typing and the program's running. But it converges glacially and teaches you nothing, because none of it is testing a specific idea. There is a method that guarantees progress on every step, and it comes straight out of a first principle you already trust from Volume 1: the machine is deterministic. Identical bytecode, identical inputs, identical starting state produce identical output, every single time.

Sit with what that implies, because it dissolves the whole mystery. A bug is not a spooky, intermittent event. It is a fixed, reproducible divergence between the state you predicted and the state that actually exists. And it first appears at one specific line: the first line where your prediction and reality disagree. Everything before that line is correct by definition. If it weren't, the divergence would have shown up earlier. Everything after it is downstream corruption, wrong values propagating from a fault that already happened. So debugging is not a hunt through a fog. It is a search for a single boundary on the execution timeline: the last known-good point and the first known-bad point, with the bug pinned exactly between them.

And a search for a boundary on an ordered line is the oldest efficient algorithm there is: binary search. If the program runs N steps and you can observe state at any step, checking the midpoint tells you which half holds the first divergence. Is the state still correct at step N/2? Then the bug is in the later half — everything up to the midpoint is exonerated. Is it already wrong there? Then the bug is in the earlier half. Each observation halves the suspect region, so you localise the fault in about log₂(N) checks, not N. A thousand-step program yields to ten well-placed probes. That is the difference between a bug that takes two minutes and one that takes two hours — not talent, just refusing to look anywhere but the midpoint of the current suspect region.

Two preconditions make the experiment valid, and skipping either is why debugging sometimes feels impossible. First, reproduce reliably. A bug that appears "randomly" is lying to you. It means there is a hidden input you have not pinned. The usual culprits are wall-clock time, dict or set ordering under a fresh PYTHONHASHSEED, thread interleaving, an un-seeded RNG, or the classic reused mutable default argument. Pin every input until the failure is deterministic: seed the RNG, fix the hash seed, freeze the clock, force one thread. Determinism is what makes an experiment repeatable, and an experiment you can't repeat can't be halved. Second, every hypothesis must be falsifiable. It must predict a concrete, observable value. "After line 14, total should be 42" can be confirmed or refuted by running line 14. "Something's wrong with the loop" predicts nothing and can't be tested against anything. Vague hunches cannot be binary-searched. Specific predictions can.

SYNTAX · the five moves — shrink it, halve it, say it out louda procedure, not a mood: every move either localises the fault or exonerates a region
1. REPRODUCE -- one command that fails EVERY time. Pin every hidden input. $ PYTHONHASHSEED=0 python app.py fixtures/bad_row.csv seed the RNG, freeze the clock, force one thread, fix the order. 2. MINIMISE -- delete everything the failure does not need. HALF at a time. 400 lines -> 200 -> 50 -> 4. still fails? the cut was safe, keep cutting. stopped failing? you just deleted the bug. put that half back. 3. BISECT THE RUN -- probe the MIDPOINT of the suspect region. never the start. good at step N/2 -> the fault is LATER. discard the first half. bad at step N/2 -> the fault is EARLIER. discard the second half. log2(N) probes, not N. sixteen steps yield to four. 4. BISECT HISTORY -- if it used to work, the bug is a commit. Let git find it. $ git bisect start $ git bisect bad ; git bisect good v1.2 $ git bisect run python -m pytest tests/test_sync.py 5. EXPLAIN IT -- say the code out loud, line by line, to a rubber duck. you will interrupt yourself mid-sentence. that interruption is the bug.
1 · reproduceThe gate for everything else. A failure you cannot repeat cannot be halved, because each run moves the boundary.
2 · minimiseCut in halves, not lines. A four-line repro often names the bug before you have run it once.
the shrink testIf a cut makes the failure disappear, that half held the bug. Put it back and cut the other half.
3 · midpointNever probe where you suspect. Probe the middle: a hunch confirms one line, a midpoint eliminates half the program.
the invariantBisecting needs one yes/no question you can ask at any step. "Are all values still positive" is enough.
4 · git bisectThe same halving over commits instead of steps. bisect run automates it: give it a command whose exit code is the answer.
5 · the duckExplaining forces you to state what each line should do. The gap between should and does is where you stop mid-sentence.
when to stopWhen the good and bad boundaries touch on one line. Not when you have a theory — when you have a boundary.
you type
# bisect_probe.py -- eight stages, one lies. Three probes, not eight.
STAGES = [
    ("load",      lambda xs: [3, 8, 5, 13]),
    ("scale",     lambda xs: [x * 10 for x in xs]),
    ("shift",     lambda xs: [x - 5 for x in xs]),
    ("normalise", lambda xs: [x // 5 for x in xs]),
    ("trim",      lambda xs: [x - 20 for x in xs]),
    ("smooth",    lambda xs: [x + 1 for x in xs]),
    ("clamp",     lambda xs: [min(x, 99) for x in xs]),
    ("round",     lambda xs: [round(x) for x in xs]),
]


def run_to(n):
    xs = []
    for _name, fn in STAGES[:n]:
        xs = fn(xs)
    return xs


def ok(xs):                       # the invariant: every value stays positive
    return all(x > 0 for x in xs)


print(f"final answer      : {run_to(8)}   invariant holds? {ok(run_to(8))}")
for probe in (4, 6, 5):           # midpoint, then midpoint of the bad half, then...
    xs = run_to(probe)
    print(f"probe after {probe} ({STAGES[probe - 1][0]:>9}): {str(ok(xs)):>5}   {xs}")
you see
$ python bisect_probe.py
final answer      : [-14, -4, -10, 6]   invariant holds? False
probe after 4 (normalise):  True   [5, 15, 9, 25]
probe after 6 (   smooth): False   [-14, -4, -10, 6]
probe after 5 (     trim): False   [-15, -5, -11, 5]

   4 good, 6 bad, 5 bad  ->  the boundary lies between stage 4 and stage 5.
   stage 5 is "trim". three probes over eight stages, and nothing was guessed.
where beginners trip
  • Probing where you suspect instead of the midpoint. A confirmed hunch clears one line; a midpoint clears four hundred.
  • Changing two things between runs. Then a passing run tells you nothing, because you cannot say which change did it.
  • Skipping step 1. Debugging an intermittent failure is not hard work, it is invalid work — the experiment does not repeat.
  • git bisect needs a known-good tag. Tag releases, or you are bisecting a history with no left-hand end.
  • Minimising by rewriting rather than deleting. A rewrite introduces new code, and new code introduces new bugs.

Here is the most infamous "random" bug in Python, and how pinning it reveals the hidden input:

hidden_input.pypython
def collect(item, bucket=[]):     # bucket is created ONCE, at def-time — the hidden state
    bucket.append(item)
    return bucket

print(collect("a"))    # ['a']
print(collect("b"))    # ['a', 'b']  — 'randomly' remembers the previous call!

It doesn't remember randomly. It remembers deterministically. The default [] is one list object, created a single time when the def statement ran, and bound to the function forever. Every call that omits bucket shares that same list. So the "randomness" was a hidden input hiding in plain sight: the accumulated state of one object across calls. Predict the value of bucket at the top of the second call ("it should be empty") and reality says ['a']. The prediction and reality diverge right there, at the parameter binding, and the bug is pinned. The fix follows from the diagnosis, not from guessing. Write def collect(item, bucket=None): then bucket = [] if bucket is None else bucket, so a fresh list is born on each call. The lesson generalises past this one trap: reproduce first, and "random" resolves into a variable you forgot to pin.

SYNTAX · f"{var=}" — the print that labels itself, and the repr that tells the truththree upgrades: let Python write the label, show the repr, and print the shape not the value
print(secs) -- 1. bare. tells you a number. not WHICH number. print("secs:", secs) -- 2. hand-labelled. you type the name twice, and typos lie. print(f"{secs=}") -- 3. the = suffix. Python copies the SOURCE TEXT as the label. f"{secs=}" -- secs=245 f"{len(tracks)=}" -- len(tracks)=2 any expression works f"{tracks[0][1] * 2=}" -- tracks[0][1] * 2=490 your spacing is preserved verbatim f"{secs=:>6}" -- secs= 245 format specs still apply f"{title=!s}" -- title=Titanium force str(); = uses repr by default # the same string, two truths. one of them is the bug. print(title) -- Titanium str() -- looks fine. it is not. print(repr(title)) -- 'Titanium ' repr() -- THERE. a trailing space. print(type(x), len(x)) -- when a value is huge, print its SHAPE, not the value
the = suffix3.8+. The compiler copies the expression's source text into the output, so the label can never drift from the value.
it uses reprDeliberately. = defaults to repr() because a debugging print exists to show you exactly what is there.
str vs reprstr is for your user; repr is for you. Only repr keeps the quotes, the escapes, and the trailing space.
containersPrinting a list shows the repr of each element. That is why ['Titanium '] confesses what Titanium hid.
any expressionf"{len(rows)=}", f"{x in seen=}", f"{a < b=}". Print the question, not just the variable.
the shapeFor a large object, type(x) and len(x) beat ten screens of dump every time.
when print winsA loop over a thousand rows, a crash you cannot reproduce locally, a remote or CI-only failure — print gives you the whole history at once.
when it losesWhen you do not yet know which value matters. Every print is a guess made in advance; a breakpoint is a question asked after.
you type
# print_right.py -- three upgrades that turn a print into evidence
title  = "Titanium "                 # note the trailing space
secs   = 245
tracks = [("Titanium ", 245), ("Levels", 340)]

print(secs)                          # 1. bare -- a number, not WHICH number
print("secs:", secs)                 # 2. hand-labelled -- you type the name twice
print(f"{secs=}")                    # 3. the = suffix writes the label for you

print(f"{len(tracks)=}")
print(f"{tracks[0][1] * 2=}")
print(f"{secs=:>6}")
print(f"{title=!s}")

print(title)                         # str  -- looks fine. it is not.
print(repr(title))                   # repr -- THERE it is.
print([title])                       # a container always shows you reprs

lookup = {"Titanium": 245}
print(f"{title=}  {(title in lookup)=}")
print(type(tracks), len(tracks))     # when a value is huge, print its SHAPE
you see
$ python print_right.py
245
secs: 245
secs=245
len(tracks)=2
tracks[0][1] * 2=490
secs=   245
title=Titanium
Titanium
'Titanium '
['Titanium ']
title='Titanium '  (title in lookup)=False
<class 'list'> 2
where beginners trip
  • Line 7 of the output is the whole lesson: Titanium and 'Titanium ' are the same string, and only one of them shows the space.
  • That space is why the last line reads False. The dict has "Titanium"; the lookup asks for "Titanium ".
  • Hand-typed labels drift. Rename secs to seconds and print("secs:", seconds) lies forever, silently.
  • The = keeps your spacing exactly: f"{a + b=}" and f"{a+b=}" print different labels for the same value.
  • Prints go to stdout while tracebacks and logging go to stderr. Pipe one and the two streams stop interleaving in order.
  • A print you forget to delete becomes noise in someone's log. If it earned its place, promote it to log.debug instead.
THE LOOP, IN ONE BREATH
Reproduce deterministically (pin every input). Predict a concrete value at the midpoint of the suspect region. Run to there. Compare: match pushes the good-boundary forward, mismatch pulls the bad-boundary back. Repeat, halving each time, until the two boundaries touch on one line. That line is the bug. About log₂(N) checks, guaranteed — not flailing, converging.
Wait — if determinism is what makes debugging tractable, then the hardest bugs are precisely the ones that break it: threads, un-seeded randomness, and hash ordering. Run a program that prints a set twice under different PYTHONHASHSEED values and the order changes between runs — same code, different output, no determinism to search. The first move on any "heisenbug" is not to debug it but to make it deterministic: fix the seed, single-thread it, freeze the clock. You cannot binary-search a moving target.
bisect the run: find the first bad stepexecution steps 1 … Nstate known-goodstate known-badboundary unknownprobe 1good → push boundary rightprobe 2bad → push boundary leftprobe 3first divergence= the bugmake it repeatable first:seed RNG · PYTHONHASHSEED=0 · freeze the clock · one thread — else each run moves the boundary
Fig — Debugging is binary search over a run: halve the known-good/known-bad gap with each probe until the boundary is one step — but only after you pin every source of nondeterminism.
NOW WRITE IT YOURSELFa wrong number and no traceback — decide where the first breakpoint goes, and defend it
Here is the whole program. QUEUE = ["Blinding Lights", "Titanium", "Levels", "Clarity", "Sunflower"], then def play_all(queue): with played = 0, a for track in queue: loop whose body is queue.remove(track) followed by played += 1, and finally return played. The last two lines print queued: 5 and then played: the result. Run it and it prints queued: 5 and played: 3. Nothing raises. Nothing is red. It is simply, calmly wrong. Answer in writing before you touch the file. There are exactly four places a breakpoint() could go: above the for, on the first line of the loop body, on played += 1, or on the return. Pick one and write two sentences saying why that line and not the other three. Then name, in advance, the exact expression you will type at the first (Pdb) prompt — and say what value you predict it will show. A prediction you write down is a hypothesis; one you make up afterwards is a memory. Now run it. Add your breakpoint, press c at each stop rather than n, and print the same expression every lap. Count the stops. You expected five and you will get three, and the three values you printed will spell out the reason without you reading a single line of the source again. Write the one-sentence explanation, then fix it, then rerun until it prints played: 5. One hint, and no more. Print the loop variable and the list you are looping over in the same command.
show the solution
# ---------- where to break, and why ----------
# Break on the FIRST line of the loop body, before queue.remove(track).
#
# The reasoning, and it is the only part that matters:
#   played is not a value you can inspect once. It is BUILT, one lap at a time.
#   The only place a built value can go wrong is inside the thing that builds it.
#   And of the two candidates -- the loop, or the return -- the return is a
#   single expression with no arithmetic in it. So the loop is the whole suspect
#   region, and the top of its body is that region's earliest observable point.
#
# Do NOT break on the return. By then the evidence is one number with no history.
# Do NOT break before the for. Nothing has happened yet; you learn only the input.
#
# Print BOTH the loop variable AND the list you are iterating. That pairing is
# the entire trick: a loop bug is almost always a disagreement between the two.


# ---------- mystery.py, with the breakpoint added ----------
QUEUE = ["Blinding Lights", "Titanium", "Levels", "Clarity", "Sunflower"]


def play_all(queue):
    played = 0
    for track in queue:
        breakpoint()
        queue.remove(track)
        played += 1
    return played


print("queued:", len(QUEUE))
print("played:", play_all(QUEUE))


# ---------- the session ----------
$ python mystery.py
queued: 5
> c:\tmp\ch25\mystery.py(9)play_all()
-> queue.remove(track)
(Pdb) p track, played, queue
('Blinding Lights', 0, ['Blinding Lights', 'Titanium', 'Levels', 'Clarity', 'Sunflower'])
(Pdb) c
> c:\tmp\ch25\mystery.py(9)play_all()
-> queue.remove(track)
(Pdb) p track, played, queue
('Levels', 1, ['Titanium', 'Levels', 'Clarity', 'Sunflower'])
(Pdb) c
> c:\tmp\ch25\mystery.py(9)play_all()
-> queue.remove(track)
(Pdb) p track, played, queue
('Sunflower', 2, ['Titanium', 'Clarity', 'Sunflower'])
(Pdb) c
played: 3


# ---------- read the three stops as one sentence ----------
# track goes:  Blinding Lights -> Levels -> Sunflower.
# It skipped Titanium and Clarity -- every second item.
#
# A for loop over a list holds an internal index and walks it forward: 0, 1, 2...
# remove() shifts everything after the removed item DOWN by one. So after lap 1
# the index moves to 1 while the item that used to be at 1 has slid to 0.
# The loop and the list disagree about where "next" is, and half the queue is missed.


# ---------- the fix ----------
    for track in list(queue):        # iterate a COPY; drain the real one

$ python mystery.py
queued: 5
played: 5

# The law behind it: never mutate the collection you are iterating.
# Iterate a copy (list(queue)), or build a new list and rebind at the end.

The scientific loop finds bugs while the program runs. But the best evidence is often at the crash scene itself — and that scene doesn't have to vanish when the program dies. Next: re-enter the dead frames and interrogate the real objects that were alive at the instant of failure. →

06Post-mortem: inspecting the dead frames

Print debugging has a cruel structure. You must decide in advance which variables to print, then rerun, discover you printed the wrong ones, add more prints, rerun again. Each cycle is a guess about which value mattered. And each print shows you only a stringified snapshot frozen at print-time, str(x), not x itself. What you actually want is to arrive at the crash scene with everything still intact. After the program has already died, re-enter the exact frames that unwound and interrogate the real, live objects that existed at the moment raise fired. No rerun. No guessing which value was the one. This is post-mortem debugging, and it works for a reason that ties this whole chapter together.

Normally, frames are freed as the stack unwinds. Section 2 showed each frame being finalized and its locals decref'd on the way out. So how can they still be there after the crash? Because of section 1: the traceback holds strong references to every frame it captured. Each PyTracebackObject.tb_frame keeps its PyFrameObject alive even after the lightweight _PyInterpreterFrame on the C data stack is gone. This is precisely what "frame materialization" was for. The frames are dead in the sense that execution has left them. But they are still sitting on the heap, tethered by the traceback's tb_next chain. And CPython stashes that chain where you can reach it. After an unhandled top-level exception, sys.last_traceback (with sys.last_type and sys.last_value) points straight at it.

That is the entire mechanism behind post-mortem. pdb.pm() reads sys.last_traceback. Then pdb.post_mortem(tb) walks tb_next to the innermost node, the crash site, and opens the interactive prompt positioned in that frame. There frame.f_locals is the actual dict of live objects that existed when the exception fired. From there u and d walk the frame chain (tb_frame's f_back), so you can inspect any caller's real state too. Watch it turn a rerun-and-guess loop into a single shot:

a post-mortem sessionpython
>>> def crunch(rows):
...     total = 0
...     for r in rows:
...         total += int(r["n"])      # ValueError on a bad row
...     return total
...
>>> crunch([{"n": "10"}, {"n": "oops"}])
Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: 'oops'
>>> import pdb; pdb.pm()          # re-enter the dead innermost frame
> <stdin>(4)crunch()
(Pdb) p r                          # the EXACT row that killed it — a live dict, not a string
{'n': 'oops'}
(Pdb) p total                      # the accumulator, frozen mid-loop at the crash
10
(Pdb) u                            # walk f_back up to the caller frame
(Pdb) p rows                       # the caller's real locals, still alive on the heap
[{'n': '10'}, {'n': 'oops'}]

No rerun, no advance guessing. The malformed row {'n': 'oops'} is right there in frame.f_locals as the actual dict object. And total shows the loop had accumulated 10 before it died: the state of the machine at the instant of failure, preserved. breakpoint() from the last section is the live counterpart. It arms the trace hook to drop you in before the crash, at a line you choose. Same frames, inspected either just before death or just after. Post-mortem is simply the after.

SYNTAX · logging, first taste — a print with a level and a namefour calls, one config line, and a switch that turns detail on without editing code
import logging log = logging.getLogger(__name__) -- one per MODULE. it names itself. log.debug("cache miss for %s", key) -- 10 for YOU, while developing log.info("saved %d tracks", n) -- 20 the program is doing its job log.warning("library is empty") -- 30 odd, but we carried on log.error("no library at %s", path) -- 40 this operation FAILED log.exception("save failed") -- 40 + the traceback. inside except only. # ONE line, ONCE, in the entry point. never inside a library module. logging.basicConfig( level=logging.DEBUG, -- the threshold. below it, nothing prints. format="%(levelname)-8s %(name)s: %(message)s", ) # -> DEBUG jukebox.storage: cache miss for 7 %(levelname)s -- DEBUG / INFO / WARNING / ERROR / CRITICAL %(name)s -- the logger's name, so you can see WHICH module spoke %(message)s -- your text, with the % arguments already substituted %(asctime)s -- a timestamp, the moment ordering starts to matter log.debug("row %d of %d", i, n) -- LAZY: the % runs only if DEBUG is enabled log.debug(f"row {i} of {n}") -- eager: the f-string is built even when hidden
getLogger(__name__)The whole convention. Every module gets a logger named after itself, so the output says who spoke.
the levelA number, not a mood. DEBUG 10, INFO 20, WARNING 30, ERROR 40, CRITICAL 50. Below the threshold, nothing prints.
basicConfigCalled once, in the entry point. A library that configures logging steals the decision from its caller.
the format stringOld-style % named fields. %(levelname)-8s pads to eight so the columns line up and you can scan them.
%s, not f-stringsPass the arguments and let logging interpolate. A hidden debug call then costs nothing at all.
log.exceptionERROR plus the current traceback, attached automatically. Only valid inside an except block.
debug vs printA print is a note to yourself that you must remember to delete. A debug line is a note you can switch back on next year.
honestly deferredHandlers, files, rotation, propagation, dictConfig, JSON logs. All real, all later. This box is the taste, not the meal.
you type
# ---------- logging_taste.py -- before you configure anything ----------
import logging

log = logging.getLogger(__name__)      # a logger named after this module

log.debug("cache miss for track 7")    # invisible -- below the default threshold
log.info("library loaded")             # invisible -- same reason
log.warning("library file is empty")   # VISIBLE
log.error("library file is missing")   # VISIBLE


# ---------- logging_taste2.py -- one line of config changes everything ----------
import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(levelname)-8s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)

log.debug("cache miss for track 7")
log.info("library loaded")
log.warning("library file is empty")
log.error("library file is missing")
you see
$ python logging_taste.py
library file is empty
library file is missing

   two calls vanished, and the two that printed carry no level and no name.
   that bare output is logging's "last resort" handler: WARNING and up, no format.

$ python logging_taste2.py
DEBUG    __main__: cache miss for track 7
INFO     __main__: library loaded
WARNING  __main__: library file is empty
ERROR    __main__: library file is missing

   same four calls. one config line, and every one of them speaks.
where beginners trip
  • Calling logging.warning(...) at module level instead of on your own logger. That one auto-configures and prints WARNING:root:..., which is why root shows up in so many logs.
  • basicConfig does nothing on the second call unless you pass force=True. It configures; it does not reconfigure.
  • Logging writes to stderr. python app.py > out.txt captures your prints and leaves every log line on the terminal.
  • log.debug(f"...") builds the string even when DEBUG is off. Use log.debug("%s", x) and the work is skipped entirely.
  • Configuring logging inside a library module. The application chooses the level and the format — your module only reports.

There is a sharp corollary hiding in all this, and it is a real memory bug people ship. Tracebacks pin frames, and frames pin their locals. So holding onto a traceback keeps an entire chain of frames — and every object they reference — alive. The most common way to do this by accident is to stash the exception itself. An except Exception as e gives you an e whose e.__traceback__ is the whole chain. Save that e in a list or a module global "for logging later" and you have quietly rooted a graph of dead frames and all their big local objects. That is a leak that grows with every handled error. This is exactly why Python automatically deletes the as e name at the end of an except block. And when you must keep something, the fix is to keep the message or a formatted string, or to call traceback.clear_frames(e.__traceback__), rather than the live exception.

TYPE THIS · save it, then run it twicelogging_upgrade.py — the jukebox's prints become a dial you can turn from the command line
# ---------- jukebox/storage.py ----------
"""storage.py -- save and load the library. Now it reports, instead of shouting."""
import json
import logging
from pathlib import Path

log = logging.getLogger(__name__)          # "jukebox.storage" -- it names itself


def save(tracks, path):
    log.debug("save() called: %d tracks -> %s", len(tracks), path)
    if not tracks:
        log.warning("saving an EMPTY library to %s", path)
    Path(path).write_text(json.dumps(tracks), encoding="utf-8")
    log.info("saved %d tracks to %s", len(tracks), path)


def load(path):
    log.debug("load() reading %s", path)
    if not Path(path).exists():
        log.error("no library at %s -- starting empty", path)
        return []
    tracks = json.loads(Path(path).read_text(encoding="utf-8"))
    log.info("loaded %d tracks from %s", len(tracks), path)
    return tracks


# ---------- logging_upgrade.py  (one level UP from the package) ----------
# logging_upgrade.py -- one switch decides how much the program tells you
import logging
import sys

from jukebox import storage

logging.basicConfig(
    level=logging.DEBUG if "-v" in sys.argv else logging.INFO,
    format="%(levelname)-8s %(name)s: %(message)s",
)
log = logging.getLogger("jukebox.main")

LIB = "library.json"
storage.save([["Titanium", 245], ["Levels", 340]], LIB)
log.info("titles on disk: %s", [t for t, _s in storage.load(LIB)])
storage.save([], LIB)
storage.load("missing.json")
log.debug("run finished")


# ---------- what storage.py used to say ----------
#     print("saving", len(tracks), "tracks to", path)
#     print("saved")
#     print("!!! no library at", path)
$ python logging_upgrade.py
INFO     jukebox.storage: saved 2 tracks to library.json
INFO     jukebox.storage: loaded 2 tracks from library.json
INFO     jukebox.main: titles on disk: ['Titanium', 'Levels']
WARNING  jukebox.storage: saving an EMPTY library to library.json
INFO     jukebox.storage: saved 0 tracks to library.json
ERROR    jukebox.storage: no library at missing.json -- starting empty

$ python logging_upgrade.py -v
DEBUG    jukebox.storage: save() called: 2 tracks -> library.json
INFO     jukebox.storage: saved 2 tracks to library.json
DEBUG    jukebox.storage: load() reading library.json
INFO     jukebox.storage: loaded 2 tracks from library.json
INFO     jukebox.main: titles on disk: ['Titanium', 'Levels']
DEBUG    jukebox.storage: save() called: 0 tracks -> library.json
WARNING  jukebox.storage: saving an EMPTY library to library.json
INFO     jukebox.storage: saved 0 tracks to library.json
DEBUG    jukebox.storage: load() reading missing.json
ERROR    jukebox.storage: no library at missing.json -- starting empty
DEBUG    jukebox.main: run finished
Rebuild the jukebox/ package from Chapter 21 — an __init__.py and this storage.py — then put logging_upgrade.py beside the folder and run it both ways. Compare the two outputs by counting lines: six against eleven. Not one line of storage.py changed between them. That gap is the whole reason to leave prints behind. Read the first column and you can see the shape of the run at a glance: the WARNING caught an empty save, the ERROR caught a missing file, and everything INFO is just the program narrating a normal day. Read the second column and you get something a print can never give you — %(name)s naming which module spoke. jukebox.storage and jukebox.main are two different voices in one stream, and you got that for free by writing getLogger(__name__) once per module. Now notice what the -v run added. Every extra line is a log.debug that was always in the code, sitting silently below the threshold. On a quiet day it costs nothing; on the day the library goes missing you type five characters and the program tells you what it was doing when it happened. That is the difference between evidence you must predict in advance and evidence you can ask for afterwards. Three things to try. Change level to logging.WARNING and rerun: the run drops to two lines, the two that mean something is wrong. Add %(asctime)s to the front of the format string and watch the timestamps arrive with no other edit. Then wrap the storage.load("missing.json") call in a try, raise inside load instead of returning [], and use log.exception("load failed") in the handler — the whole traceback lands in the log, attached to the level that flagged it.
THE except ... as e LEAK
Storing except Exception as e beyond its block keeps e.__traceback__ alive, which pins the whole frame chain and every local object those frames reference — potentially megabytes of dead state per error. Python deletes the e name at the end of the block for exactly this reason. If you need details later, extract them now: save str(e) or traceback.format_exc(), not e itself. Same discipline as this entire volume — flatten the object graph to text at the boundary; don't carry live objects across it.
Wait — "dead" frames are not special ghost objects — they are perfectly ordinary, fully reachable Python objects. After a crash you can write tb = sys.last_traceback and then walk tb.tb_next.tb_next.tb_frame.f_locals by hand, reading the innermost frame's variables as a plain dict, no debugger involved. pdb.pm() is a convenience over data you can touch directly. The frames survive the crash for the same humble reason any object survives — something still holds a reference to them.
post-mortem: the traceback pins the dead framessys.last_tracebackholds the tb chain —keeps frames alive<module> (ghost)load_config (ghost)to_int (crash frame)f_locals = {s:'x9', base:10}tb_next / tb_framef_back ↑ (u / d)pdb.pm()re-enters crash frame; localsreal objects, not stringscaution — the same pinning is a leak:except Exception as e: → e.__traceback__ holds the whole frame chain (and all its locals)keep a ref to e past the block and every pinned frame's memory stays alive. del e when done.
Fig — Frames outlive their functions only while a traceback references them — that's what makes pdb.pm() and live f_locals possible, and what makes holding onto an exception a memory leak.
NOW WRITE IT YOURSELFa function that shouts — give every line the level it deserves, and defend each choice
Here is the function, exactly as it stands. def sync_library(rows, path): opens with print("starting sync ->", path) and print("rows in:", len(rows)). It builds good = [], then loops for i, row in enumerate(rows):. Inside, if "title" not in row it does print("BAD ROW at index", i, row) and continue; otherwise print(" keeping", row["title"]) and appends. After the loop, if not good: it prints "!!! nothing to write, the library would be wiped". It ends with print("done:", len(good), "rows kept") and returns good. Convert it, one print at a time. Add a module logger, then give each of the six prints a level — and write one sentence per print saying why that level and not the one above or below it. Two of them are genuinely arguable; find those two and say what would change your mind. Then handle the three details that separate a conversion from a rename. First, basicConfig must not live inside sync_library — put it where it belongs and say why. Second, make the verbosity a command-line switch, so python sync.py and python sync.py -v give different amounts of detail from the same code. Third, use lazy % arguments rather than f-strings, and use %r for the malformed row so you can see its real shape. Run it both ways with ROWS = [{"title": "Levels"}, {"secs": 271}, {"title": "Clarity"}] and then with an empty list. One hint, and no more: one of your six lines should probably not just report the problem — it should stop.
show the solution
# ---------- sync.py -- converted ----------
import logging
import sys

log = logging.getLogger(__name__)


def sync_library(rows, path):
    log.debug("sync_library(%d rows) -> %s", len(rows), path)
    good = []
    for i, row in enumerate(rows):
        if "title" not in row:
            log.warning("row %d has no title, skipping: %r", i, row)
            continue
        log.debug("  keeping %s", row["title"])
        good.append(row)
    if not good:
        log.error("nothing to write to %s -- refusing to wipe the library", path)
        return good
    log.info("kept %d of %d rows for %s", len(good), len(rows), path)
    return good


if __name__ == "__main__":
    logging.basicConfig(
        level=logging.DEBUG if "-v" in sys.argv else logging.INFO,
        format="%(levelname)-8s %(name)s: %(message)s",
    )
    ROWS = [{"title": "Levels"}, {"secs": 271}, {"title": "Clarity"}]
    sync_library(ROWS, "library.json")
    sync_library([], "library.json")


# ---------- the two runs ----------
$ python sync.py
WARNING  __main__: row 1 has no title, skipping: {'secs': 271}
INFO     __main__: kept 2 of 3 rows for library.json
ERROR    __main__: nothing to write to library.json -- refusing to wipe the library

$ python sync.py -v
DEBUG    __main__: sync_library(3 rows) -> library.json
DEBUG    __main__:   keeping Levels
WARNING  __main__: row 1 has no title, skipping: {'secs': 271}
DEBUG    __main__:   keeping Clarity
INFO     __main__: kept 2 of 3 rows for library.json
DEBUG    __main__: sync_library(0 rows) -> library.json
ERROR    __main__: nothing to write to library.json -- refusing to wipe the library


# ---------- the level for each print, and the reason ----------
# "starting sync -> path"   -> DEBUG    tracing. nobody needs it on a good day.
# "rows in: N"              -> DEBUG    same. it folds into the DEBUG entry line.
# "  keeping TITLE"         -> DEBUG    one line PER ROW. never INFO: it would
#                                       bury the real events under a thousand
#                                       lines the moment the input grows.
# "BAD ROW at index i"      -> WARNING  odd input, but we carried on. This is the
#                                       textbook WARNING: a decision was made to
#                                       skip something, and someone should know.
# "!!! nothing to write"    -> ERROR    this operation FAILED. The caller's
#                                       intent -- sync the library -- did not happen.
# "done: N rows kept"       -> INFO     the normal, successful outcome. One line
#                                       per call, and it is the line you want in
#                                       production when everything is fine.
#
# Two changes beyond level-mapping, and both are the point of the exercise:
#
#   1. %r for the bad row.  log.warning("... %r", row) shows {'secs': 271} with
#      its quotes and braces intact. %s would flatten it and hide the types.
#
#   2. return early on the error.  The print version reported the disaster and
#      then cheerfully carried on to "done: 0 rows kept". A log level is not just
#      a label -- deciding something is an ERROR usually changes the control flow.
#
# And note what did NOT change: sync_library() never calls basicConfig. Only the
# __main__ block does. A function that configures logging steals that choice from
# whoever imports it.

You began this chapter flinching at a wall of red. You end it able to read that wall as the unwound stack, watch an exception travel and clean up as it goes, ride the interpreter's own hook into a frozen machine, binary-search to the first divergence, and re-enter the dead frames to read the truth. The traceback was never noise. It was Volume 1's stack, handing you the answer. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 25, in working code

A traceback isn't decoration Python bolts on when things go wrong — it IS the call stack, frozen at the instant of the crash and printed in reverse. These twelve programs treat the exception as an object you can walk, catch, and dissect: first the stack-as-a-list, then the mechanics of unwinding through except / with / finally, then how Python chains one failure to another, and finally the trace hooks and post-mortem inspection that pdb is built from.

The traceback is a list of frames
Before it's ever printed, a traceback is a data structure: an ordered chain of the frames the exception passed through on its way up. Walk it and the crash's path becomes plain Python you can iterate.
Unwinding: except, with, finally
Watch what actually happens as the exception travels up: which handler catches it, and what cleanup Python is forced to run mid-flight before a frame is allowed to disappear.
Chained exceptions: context vs cause
When a handler raises a new error, Python doesn't forget the old one — it links them. __context__ is the implicit 'during handling' link; __cause__ is the explicit one you set with 'raise ... from'.
Trace hooks and post-mortem
pdb has no magic. It hangs a function off sys.settrace to watch every call and line, and after a crash it reads the dead frames' locals straight off the traceback. Here's each of those, in the raw.
end of chapter 25 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked