MA-PY1 · Module 4.1
Exceptions and robust boundaries
Topics: try/except · raise · finally · Custom exceptions
Overview
Module 4.1 — Exceptions and robust boundaries
Scroll Core ideas → worked snippets → Practice (quiz). Decide which failures you catch broadly vs narrowly before opening Discuss. Run examples in the REPL or small scripts.
Concepts
Core ideas — failures as first-class objects
Real programs hit illegal inputs, missing files, network drops, and None where data was promised. Python signals these with exceptions — objects carrying a type, message, and traceback. Your job is threefold:
- Catch recoverable faults near the boundary (user I/O), log or translate them, retry or abort cleanly.
- Raise purposeful errors upstream when preconditions are not met so callers stop early.
- Avoid blanket
exceptthat hidesKeyboardInterruptor makes bugs hard to find.
The try suite
Structured form:
try:
fragile()
except ExcType as err:
handle(err)
except Another:
fallback()
else: # optional — runs ONLY if NO exception propagated from try-body
on_success()
finally: # ALWAYS runs leaving the suite (unless process dies)
cleanup()
finally shines for releasing external resources — even when return lives inside try, finally still executes before the frame unwinds. When you are learning, trace a small example on paper before combining return and finally in production code.
Narrow exceptions win
Prefer except ValueError or except OSError over catching everything at once.
except ValueError:— you expected a string that might not parse.except OSError:— a file or path operation failed.except Exception:— a wide net for libraries; still does not catchKeyboardInterruptorSystemExit(they inherit fromBaseException, notException).
Bare except: (no type at all) traps everything, including KeyboardInterrupt — almost never justified.
raise and re-raise
Validators at function boundaries often end with:
if age < 0:
raise ValueError("age must be non-negative")
Inside except, raise alone rethrows the caught exception and preserves context. raise NewError(...) from e attaches a clearer message while keeping the original cause in the traceback.
Custom types subclass Exception (not BaseException — reserve BaseException for system-level events like KeyboardInterrupt).
Assertions vs raises
assert looks like a guard:
def deposit(amount):
assert amount > 0, "amount must be positive"
...
In normal runs, a failed assert raises AssertionError. That feels similar to raise ValueError(...), but the two are not interchangeable.
Critical detail: when Python runs with optimisation (python -O), all assert statements are removed. They compile to nothing. Code that relied on an assert for safety silently stops checking.
# Development only — fine inside your own tests / internal checks
assert len(items) == len(labels), "parallel lists must match"
# Public API — never rely on assert for untrusted input
def parse_port(text):
# BAD: stripped away under -O; caller could pass "abc" with no error
assert text.isdigit()
return int(text)
For arguments from users, files, networks, or any code you do not fully control, raise a normal exception:
def parse_port(text):
try:
port = int(text)
except ValueError as e:
raise ValueError(f"not a valid port number: {text!r}") from e
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return port
Rule of thumb:
| Situation | Use |
|-----------|-----|
| "This should be impossible if my code is correct" (internal invariant) | assert |
| "This input might actually be wrong" (user / file / API) | raise ValueError (or a narrower type) |
The practice quiz asks about this distinction — the examples tab walks through runnable assert vs raise code.
Exception hierarchy — types you will see often
| Type | Typical cause | Example |
|------|----------------|---------|
| ValueError | Right type, illegal value | int("twelve") |
| TypeError | Operation on incompatible types | 3 + [] |
| KeyError | Missing dict key | d["missing"] |
| IndexError | List index out of range | items[99] on a short list |
| OSError | OS / I/O failure (family) | open("nope.txt") → often FileNotFoundError |
Self-study checklist
- For each
exceptblock, decide: retry, remap to a domain error, log and abort? - Explain when
elseruns versusfinally. - Write one function that uses
raise ValueErrorfor bad input, and one internalassertthat documents an invariant — and say why each choice fits.
Examples
Worked snippets — exceptions in practice
Guard with raise (public API)
Validate before you compute. Callers get a clear error instead of a confusing crash deeper in the call stack.
def pct(part, whole):
if whole == 0:
raise ZeroDivisionError("whole must be non-zero")
if part < 0 or whole < 0:
raise ValueError("negative inputs disallowed")
return 100.0 * part / whole
print(pct(1, 4)) # 25.0
# pct(-1, 4) # ValueError
# pct(1, 0) # ZeroDivisionError
assert vs raise
assert is for internal checks you believe are always true if the program is correct. raise is for external input that might genuinely be wrong.
def naive_with_assert(text):
"""Do not ship this pattern for user input."""
assert text.isdigit(), "expected digits only"
return int(text)
def safe_port(text):
"""Validate untrusted text with raise — survives python -O."""
try:
port = int(text)
except ValueError as e:
raise ValueError(f"not an integer: {text!r}") from e
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return port
print(safe_port("443")) # 443
# safe_port("abc") # ValueError: not an integer: 'abc'
# safe_port("99999") # ValueError: port out of range: 99999
Run the assert version under normal Python, then imagine python -O: the assert line disappears, so int("abc") would run and raise ValueError anyway — but int("12abc") or other cases might behave differently depending on your guard. The point is: never let optimisation remove your only safety check.
Internal invariant — assert is appropriate:
def normalize_weights(weights):
total = sum(weights)
assert total > 0, "caller must pass non-empty positive weights"
return [w / total for w in weights]
Try / except / finally
def read_number(path):
f = None
try:
f = open(path, encoding="utf-8")
return int(f.read().strip())
except (OSError, ValueError) as e:
print(f"recoverable trouble: {e}")
return None
finally:
if f is not None:
f.close()
finally runs whether the try succeeded, raised, or **return**ed. Prefer with open(...) in real code; this version shows finally intent explicitly.
Modern short form with separate handlers:
from pathlib import Path
def read_number(path):
try:
return int(Path(path).read_text(encoding="utf-8").strip())
except OSError:
print("filesystem issue")
return None
except ValueError:
print("contents not an integer")
return None
Catch the narrow type
Handle the failure you can recover from; let unexpected errors propagate.
def parse_positive_int(text):
try:
n = int(text)
except ValueError:
print(f"not an integer: {text!r}")
return None
if n <= 0:
raise ValueError(f"expected positive, got {n}")
return n
print(parse_positive_int("42")) # 42
print(parse_positive_int("x")) # None (handled ValueError from int)
# parse_positive_int("-3") # ValueError: expected positive, got -3
int(text) raises ValueError for "x" — we catch that and return None. A negative number parses successfully, so we raise ourselves: that is a business-rule failure, not a parse failure.
Re-raise with context
Translate a low-level error into a message your caller understands, but keep the original cause:
def load_config(path):
try:
raw = open(path, encoding="utf-8").read()
except OSError as e:
raise RuntimeError(f"could not read config {path!r}") from e
return raw
from e chains the OSError into the traceback — invaluable when debugging.
Custom exception type
Subclass Exception when callers should catch a domain-specific failure:
class BoundsError(Exception):
"""Raised when no samples fall inside the requested window."""
def clipped_mean(xs, low, high):
if low >= high:
raise ValueError("low must be < high")
filt = [x for x in xs if low <= x <= high]
if not filt:
raise BoundsError(f"no samples in [{low}, {high}]")
return sum(filt) / len(filt)
print(clipped_mean([0.2, 0.5, 0.9], 0.0, 1.0)) # 0.533...
try:
clipped_mean([5.0, 6.0], 0.0, 1.0)
except BoundsError as e:
print("caught:", e)
Callers can except BoundsError without catching every ValueError in the program.
else runs only on a clean try
def divides(a, b):
try:
q = a // b
except ZeroDivisionError:
print("cannot divide by zero")
return None
else:
print("quotient", q)
return q
print(divides(10, 2)) # quotient 5, returns 5
print(divides(10, 0)) # cannot divide by zero, returns None
else is for code that should run only when no exception occurred — here, printing and returning the quotient after a successful division.
Practice check — exceptions
Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.
1.In a `try`/`finally`, if the `try` block executes `return 1`, does the `finally` block still run?
2.Which exception does `int('twelve')` raise?
3.When does the `else` clause of a `try` statement run?
4.Why is a bare `except:` (catching everything) discouraged?
5.For validating an untrusted argument in a public function, should you use `assert` or `raise`?