Skip to main content
Learning Center
Python Programming

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.

These are alternative paths, not four sequential steps. Keep the protected block narrow and handle only failures you can respond to. The finally paths shown assume cleanup completes without raising or returning; cleanup that does so can replace a pending error or return. Abrupt process termination is outside this model. The worked batch has no finally clause.

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:

  1. The bare except catches any exception raised inside its try, including TypeError from int(None) or an interrupt during conversion. It does not catch a missing record["estimated_value"] key here: that lookup happens in the caller before parse_value starts.
  2. 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. None at least refuses to be added by accident.
  3. 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

  1. Why is except ValueError: better than except Exception: when parsing a number?
  2. What is wrong with a handler whose body is only pass?
  3. When should code raise rather than return a default?
  4. What does !r add to a value in an error message?
Answers
  1. It expresses the failure you anticipated. A broad catch also swallows programming mistakes such as a mistyped key, turning a defect into silence.
  2. It converts a failure into no information at all. Nothing downstream can report, count, or investigate what was skipped.
  3. 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.
  4. 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.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.