An exception is an object that represents an error. Code that raises an exception interrupts normal flow until something catches it. The try / except / else / finally block lets you separate the happy path from the recovery path — and with lets you guarantee cleanup even when things go wrong.
In this chapter you will learn to:
Catch exceptions with try / except and re-raise the ones you can’t handle.
Use else and finally correctly.
Raise exceptions yourself and chain them with from.
Build a custom exception hierarchy for an application.
Manage resources with with and write a context manager.
7.1 Reading a traceback
When an exception goes uncaught, Python prints a traceback — the chain of calls that led to the failure. Read it from the bottom up: the last line is the error type and message; the lines above show the call path.
def divide(a, b):return a / bdivide(10, 0)
---------------------------------------------------------------------------ZeroDivisionError Traceback (most recent call last)
CellIn[1], line 4 1def divide(a, b):
2return a / b
3----> 4 divide(10, 0)
CellIn[1], line 2, in divide(a, b) 1def divide(a, b):
----> 2return a / b
ZeroDivisionError: division by zero
ZeroDivisionError: division by zero — that’s the what. The frames above are the where.
7.2try / except
The default behavior — let the exception propagate up and crash the program — is right surprisingly often. But sometimes you have a sensible recovery: substitute a default, log and continue, return None. try / except lets you intercept a specific exception and run a handler instead.
def safe_divide(a, b):try:return a / bexceptZeroDivisionError:returnfloat("inf")[safe_divide(10, 2), safe_divide(10, 0)]
[5.0, inf]
The try: block is the protected code. If a / b raises ZeroDivisionError, control jumps to the matching except clause — the rest of the try is abandoned.
except ZeroDivisionError: matches only that specific class (and its subclasses). Other exceptions still propagate.
The handler returns float("inf") as a graceful fallback for division by zero.
The first call (10 / 2) never triggers the handler; the second call (10 / 0) does.
The general rule: catch the narrowest exception class that fits the recovery you actually have. Bind the exception to a name with as to inspect it:
try:int("not a number")exceptValueErroras e: message =f"can't parse: {e}"message
"can't parse: invalid literal for int() with base 10: 'not a number'"
int("not a number") raises ValueError("invalid literal for int() with base 10: 'not a number'").
as e binds the exception object to e so the handler can read its message, args, or chained cause.
f"can't parse: {e}" calls str(e) to format the exception’s message.
The general rule: except SomeError as name: gives you the exception object for inspection or re-raising. You can catch several types:
def parse_int(s):try:returnint(s)except (ValueError, TypeError) as e:returnf"error: {e}"[parse_int("42"), parse_int("nope"), parse_int(None)]
[42,
"error: invalid literal for int() with base 10: 'nope'",
"error: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'"]
The tuple (ValueError, TypeError) matches either class.
int("nope") raises ValueError; int(None) raises TypeError — both go to the same handler.
Pass each case through and you get a successful parse, then two formatted error messages.
The general rule: except (A, B): is the way to match multiple unrelated classes in one handler.
Never use a bare except:. It catches everything, including KeyboardInterrupt (Ctrl+C) and SystemExit. If you really need a catch-all, write except Exception: — that omits the system-exit family.
7.3else and finally
A try block has two related extensions. else is the “no exception was raised” branch — useful when you want to keep the try block as small as possible. finally is the “always run, exception or not” branch — useful for cleanup that must happen regardless of outcome.
def parse_log_line(line):try: n =int(line)exceptValueError:returnNoneelse:return n *2finally:# always runs — useful for cleanup, logging, releasing resourcespass[parse_log_line("21"), parse_log_line("oops")]
[42, None]
try: runs the parse. Only the line that might raise is in the try.
except ValueError: returns None for non-numeric input.
else: runs only when the try succeeded — it has access to n. Doubling lives here, not in the try, so we never accidentally catch an exception from the doubling.
finally: runs no matter what — after else, after except, even if return already fired in either branch.
The general rule: try for the protected operation, except for recovery, else for “happy path follow-up”, finally for unconditional cleanup.
7.4 Raising exceptions
You raise an exception when something is wrong that callers must know about.
def set_age(age):ifnotisinstance(age, int):raiseTypeError(f"age must be int, got {type(age).__name__}")ifnot0<= age <=150:raiseValueError(f"age {age} is not realistic")return ageset_age("not a number")
---------------------------------------------------------------------------TypeError Traceback (most recent call last)
CellIn[6], line 8 4ifnot0 <= age <= 150:
5raise ValueError(f"age {age} is not realistic")
6return age
7----> 8 set_age("not a number")
CellIn[6], line 3, in set_age(age) 1def set_age(age):
2ifnot isinstance(age, int):
----> 3raise TypeError(f"age must be int, got {type(age).__name__}")
4ifnot0 <= age <= 150:
5raise ValueError(f"age {age} is not realistic")
6return age
TypeError: age must be int, got str
Two distinct exception classes guard the two distinct failure modes. TypeError is for “wrong kind of value” — passing a str where an int was expected. ValueError is for “right kind, wrong value” — an int outside the realistic range. Choosing the right class matters because callers’ except clauses match by type: code that wants to recover from age-out-of-range should write except ValueError, and a TypeError should propagate unchecked. The f"age must be int, got {type(age).__name__}" formats a useful diagnostic — telling the user what we got, not just what we wanted.
Use raise X from Y to chain — the traceback shows both the original cause and the new exception:
import jsondef load_config(text):try:return json.loads(text)except json.JSONDecodeError as e:raiseValueError("config file is not valid JSON") from eload_config("{ bad json")
---------------------------------------------------------------------------JSONDecodeError Traceback (most recent call last)
CellIn[7], line 7, in load_config(text) 5return json.loads(text)
6except json.JSONDecodeError as e:
----> 7raise ValueError("config file is not valid JSON") from e
File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/json/__init__.py:352, in loads(s, cls, object_hook, parse_float, parse_int, parse_constant, object_pairs_hook, **kw) 349if (clsisNoneand object_hook isNoneand 350 parse_int isNoneand parse_float isNoneand 351 parse_constant isNoneand object_pairs_hook isNoneandnot kw):
--> 352return_default_decoder.decode(s) 353ifclsisNone:
File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/json/decoder.py:345, in JSONDecoder.decode(self, s, _w) 341"""Return the Python representation of ``s`` (a ``str`` instance 342containing a JSON document). 343 344"""--> 345 obj, end = self.raw_decode(s,idx=_w(s,0).end()) 346 end = _w(s, end).end()
File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/json/decoder.py:361, in JSONDecoder.raw_decode(self, s, idx) 360try:
--> 361 obj, end = self.scan_once(s,idx) 362exceptStopIterationas err:
JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 3 (char 2)
The above exception was the direct cause of the following exception:
ValueError Traceback (most recent call last)
CellIn[7], line 9 5return json.loads(text)
6except json.JSONDecodeError as e:
7raise ValueError("config file is not valid JSON") from e
8----> 9 load_config("{ bad json")
CellIn[7], line 7, in load_config(text) 3def load_config(text):
4try:
5return json.loads(text)
6except json.JSONDecodeError as e:
----> 7raise ValueError("config file is not valid JSON") from e
ValueError: config file is not valid JSON
The try / except catches the low-level json.JSONDecodeError and re-raises as a ValueError whose message describes the problem in the caller’s vocabulary (the caller knows about config files; they shouldn’t have to know about JSON internals). The from e clause attaches the original exception as e.__cause__ on the new one, so the printed traceback shows both:
json.decoder.JSONDecodeError: Expecting property name ...
The above exception was the direct cause of the following exception:
ValueError: config file is not valid JSON
That two-frame traceback is the value of from e: callers see the abstraction-appropriate error; debuggers still see the raw cause. Without from e, Python prints the chain anyway but with “During handling of the above exception, another exception occurred” — that’s the implicit chain (called __context__), reserved for accidental cascades.
7.5 The exception hierarchy
All exceptions inherit from BaseException. Most “normal” errors inherit from Exception — that’s the level you usually catch.
The rule: catch the most specific exception that fits. Catching Exception everywhere hides bugs.
7.6 Custom exceptions
Catching Exception is too broad; catching ValueError everywhere a domain error happens is sloppy. The right move is to define your own hierarchy: a base class for “any error from this app” plus a subclass per logical category. Callers then handle specific errors by class, never by string-matching. Build it in three small steps.
Step 1: a custom exception is just a class. Inherit from Exception and you’re done — no body required:
class AppError(Exception):"""Base exception for this application."""try:raise AppError("something went wrong")except AppError as e:str(e)
class AppError(Exception): declares an empty subclass of Exception. The docstring is the whole body — that’s enough for a marker class.
The string passed to raise AppError(...) becomes the message that str(e) returns; Exception.__init__ does that for you.
Step 2: build a hierarchy. Add a subclass per category — callers can catch the base for any-of-ours, or a specific one:
class AppError(Exception):"""Base exception for this application."""class DataError(AppError):"""Raised when data is invalid or missing."""class AuthError(AppError):"""Raised when authentication fails."""try:raise DataError("bad row 42")except AppError as e:type(e).__name__, str(e)
DataError and AuthError extend AppError, so any except AppError: clause catches both.
The exception caught is exactlyDataError — type(e).__name__ shows the concrete class. except AppError matched because DataErroris-aAppError.
A caller who wants to handle DataError differently from AuthError writes two except clauses, most-specific first.
Step 3: carry structured data. Sometimes a message string is not enough — you want fields the caller can inspect (a user id, a reason code). Override __init__ and call super().__init__(...) to set the message:
class AuthError(AppError):"""Raised when authentication fails."""def__init__(self, user, reason):self.user = userself.reason = reasonsuper().__init__(f"auth failed for {user!r}: {reason}")try:raise AuthError("alice", "wrong password")except AuthError as e: e.user, e.reason, str(e)
__init__ stores user and reason on the instance — callers can read them programmatically without parsing the message.
super().__init__(message) calls the parent class’s (Exception’s) __init__ to set the formatted string that str(e) returns. super() is the standard way to invoke a parent class’s behaviour from a subclass — the deep treatment is in Chapter 9. Forgetting this call is the classic bug: str(e) would come back empty.
Catching code can now branch on e.reason instead of string-matching the message.
The general rule: subclass Exception for an app-specific base, then add a subclass per error category. Override __init__ only when you need structured fields beyond a plain message.
7.7with and context managers
When you open a file, you eventually need to close it — even if an exception interrupts the work in between. Hand-rolled try / finally works but clutters every call site. The with statement bundles “set up the resource, do the work, tear it down no matter what” into one block, by calling the object’s __enter__ and __exit__ methods. The most common use is opening a file:
tempfile.mkdtemp() returns the path of a fresh, empty temporary directory; Path (from pathlib, the modern path-handling module covered in Chapter 8) wraps that path so we can use / to compose subpaths — target ends up as something like /tmp/tmpAbCdEf/hello.txt.
target.write_text(...) is pathlib’s one-line write — it opens the file, writes the text, and closes it. We’re using it just to set up the input for the demo; the topic is what comes next.
with open(target, encoding="utf-8") as f: opens the file, binds it to f, and registers a guarantee: when the block exits — normally or via exception — f.close() is called.
content = f.read() reads while the file is open.
The moment the indented block ends, the file is closed. There’s no way to forget.
The general rule: any time a resource needs cleanup, look for or write a context manager and use it with with. You can write your own context manager with contextlib.contextmanager:
@contextmanager turns a generator function into a context manager. The single yield splits the function into “before the block” (setup) and “after the block” (teardown).
Code before yield runs on __enter__ — here, capturing the start time.
Code after yield, inside finally, runs on __exit__ — guaranteed to print the elapsed time even if the body raises.
with timer("compute"): enters, runs the body, exits — and the timing line prints.
The general rule: write the setup, yield, then the teardown wrapped in try/finally — @contextmanager handles the protocol wiring for you.
For the common case “swallow this specific exception silently,” contextlib.suppress is more concise than a try / except that does nothing:
from contextlib import suppressfrom pathlib import Path# Delete a file if it exists; do nothing if it doesn't:target = Path("/tmp/does-not-exist-xyz")with suppress(FileNotFoundError): target.unlink()"continued without error"
'continued without error'
Use it sparingly — silently ignored exceptions are bugs in disguise. It earns its place when the absence of the resource is genuinely a no-op.
7.8 Multiple exceptions at once: ExceptionGroup
Sometimes a single operation produces several independent failures — a parallel task fan-out, a batch validation, a multi-file write. Python 3.11 added ExceptionGroup to bundle them, and except* to catch by type without unbundling the rest:
def validate_all():raise ExceptionGroup("invalid input", [ValueError("age out of range"),TypeError("name must be a string"), ])try: validate_all()except*ValueErroras eg:print("value problems:", [str(e) for e in eg.exceptions])except*TypeErroras eg:print("type problems:", [str(e) for e in eg.exceptions])
value problems: ['age out of range']
type problems: ['name must be a string']
The * says “match any leaf exception of this type inside the group.” Both except* clauses run; you don’t have to choose one. For everyday single-error code, plain except is still the right tool — ExceptionGroup is for genuinely concurrent or batch failures, where reporting all problems is the point.
The deep treatment of with, including the __enter__ / __exit__ protocol and contextlib.suppress, is in Chapter 30.
TipWhy this matters
Exceptions separate the happy path from the recovery path. Code with explicit error returns (if result is None: …) clutters every call site; code with exceptions reads as the algorithm. The with statement makes resource leaks impossible — a guarantee that’s hard to provide with manual try / finally.
7.9 Going deeper
with, match, and the orphaned else clauses on for and while loops are the subject of Chapter 30. The descriptor protocol behind __enter__ / __exit__ and the broader data model is Chapter 13.
7.10 Build: a robust JSON config loader
Real applications load configuration from a file, parse it, and complain clearly when something’s wrong. We’ll build load_config(path) that exercises custom exceptions, exception chaining, multiple except clauses, and with for file lifecycle — all the chapter’s pieces in one running example.
Step 1: a small exception hierarchy. Two classes — a base for “any config problem” and a specific subclass for “a required key is missing”:
class ConfigError(Exception):"""Base exception for config-loading failures."""class MissingKeyError(ConfigError):def__init__(self, key):self.key = keysuper().__init__(f"missing required key: {key!r}")try:raise MissingKeyError("database_url")except ConfigError as e:type(e).__name__, e.key, str(e)
MissingKeyError carries a structured key field so callers can branch on the missing key without parsing the message. Catching ConfigError matches both classes — exactly the hierarchy from earlier in the chapter.
Step 2: chain a parse failure with from. When json.loads fails, we want the caller to see a ConfigError (our domain error) but still keep the original JSONDecodeError for debugging. raise X from Y does that:
import jsondef parse_config(text):try:return json.loads(text)except json.JSONDecodeError as e:raise ConfigError(f"config is not valid JSON: {e.msg}") from etry: parse_config("{ bad json")except ConfigError as e:type(e).__name__, str(e), type(e.__cause__).__name__
from e sets e.__cause__ on the new exception — Python’s traceback uses it to print “The above exception was the direct cause of the following exception”. e.__cause__ is the original JSONDecodeError. Callers see a clean ConfigError; debuggers still see the raw cause.
Step 3: tie it together with with, validation, and a missing-file branch. The full loader opens the file (closing it on every exit path), parses, validates required keys, and raises a clean ConfigError for every failure mode:
import json, tempfilefrom pathlib import PathREQUIRED = ("host", "port")def load_config(path):try:withopen(path, encoding="utf-8") as f: data = json.load(f)exceptFileNotFoundError:raise ConfigError(f"config file not found: {path}") fromNoneexcept json.JSONDecodeError as e:raise ConfigError(f"config file is not valid JSON: {e.msg}") from efor key in REQUIRED:if key notin data:raise MissingKeyError(key)return data# Set up a valid file and a missing-key file in a temp dirtmp = Path(tempfile.mkdtemp())(tmp /"good.json").write_text('{"host": "localhost", "port": 5432}')(tmp /"partial.json").write_text('{"host": "localhost"}')results = []for name in ("good.json", "partial.json", "missing.json"):try: results.append(("ok", load_config(tmp / name)))except ConfigError as e: results.append(("err", type(e).__name__, str(e)))results
Three new tricks here. The with open(...) block guarantees the file closes even if json.load raises — exactly the cleanup invariant with was designed for. from Nonesuppresses the original FileNotFoundError from the chained traceback — useful when the original exception adds noise rather than information. And the for key in REQUIRED: validation loop raises the structured MissingKeyError at the first missing key — callers can read e.key to know which.
The build exercises every piece from the chapter: custom exception hierarchy, super().__init__, multiple except clauses, chaining (from e) vs. suppression (from None), with for file lifecycle, and validation as raise-on-failure. It’s also the shape of nearly every real-world config loader.
7.11 Exercises
Specific over generic. Catch only FileNotFoundError for a missing file, returning a default. Run again with except Exception — what bugs would the broad catch hide?
Chain a parse error. Write parse_age(s) that calls int(s) and re-raises ValueError(f"age must be a number, got {s!r}") — chained with from.
Custom hierarchy. Define ConfigError(Exception) with subclasses MissingKeyError and BadValueError. Write a function that raises one or the other.
with for cleanup. Write a context manager chdir(path) that changes the working directory on entry and restores it on exit, even if the body raises.
finally order. Predict the print order of: try: print("a"); raise ValueError("x") except ValueError: print("b") finally: print("c").
7.12 Summary
try / except / else / finally cover error handling; raise and from cover signaling; with covers resource lifecycles. The next chapter, Chapter 8, applies all of this to the most common source of errors in real programs — talking to the file system.