Worked examples — Reading documentation and debugging
This page is a catalogue of concrete cases for the two skills from Reading documentation and debugging: reading docs to build a mental model, and debugging by systematic narrowing. The parent note gave you the methods. Here we run those methods on every kind of bug you will actually meet, one worked example per case.
Before we touch a single example, we need one honest map of the territory.
The scenario matrix
Every bug you hit falls into a small number of shapes. Think of a bug not as "the code is broken" but as a mismatch between what you expected and what happened. The mismatch has a type. Below is the full list of types we will cover — each cell is one distinct failure mode.
| Cell | Case class | What makes it distinct | Example that hits it |
|---|---|---|---|
| C1 | Loud crash — exception thrown | Program stops, prints a traceback | Ex. 1 (KeyError) |
| C2 | Silent wrong answer — no crash | Runs fine, output is just wrong | Ex. 2 (reset-in-loop) |
| C3 | Degenerate input — empty container | Input size = 0, boundary of the loop | Ex. 3 (empty list) |
| C4 | Zero / division edge | A denominator or count is 0 | Ex. 3 & Ex. 8 |
| C5 | Type mismatch — wrong "kind" of value | str where a number was expected |
Ex. 4 (str + int) |
| C6 | Off-by-one / boundary index | Loop touches one slot too many/few | Ex. 5 (index range) |
| C7 | Doc-contract violation | You broke a promise the docs stated | Ex. 6 (linspace endpoint) |
| C8 | Limiting / large behaviour | Correct on small input, fails at scale | Ex. 7 (float accumulation) |
| C9 | Real-world word problem | Messy CSV, must combine skills | Ex. 8 (CSV averages) |
| C10 | Exam-style twist — subtle & adversarial | Looks right, one detail sabotages it | Ex. 9 (mutable default) |
To make this a real map and not just a wall of text, the figure below draws all ten cells as a grid of tiles, sorted along two axes you can feel: left-to-right is how loud the bug is (silent → loud crash), bottom-to-top is how subtle it is to spot (obvious → adversarial). Read the picture like a landscape:

