Module 5: Errors, Debugging, and Testing
Exceptions, and the Failures Worth Handling
Catching the narrow failures you can genuinely respond to, raising errors that name the problem, and why a bare except is worse than no handling at all.
Lesson 17 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can wrap the smallest possible block in a handler for one specific exception, respond in a way that helps the person reading the output, and explain why catching everything is worse than catching nothing.
Why it matters
Two failures look identical from outside and deserve opposite treatment. "This record's amount is unparseable" is expected: batches contain bad rows, and one bad row should not end a run over four hundred. "I misspelled a field name" is a defect, and hiding it means shipping a program that reports "no matches" forever while a reviewer wonders why.
The difference is not in the exception; it is in your intent. Handling has to be narrow enough to express that intent, which is why "catch everything, log nothing" is the single most damaging pattern in beginner and generated code alike.
Concept
try: runs a block. A matching except SomeError: handles an exception from that block of the named type or a subclass. else: runs when the block finishes normally, without an exception or an early return, break or continue. During ordinary Python execution, finally: runs as control leaves the statement, including on an exception or early return, and is where cleanup belongs.
Four rules make handling useful rather than decorative.
Catch a specific type. except ValueError: says what you expected. except Exception: catches many ordinary exceptions, potentially hiding your own programming mistakes. It does not catch KeyboardInterrupt or SystemExit; a bare except: catches those too when they arise inside its protected block, so a program can become hard to stop.
Wrap the smallest block. Put only the line that can fail inside the try. A ten-line try catches failures from nine lines you were not thinking about, and attributes them to the one you were.
Respond, do not just continue. A handler that only says pass converts a failure into silence. At minimum record what was skipped and why, so a run can report "382 processed, 4 skipped" rather than pretending nothing happened.
Raise when you cannot proceed. raise ValueError(f"unparseable amount: {raw!r}") interrupts normal execution at the bad value and looks for a matching handler, with the value in the message. If no handler catches it, the error propagates out to the runner. That is far kinder than returning a plausible default that fails three functions later.
Two supporting tools. assert condition, "message" states something you believe must always be true; it is a development aid, not input validation, because Python can be run with assertions disabled. And !r inside an f-string uses the value's repr, which shows quotes and whitespace, so '' and ' ' look different in the message.
One safety note for error messages: they end up in logs, screenshots, and support tickets. Name the field and the shape of the problem; do not paste an entire record, and never include a credential.
Read the code
def parse_value(raw):
"""Return the amount as an int. Raise ValueError naming the field
when the text cannot be parsed."""
try:
return int(raw)
except ValueError:
raise ValueError(f"estimated_value is not a whole number: {raw!r}")
def total_value(batch):
"""Return (total, skipped). Bad rows are skipped, not fatal."""
total = 0
skipped = []
for record in batch:
try:
total += parse_value(record["estimated_value"])
except ValueError as error:
skipped.append((record["notice_id"], str(error)))
return total, skipped
batch = [
{"notice_id": "A-1", "estimated_value": "310000"},
{"notice_id": "A-2", "estimated_value": "96,500"},
{"notice_id": "A-3", "estimated_value": "180000"},
]
total, skipped = total_value(batch)
print(total)
print(len(skipped), "skipped")
for notice_id, message in skipped:
print(notice_id, message)
Two layers, doing different jobs. parse_value converts one value and, when it cannot, raises an error that names the field and shows the offending text. It does not decide what the program should do about it, because it cannot know.
total_value makes that decision: one unparseable amount should not abandon the batch, so it records the identifier and the message and carries on. The try wraps exactly one statement, and the caller gets both the total and an honest account of what was left out.
Trace normal and exceptional paths
Follow the path; respect the handler boundary
General control flow: The first three panels describe try / except / else / finally when those clauses are present.
The worked example uses try and except only. It has no else, finally or resource cleanup.
Normal completion
try- Finish the protected block normally.
else- Run this branch only on normal fall-through.
finally- Run cleanup as control leaves the statement.
An early return, break or continue skips else but still passes through finally during ordinary Python execution.
A matching exception
try → except- An exception selects the first matching handler, including matches through a superclass.
except- Handle this failure; else is skipped.
finally- Run cleanup after the handler.
If the handler finishes normally, continue after the statement. A new error raised by the handler propagates after finally.
No matching handler
try → outward- No local except matches the error; else is skipped.
finally- Run cleanup before the error continues outward.
Caller or runner- A caller may handle it. Otherwise it remains unhandled.
Errors raised in else are not caught by the same statement’s except clauses; they also pass through finally.
Worked example: two boundaries
Good text- int(raw) returns an integer; total_value adds it.
Bad text- int("96,500") raises ValueError; parse_value raises a field-specific ValueError.
Caller handles- total_value catches ValueError, records the ID and message, then continues the loop.
Different failures- A missing amount key raises KeyError; int(None) raises TypeError. These ValueError handlers do not catch either.
A-1 and A-3 contribute 490000; A-2 is recorded as skipped. The outer print calls display the total and skip report.
Predict the output
Predict every printed line.
Check your prediction
490000
1 skipped
A-2 estimated_value is not a whole number: '96,500'
The total is 310000 plus 180000; the middle record contributed nothing. The message shows the value in quotes because of !r, which is how you can see the comma is inside the string rather than a formatting artefact.
The second and third lines are the part that matters operationally. A run that returned 490000 alone would be quietly wrong by 96,500, and nobody would know to ask.
Modify the code
In total_value, change except ValueError as error: to except Exception: with a body of only pass, and change the first record's key from estimated_value to estimatedValue.
What changes, and why
It prints 180000 and 0 skipped.
Two records vanished and the program reports that nothing was skipped. The renamed key raises KeyError, which except Exception happily catches, and pass discards it. The unparseable comma is swallowed the same way.
Every signal is gone: no traceback, no skip count, no message. The total is wrong by more than sixty per cent and the output looks completely healthy. This is why the two changes are shown together, they are the same mistake at two scales, and why "catch narrowly, always record something" is not a style preference.
Debug the bug
An assistant was asked to make the parser resilient. It produced this and said invalid amounts now default to zero.
def parse_value(raw):
try:
return int(raw)
except:
return 0
batch = [
{"notice_id": "A-1", "estimated_value": "310000"},
{"notice_id": "A-2", "estimated_value": "to be determined"},
]
total = 0
for record in batch:
total += parse_value(record["estimated_value"])
average = total / len(batch)
print(f"average {average:,.0f}")
What's actually wrong
It prints average 155,000, and that number is not the average of anything real.
The unparseable record was turned into a zero and then included in the divisor, so the average is the one genuine amount halved. A reviewer reading "average 155,000" has no way to know that half the input was fabricated.
Three separate faults, all common:
- The bare
exceptcatches any exception raised inside itstry, includingTypeErrorfromint(None)or an interrupt during conversion. It does not catch a missingrecord["estimated_value"]key here: that lookup happens in the caller beforeparse_valuestarts. - Zero is not a neutral default. It is a specific claim, "this opportunity is worth nothing", and it propagates into every sum, average, and comparison downstream.
Noneat least refuses to be added by accident. - The skipped record still counted. Whatever the default, the denominator should reflect how many values were genuinely parsed.
The version in Read the Code has none of these: it catches one type, it reports rather than substitutes, and the caller can compute an average over parsed values while stating how many were excluded.
Try it yourself
Write a conversion that reports its failures instead of raising them, and run it over four differently broken inputs.
Loading this exercise…
Practical challenge (optional)
Optional: extend parse_amount to accept "96,500" by removing thousands separators before converting, and keep rejecting "to be determined". Then write one sentence on why that change belongs in a normalisation step rather than inside the exception handler. Deciding what to repair and what to report is the judgement the capstone's validation phase is built on.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why is
except ValueError:better thanexcept Exception:when parsing a number? - What is wrong with a handler whose body is only
pass? - When should code raise rather than return a default?
- What does
!radd to a value in an error message?
Answers
- It expresses the failure you anticipated. A broad catch also swallows programming mistakes such as a mistyped key, turning a defect into silence.
- It converts a failure into no information at all. Nothing downstream can report, count, or investigate what was skipped.
- When the code cannot produce a correct answer and no default is honest. Raising interrupts normal execution and looks for a matching handler; a fabricated default travels onward and can corrupt totals, averages, and comparisons.
- It shows the value's representation, including quotes and whitespace, so an empty string, a space, and a comma inside a number are all visible in the message.
Sign in to track your progress on this exercise.
Summary and next step
Catch one type, wrap one statement, always leave a record of what was skipped, raise with the offending value in the message, and treat zero as a claim rather than a neutral default. Next: writing the tests that prove all of this still holds tomorrow, including a regression test for a bug you have just repaired.