Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Testing & Debugging  ›  Lesson

pdb, Post-Mortem & Bisecting

Debugging 24 minFind where, why and on which input a program fails — by reading the crash, inspecting its frames, and halving the search
You're building a piece ofQuarry — the test rig
This piece — narrow_failure(): Shrinks a batch that crashes the vault down to the few rows that actually cause it.
Scenario The vault loader crashed on a forty-thousand-row batch overnight; the rig cuts it down to the two rows that actually collide.
Your task
Build narrow_failure(rows): if crashes(rows) is False, return rows unchanged. Otherwise scan from the left trying to delete one element at a time; whenever the shorter list still crashes, keep it and restart the scan. When no single deletion still crashes, return what is left. Example: narrow_failure(["q1", "q2", "q3", "q2"]) → ["q2", "q2"].

pdb, Post-Mortem & Bisecting

In short: debugging is a loop with a clear exit at every step — reproduce the failure on demand, narrow the input or code path, hypothesise in one sentence, test it with the smallest check that can confirm or kill it, then fix and pin it with a regression test written before the fix. A traceback reads bottom-up: the last frame is where the exception surfaced, not necessarily where the mistake is. Post-mortem debugging walks that same chain of frames with every local variable still alive, which is what pdb's post-mortem mode gives you interactively. And when you don't know where to look at all — across 120 changes, or a 40,000-row batch — bisecting halves the search every time, so the cost grows only with the logarithm of the size.

Three stacked frames at the moment of a crash — nightly, summarise, rate — each with its locals frozen; the bottom frame shows WHERE it broke, the middle one shows WHY

At 03:12 last night Quarry's nightly summary crashed. The log holds one line — ZeroDivisionError: division by zero — and a batch of forty thousand rows. Tests tell you that something is wrong. Debugging is the other half: finding where, why, and which input — quickly, without guessing.

Read a crash from the bottom up, then look inside it

When an exception escapes, Python prints a traceback: the chain of calls in progress when it happened, most recent call last — so the story reads from the outside in, and the very last frame is where the exception was raised.

python

Read it from the bottom: what went wrong (ZeroDivisionError), where (inside rate, on errors / total), how we got there (nightly called summarise, which called rate). The bottom frame is where the error surfaced — not necessarily where the mistake is. Here the mistake is upstream: a stage with zero rows should never have reached rate at all.

A traceback is also an object you can walk, one link per frame:

What does this print?
def inner():
    return 1 / 0

def middle():
    return inner()

def outer():
    return middle()

try:
    outer()
except ZeroDivisionError as exc:
    tb = exc.__traceback__
    while tb.tb_next:
        tb = tb.tb_next
    print(tb.tb_frame.f_code.co_name)

Python ships a debugger, pdb: put breakpoint() on a line, run in a terminal, and execution pauses with a (Pdb) prompt where you can print variables, step, and move between frames (p, n, s, w, u/d, c). Its most useful trick is post-mortem: run under python -m pdb and when the program dies you land inside the dead frame, locals and all — no guessing which print to add, you just look. pdb needs an interactive terminal, which this page doesn't have, but post-mortem is built on something that does work here: the traceback object keeps every frame alive.

python

Three frames, and the answer is in the middle one: stage = 'vault', errors = 0, total = 0. The vault stage had no rows, and nobody expected that. Walking tb_next from outside in is exactly pdb's d command; each frame's f_locals is exactly what p shows you.

step through it
1def rate(errors, total):
2 return errors / total
3 
4def summarise(counts):
5 out = {}
6 for stage, (errors, total) in counts.items():
7 out[stage] = rate(errors, total)
8 return out
9 
10try:
11 summarise({"yard": (2, 40), "vault": (0, 0)})
12except ZeroDivisionError:
13 crashed = True
14print(crashed)
Drawing diagram…

Print debugging, done properly

There's no shame in print, as long as it answers a precise question. The = specifier in an f-string prints the expression and its value, so you never mislabel a number:

What does this print?
errors, total = 3, 40
print(f"{errors / total=}")

Three rules keep it from turning into archaeology: print values, not "got here"; print once before and once after the suspect line; delete the prints once the hypothesis is settled. Not every bug crashes — the hard ones return a wrong answer quietly. Quarry's dashboard averages the last n runs, and it's always a little off:

broken — fix it

The last two runs took 30 and 40 seconds, so the average should be 35.0. It prints 30.0. Find the slice bug and fix it.