- Bottom-right corner (loud + obvious): C1, C4, C5, C6 — these crash immediately and tell you where. Easiest to fix.
- Top-left corner (silent + subtle): C2 and C10 — nothing tells you; you must go hunting. This is the red C2 tile — the corner we spend the most examples on.
- The tiles in between (C3, C7, C8, C9) mix the two: they can be silent or loud depending on the input.
The one red tile in the picture is the class most beginners fear the most — a silent wrong answer — because nothing tells you it went wrong. Keep your eye on where each example below lands on this map.
Example 1 — The loud crash KeyError [Cell C1]
Forecast: guess now — will every record fail, or just one? Which one?
-
Read the traceback bottom-up. The parent note's rule: the last line names the exception type. Here it is
KeyError: 'price'. Why this step? The exception type already tells you the shape: aKeyErrormeans you asked a dictionary for a key it does not have. Not a math bug, not a type bug — a missing key bug. -
Read the value. The message says
'price'. So somewhererecord['price']was requested but'price'was absent. Why this step? The specific value narrows the search from "some dict" to "the dict missingprice". -
Scan the data for the offender. Record index 0 has
price, index 1 hascost(noprice!), index 2 hasprice. Why this step? Only one record — index 1 — violates the contract. The loop crashes on the second iteration, not the first. -
The fix uses the doc-blessed safe accessor
dict.get(key, default):value = record.get('price', 0) * 1.1
Verify: with the fix, the three outputs are , , . No crash. See 1.7.2-Data-preprocessing-and-cleaning for why missing keys are a data problem, not just a code problem.
Example 2 — Silent wrong answer: reset inside the loop [Cell C2]
Forecast: why exactly 70, and not 0, and not 245?
-
Recognise the shape. No traceback → this is C2, a silent bug. The parent's tool for C2 is binary search debugging: check a value in the middle of the computation. Why this step? Silent bugs give you no starting line, so you manufacture one with a print.
-
Check
totalright beforereturn.print(total)→70. Why this step? 70 is the last score. That is a fingerprint: the sum was overwritten, not accumulated. -
Check
totalinside the loop, each pass. It reads 85, then 90, then 70 — never adding up. Why this step? This confirmstotal = 0runs every iteration, wiping the previous work. The initialisation is in the wrong scope. -
Fix: move
total = 0above the loop.total = 0 for student in students: total += student['score']
Verify: . And the fingerprint check: the buggy version must return the last element, because only the final assignment survives — matches the 70 we saw.
Example 3 — Degenerate & zero-division: the empty list [Cells C3 + C4]
Forecast: does this crash, return 0, or return None?
-
Trace the values for the empty input.
sum(...)over nothing is0(Python'ssumstarts at 0). Thenlen([])is0. Why this step? We must follow every symbol to its value at the boundary — this is the "cover the degenerate case" rule from the contract. -
Spot the collision. We compute
0 / 0. Python raisesZeroDivisionError: division by zero. Why this step? A denominator that can be zero (C4) is a landmine. Empty input (C3) is exactly what makeslenzero — the two cells overlap here. -
Decide the correct behaviour. "Average of no students" has no numeric answer. The honest fix returns a signal, not a fake number:
def average(students): if not students: return None return sum(s['score'] for s in students) / len(students)
Verify:
average([])→None(no crash).average([{'score': 80}, {'score': 100}])→ .
The number line below shows the danger point. The black dots are the possible values of len(students); the red dot at 0 is the single value where the divisor becomes zero and the code explodes. Everything to the right (the black arrow, len >= 1) is the safe zone:

Example 4 — Type mismatch: number meets string [Cell C5]
Forecast: does it crash, or silently concatenate?
-
Read the traceback.
TypeError: unsupported operand type(s) for +=: 'int' and 'str'. Why this step?TypeError(C5) means an operation was asked to combine two kinds it cannot. The message names both kinds:intandstr. -
Find where the kinds collide.
totalstarts asint0. First pass:0 + '85'— int + str is undefined, crash on iteration 1. Why this step? In Python+is overloaded: number+number adds, string+string joins. Mixing them is deliberately forbidden so bugs surface loudly. -
Fix by converting at the boundary — coerce each value to the type you meant:
total = sum(int(s) for s in scores)
Verify: int('85') + 90 + int('70') . This is the same cleaning step you meet in 1.4.5-File-IOand-CSV-handling, where everything read from a file arrives as text.
Example 5 — Off-by-one boundary [Cell C6]
Forecast: which index is illegal here?
-
Trace the indices. The list
[3, 7, 10]has valid indices0, 1, 2.len(nums)is3. Sonums[3]reaches past the end. Why this step? Python indexing is zero-based: a list of length has indices to . Index is always one too far — the classic off-by-one. -
Read the crash.
IndexError: list index out of range. Why this step?IndexErroris the signature of C6 — you walked off the edge of the container. -
Fix: the last element is at
len(nums) - 1, the one before it atlen(nums) - 2. Cleaner still, use negative indices:return nums[-1] - nums[-2]
Verify: nums[-1] - nums[-2] . The picture makes the fence-post clear. The three black boxes are the real list slots (indices 0, 1, 2 holding 3, 7, 10); the red dashed box on the right is the phantom slot nums[3] that the buggy code reaches for — there is no value there, so Python raises IndexError:

Example 6 — Doc-contract violation: linspace endpoint [Cell C7]
Forecast: why does dropping endpoint change the spacing?
-
Name the symbol first. Let (read "delta x") stand for the gap between two neighbouring points — the constant step size. We use the Greek letter because in maths it traditionally means "a change in" or "difference between", and the gap is the difference between one point and the next. So literally reads as "the change in x from one point to the next". Why this step? The contract from the docs is stated in terms of that gap, so we need a name for it before we can compare the two cases.
-
Re-read the documented contract (from the parent note).
linspace(start, stop, num, endpoint=True). Withendpoint=Truethenumpoints includestop; the spacing is Why this step? The parent's "Mental Model = Purpose + Contracts + Edge Cases" —endpointis an edge-case knob, and we broke its default silently. -
Compute the two contracts side by side.
endpoint=True: . Points: .endpoint=False: . Points: —stopexcluded. Why this step? The denominator changes fromnum - 1tonumwhen the endpoint is dropped. That single term is the whole bug.
-
Fix: to keep spacing 2.5, use the default
endpoint=True(or ask fornum=5over[0, 8]if you truly want to exclude 10). Match the doc to your intent.
Verify: and — both computed in the checks below. See 1.4.10-Introduction-to-matplotlib for where these evenly-spaced arrays feed plotting.
Example 7 — Limiting behaviour: float accumulation at scale [Cell C8]
Forecast: True or False? And by how much is it off?
-
Small mental check. added ten times should be mathematically. Why this step? This establishes the expected value so we can measure the drift.
-
Understand the machine's limit. Computers store numbers in binary. has no exact binary form (like has no exact decimal). Each
+= 0.1carries a tiny rounding error, and errors accumulate — this only becomes visible over many additions (C8, the limiting case). Why this step? A correct-looking loop can drift once repeated enough; the bug scales with iteration count. -
Measure the drift.
totalcomes out as0.9999999999999999, sototal == 1.0isFalse. Why this step? Confirms floats need a tolerance, never==. -
Fix: compare with a tolerance.
abs(total - 1.0) < 1e-9 # True
Verify: the checks below confirm abs(sum of ten 0.1s - 1.0) < 1e-9 is True, while exact equality is False.
Example 8 — Real-world word problem: averaging a messy CSV [Cell C9]
Forecast: what is the correct denominator — 5 students, or fewer?
-
List every failure shape hiding here. Blank string
''(a C3-style degenerate/empty value), the non-numeric text'absent'(C5 type mismatch), and — if every score were filtered out — a zero denominator (C4 division edge). One word problem touches three cells at once. Why this step? Real data is a mixture — naming the shapes first tells you which guards to write. -
Filter to valid numeric scores.
valid = [] for name, s in rows: s = s.strip() if s.isdigit(): valid.append(int(s))Why this step?
str.isdigit()(a documented string method) answers "is this text a string of digits 0–9?" — it rejects''and'absent'in one move.
-
Guard the zero case, then average.
average = sum(valid) / len(valid) if valid else NoneWhy this step? If every score were invalid,
validis empty and we must not divide by zero (C4 again). -
Compute. Valid scores are . Denominator is 3, not 5.
Verify: ; the two rejected rows ('', 'absent') correctly do not count. This is exactly the pipeline in 1.7.2-Data-preprocessing-and-cleaning.
Example 9 — Exam-style twist: the mutable default argument [Cell C10]
Forecast: does the second call print [20] or something else? Guess before reading on.
-
Recognise the shape. No crash, and the first call looks perfect — this is C10, the adversarial twist that lives in the silent-and-subtle corner of the matrix (top-left of the map figure, next to C2). Why this step? When code passes the obvious test but you sense a trap, you must check the docs for a quiet rule you might have violated.
-
Read the documented rule most people skip. Python evaluates a default argument once, at the moment the function is defined — not freshly on each call. Why this step? This single sentence in the docs is the entire bug. The
[]is created one time and then reused on every call that omitsbucket. -
Trace the shared object across calls.
- Definition time: one empty list
L = []is made and stored as the default. - Call 1
add_score(10): appends10toL→ returns[10]. Looks right. - Call 2
add_score(20): appends20to the sameL→ returns[10, 20], not[20]. Why this step? The bug is invisible on the first call and only surfaces on the second — exactly why it is an exam favourite. The output[10, 20]is the fingerprint of a shared mutable default.
- Definition time: one empty list
-
Fix with the standard sentinel idiom. Use
Noneas the default and build a fresh list inside the function on every call:def add_score(score, bucket=None): if bucket is None: bucket = [] bucket.append(score) return bucketWhy this step?
Noneis immutable and cannot accumulate state, so each call that omitsbucketgets a brand-new list.
Verify: the buggy version's calls return [10] then [10, 20] (shared list); the fixed version returns [10] then [20] (independent lists) — both confirmed in the checks below. Compare how default hyperparameters behave in 2.3.4-Hyperparameter-tuning-grid-search-random-search — defaults are contracts you inherit.
Recall
Recall Which debugging tool matches which bug shape?
Crash (C1) → read the traceback bottom-up. Silent wrong answer (C2) → binary-search the values. Which shape gives no traceback? ::: C2 (silent wrong answer) — nothing tells you, so you insert prints.
Recall Why is
average([]) a zero-division bug and not an "empty list" bug?
Empty input (C3) is the cause; len([]) == 0 makes the denominator zero, and dividing by zero (C4) is the crash. ::: The empty list produces the zero denominator — C3 triggers C4.
Recall Why does
np.linspace(0,10,5,endpoint=False) give spacing 2.0 not 2.5?
With endpoint=False the divisor is num = 5, not num - 1 = 4. ::: instead of .
Recall Why can
str.isdigit() silently lose valid scores?
It is True only for digit runs 0–9, so -5 and 3.5 return False. ::: Negatives and decimals are rejected as if they were junk — a silent data-loss bug.