Python Part I · Module 14 · Errors and Exception Handling
Exceptions: What They Are and When to Let Them Escape
Three chapters of this module have let every failure terminate the program, and it is worth noticing how well that worked. Each crash arrived with a precise description of what went wrong and where, and none of them lied. This chapter introduces the statement that intercepts a failure instead — and then spends most of its length on the harder question, which is when you should. The syntax takes five minutes. The judgement separates a script that reports what actually happened from one that prints a reassuring summary over the top of a broken run, and the industry is full of the second kind.
Intermediate17 min readPythonPart IFundamentalsErrorsExceptions
Part I · Module 14 · Chapter 14.4 · Exceptions: What They Are and When to Let Them Escape
1. An Exception Is an Object
Everything on the last line of a traceback comes from an object. When something goes wrong, Python creates an exception object carrying a type and a message, and then raises it — which means normal execution stops and the object travels up the call stack looking for something to handle it.
If nothing does, Python prints the traceback and exits. That is what every example so far has done.
The object is ordinary and you can inspect it:
try:
int("n/a")
except ValueError as e:
print("type:", type(e).__name__)
print("str :", e)
print("args:", e.args)type: ValueError
str : invalid literal for int() with base 10: 'n/a'
args: ("invalid literal for int() with base 10: 'n/a'",)e is just a value. type(e).__name__ is the name you read in Chapter 14.3,
and str(e) — what print shows — is the message half. Nothing exotic is
happening; this is the same attribute access Chapter 13.1 used on modules.
2. The Statement
try:
# code that might fail
except SomeError as e:
# what to do if it doesTwo things to read carefully:
as eis optional. Use it when you want the message; omit it when the type alone tells you everything.- The
exceptclause names a type. It catches that type and its subclasses, and nothing else.
3. What Happens to the Rest of the try Block
This is the part that surprises people. The moment something raises, the rest
of the try block is abandoned — not resumed, not retried:
try:
print("1 before")
int("n/a")
print("2 never runs")
except ValueError:
print("3 handler")
print("4 after")1 before
3 handler
4 after2 never runs is absent. Execution jumps from the failing expression straight
to the handler, and then continues after the whole try statement.
That jump is why a try block should be short. Everything inside it is
code you are declaring might be skipped, and a twenty-line try block is
twenty lines of uncertainty about how much of it happened.
4. A Handler That Earns Its Place
Here is a genuinely good use. Some fields in a report are N/A, and the
sensible reading of N/A is "no runtime recorded":
def runtime_or_zero(field):
try:
return float(field)
except ValueError:
return 0.0
for raw in ["182.4", "204.1", "N/A", ""]:
print(f"{raw!r:>8} -> {runtime_or_zero(raw)}") '182.4' -> 182.4
'204.1' -> 204.1
'N/A' -> 0.0
'' -> 0.0What makes this good is not the syntax. It is that the handler has a real answer: a missing runtime is zero, that decision is correct for this report, and a reader can see it in one line.
5. The Type Has to Match
An except clause catches one family of exception. Anything else passes
straight through as though the try were not there:
def only_value():
try:
return 10 / 0
except ValueError:
return "handled"
print("about to fail")
print(only_value())about to fail
Traceback (most recent call last):
File "wrongtype.py", line 9, in <module>
print(only_value())
~~~~~~~~~~^^
File "wrongtype.py", line 3, in only_value
return 10 / 0
~~~^~~
ZeroDivisionError: division by zeroThe try caught nothing because ZeroDivisionError is not a ValueError. This
is a feature: a handler written for one failure does not silently absorb a
different one. Chapter 14.5 is about handling several deliberately.
6. Crashing Is a Feature
Here is the claim this chapter is built on. An uncaught exception does four things, all of them valuable:
- It stops the program before the bad value can be used by anything else.
- It prints exactly what went wrong, with a type and a message.
- It prints where, with the full call chain.
- It sets a non-zero exit status, which is how any wrapper script, Makefile or CI job knows the run failed.
That is a complete bug report, generated automatically, every time, for free.
A try/except that cannot do something better than that is a net loss — it
throws away all four.
7. The Test for a Handler
Before writing except, answer one question:
What would I actually do in the handler?
There are only a few good answers:
- Substitute a known-correct default, as Section 4 does with
0.0. - Try a different source — another path, another format, a fallback.
- Add context and stop, so the operator gets a sentence instead of a stack.
- Record it and continue deliberately, where "continue" is genuinely right and the record is genuinely read.
And one bad answer, which accounts for most handlers in the wild:
- "Print something and carry on as though it worked."
If that is your answer, there is no handler worth writing. The script will produce output that looks like a result, is not one, and nobody will know. Chapter 14.9 is entirely about what that costs.
8. Catch What You Can Act On
Compare two versions of the same parser. The first catches a failure it has a real answer for:
def parse_runtimes(lines):
"""A line without a numeric runtime is reported as having none."""
runtimes = {}
for line in lines:
name, _, raw = line.split()
try:
runtimes[name] = float(raw)
except ValueError:
runtimes[name] = None
return runtimes
LOG = ["smoke PASS 182.4", "cdc PASS 204.1", "reset FAIL N/A"]
parsed = parse_runtimes(LOG)
recorded = 0
for name, value in parsed.items():
print(f"{name:<8} {value}")
if value is not None:
recorded += 1
print("recorded:", recorded, "of", len(parsed))smoke 182.4
cdc 204.1
reset None
recorded: 2 of 3The handler has an answer (None means "not recorded"), and — critically — the
script still reports the distinction on the last line. Two of three.
Now the version that catches something it cannot act on:
def parse_runtimes_bad(lines):
"""Catches a malformed LINE, and has nothing sensible to do about it."""
runtimes = {}
for line in lines:
try:
name, _, raw = line.split()
runtimes[name] = float(raw)
except ValueError:
pass
return runtimes
LOG = ["smoke PASS 182.4", "cdc PASS 204.1", "reset FAIL"]
print(parse_runtimes_bad(LOG))
print("tests in log:", len(LOG), "-- tests reported:", len(parse_runtimes_bad(LOG))){'smoke': 182.4, 'cdc': 204.1}
tests in log: 3 -- tests reported: 2The dictionary looks perfectly healthy. Nothing in it indicates that a third
test existed, and reset — which failed — has vanished from the report
entirely. The only reason you can see the problem here is the second line, which
a real script would not print.
Note the subtlety: the widened try in the second version now covers the
line.split() unpacking as well, so one handler is absorbing two unrelated
failures. That is what Section 3's "keep the block short" is protecting you
from.
9. Adding Context and Stopping
When a failure is fatal but the traceback would not help the person running the script, the right move is a clear message and a non-zero exit:
import sys
def load_seed(raw):
return int(raw)
def main():
try:
seed = load_seed("default")
except ValueError as e:
sys.exit(f"bad config: seed must be an integer ({e})")
print("seed:", seed)
main()bad config: seed must be an integer (invalid literal for int() with base 10: 'default')sys.exit(message) prints the message and exits with status 1, which is how
a wrapper knows the run failed. Three properties make this legitimate where
Section 8's bad version was not:
- the program stops — nothing downstream runs on a bad seed;
- the message names the cause, and includes the original text via
{e}; - the exit status is non-zero, so automation sees the failure.
That is strictly more useful than a traceback for this audience — an operator reading a log — and strictly less useful for a developer, which is the trade you are making. Make it only where the cause is genuinely understood.
10. Predict, Run, Observe
def attempt(value):
try:
print(" try:", 100 / value)
print(" still in try")
except ZeroDivisionError:
print(" handler")
print(" after")
return "done"
print("A:", attempt(4))
print("B:", attempt(0)) try: 25.0
still in try
after
A: done
handler
after
B: doneA — no exception, so the handler is skipped entirely and both try lines
run.
B — still in try is missing. The failure abandoned the rest of the block.
Both return done. Once a handler has run, the function continues normally;
an exception that has been caught leaves no trace in the control flow after the
try statement.
after printed in both cases, which is the point people most often get
wrong: code after the try statement is not part of it.
11. Exercises
Exercise 1 — the object. Catch a KeyError and print its type name, its
str() and its args. Say which part you would put in a message to a colleague.
Exercise 2 — the abandoned block. Write a try block with four prints and a
failure in the middle. Predict which prints appear, then run it.
Exercise 3 — a handler that earns it. Write a function converting a field to a float, returning a documented default for unparseable input. Then write one sentence justifying the default to a reviewer.
Exercise 4 — the wrong type. Write a try that catches ValueError around
code that raises KeyError. Confirm the traceback is identical to having no
try at all.
Exercise 5 — the four free things. Take any failing script from Chapter 14.3
and list, concretely, the four things its crash gave you. Then wrap it in
except Exception: print("failed") and list what remains.
Exercise 6 — count what you dropped. Modify Section 8's bad parser so it also reports how many lines it skipped. Say why that single number changes the handler from dishonest to acceptable.
Exercise 7 — exit status. Write a script that exits via sys.exit("message")
and check its exit status from the shell. Explain why a wrapper script cares.
Exercise 8 — the test. For each, say whether you would catch it and what the handler would do: (a) a missing optional field (b) a malformed number in the one line you need (c) a missing simulator binary (d) a divide by zero when counting an average over an empty list.
12. Summary
- An exception is an object with a type and a message, travelling up the call stack until something handles it.
try/exceptintercepts it;as ebinds the object when you need the message.- The rest of the
tryblock is abandoned on failure — so keep the block short, covering only what you mean to guard. - An
exceptclause catches one type and its subclasses. Anything else propagates as though thetrywere absent. - Crashing is a feature: it stops the program, says what and where, and sets a non-zero exit status.
- A handler must beat all four of those to be worth writing.
- The test is "what would I actually do?" — a default, another source, or a clean exit with a message.
- "Print something and carry on" is not an answer; it manufactures a result that was never computed.
- A handler that drops data should report how much it dropped.
sys.exit(message)prints to standard error and exits with status 1, which is how automation learns the run failed.
The next chapter handles several different failures in one place, where a missing file and a malformed number need genuinely different answers.
Where this fits
Part of the Python curriculum.