Same loop: reproduce ([10, 20, 30, 40], n=2), hypothesise ("the slice is one short"), test (print(f"{recent=}") shows [30]), fix, then assert recent_average([10, 20, 30, 40], 2) == 35.0 joins the suite for good.

Halve your way to the one change — or the one row — that matters

Sometimes you don't know where to look at all, only that version 1.4 worked and 1.9 doesn't, with 120 changes between. Checking each would take all day — test the middle instead. Good, the bug arrived in the later half; bad, the earlier half. Each check halves what's left:

python

Seven checks instead of up to 120. Finding the bad change among 5,000 takes thirteen — doubling the history adds just one more check, which is why bisecting is worth doing even when a single test takes minutes. (git bisect start, mark one commit bad and one good, and git bisect run pytest ... does the testing itself.)

The same idea works on inputs. A batch of forty thousand rows crashes the vault — which rows matter? Remove some; if it still crashes, they weren't needed. Keep removing until nothing more can go — the random-input version of this from the last lesson, here aimed at a real failing batch. That's the piece you build now. Before you close a bug: write the regression test first, on the narrowed input, and watch it fail; fix the cause, not the symptom (wrapping rate in try/except hides the crash — decide what an empty stage should actually report); and look for siblings — if one count could be zero, where else is a count used as a divisor?

Quick practice

What does this print?
def first_bad(results):
    lo, hi = 0, len(results)
    while lo < hi:
        mid = (lo + hi) // 2
        if results[mid]:
            lo = mid + 1
        else:
            hi = mid
    return lo if lo < len(results) else -1

print(first_bad([True, True, True, False, False]))
What does this print?
def post_mortem_depth(exc):
    tb = exc.__traceback__
    depth = 0
    while tb:
        depth += 1
        tb = tb.tb_next
    return depth

def c():
    return 1 / 0

def b():
    return c()

def a():
    return b()

try:
    a()
except ZeroDivisionError as exc:
    print(post_mortem_depth(exc))

🎯 Your turn

Build narrow_failure(rows). crashes(rows) (in the starter) reports whether the vault's loader blows up on a list of row ids — it does when an id appears twice. If rows doesn't crash, return it unchanged. Otherwise narrow it: scan from the left, trying to delete one element at a time; whenever the shorter list still crashes, keep it and start scanning again from the left. Stop when no single deletion still crashes, and return what's left.

narrow_failure(["q1", "q2", "q3", "q2"])   # → ["q2", "q2"]
narrow_failure(["x", "y"])                 # → ["x", "y"]  (doesn't crash)

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upWhat `{x=}` prints

Write debug_repr(name, value) returning exactly what f"{name=}" would print for a variable with that name and value: the name, an equals sign, and the value's repr.

debuggingf-strings
DrillBinary search for the first bad version

results[i] is True if version i passes and False if it fails, and once a version fails every later one does too. Write first_bad(results) returning the index of the first failing version using binary search, or -1 if every version passes.

debuggingbinary-search
BuildWalk the frames of a crash

run_stage(rows) crashes on a bad row. Write frame_names(rows): call run_stage(rows); if it raises, walk the traceback from run_stage's frame inward and return {"frames": [function names, outermost first], "bad": the innermost frame's local called text}. If nothing is raised return {"frames": [], "bad": None}.

debuggingtracebacksexceptions
BossAn automatic post-mortem

nightly(counts) is Quarry's summary (counts maps a stage to [errors, total]). Write post_mortem(counts): run it, and if it raises return {"error": exception class name, "where": innermost function name, "locals": that frame's local variables as a dict}. If it succeeds, return {"error": None, "where": None, "locals": {}}.

debuggingtracebacksexceptions
CapstoneBisect a history of changes

Each version in history configures Quarry's parser: {"sep": ..., "maxsplit": ...}. check(version) (in the starter) tells you if that version parses correctly. The first version is known good and the last known bad. Write bisect_history(history): start with good = 0 and bad = len(history) - 1; while bad - good > 1, probe mid = (good + bad) // 2, record it, and move good or bad to it. Return {"first_bad": bad, "probed": [indices in order]}.

debuggingbinary-searchexceptions
ExplainWhy the failing test comes before the fix

You've found the bug behind Quarry's 03:12 crash. Explain why you should write a test that reproduces it — and watch it fail — before changing the code.

narrow_failure.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
narrow_failure(rows) → listShrink a batch that crashes the vault loader to the rows that cause the crash.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.