Part 1 · Foundations · Module 07
Errors & Exceptions
Things go wrong: files are missing, users type "twelve" where you wanted 12, networks drop. Exceptions are Python's way of saying "I can't continue here" and letting the right piece of code decide what to do. Handling them well is the difference between a script and a program people trust.
- Read a traceback from the bottom up and locate the real cause of an error
- Handle exceptions with
try/except/else/finally, catching only what you can handle - Choose between EAFP and LBYL styles
- Raise built-in and custom exceptions, and design a small exception hierarchy
- Chain exceptions with
from, add notes, and handle groups withexcept* - Use Python 3.14's parenthesis-free
except A, B:and understand the newfinallywarnings - Know when to use
assertandwarnings, and write error messages that help
Two kinds of errors
A syntax error means Python could not even understand your code, so nothing runs. Python reports it before execution starts, with a caret pointing at the problem.
An exception happens while the code is running: the code is valid Python, but an operation cannot be carried out with these values. When that happens, Python creates an exception object describing the problem and raises it. Normal execution stops, and Python looks for code that will handle it. If nothing does, the program ends and prints a traceback.
Every exception is an instance of a class, and the classes form a hierarchy. Catching a class also catches all its subclasses, which is why the hierarchy matters.
flowchart TD
BE[BaseException] --> SE[SystemExit]
BE --> KI[KeyboardInterrupt]
BE --> EX[Exception]
EX --> AE[ArithmeticError] --> ZDE[ZeroDivisionError]
EX --> LE[LookupError]
LE --> IE[IndexError]
LE --> KE[KeyError]
EX --> VE[ValueError] --> UDE[UnicodeDecodeError]
EX --> TE[TypeError]
EX --> NE[NameError] --> ULE[UnboundLocalError]
EX --> OE[OSError] --> FNF[FileNotFoundError]
EX --> ATE[AttributeError]
EX --> RE[RuntimeError] --> RCE[RecursionError]
Notice that SystemExit and KeyboardInterrupt (Ctrl+C) are not under
Exception. That is deliberate: except Exception should never stop a user from
quitting your program.
Anatomy of a traceback
A traceback is the report Python prints for an unhandled exception. It lists the chain of calls that were active when the error happened, oldest first. Run this and read the output:
How to read it, every time:
- Start at the last line.
ZeroDivisionError: division by zerogives the exception type and message. This is what went wrong. - Read the frame just above it. The last
File "...", line N, in averageentry shows the exact line that raised, often with~~~^~~~markers under the failing expression. - Walk upwards to see how you got there:
averagewas called byreport, which was called from the module top level. The bug is often in a caller that passed bad data (here, an empty list), not in the line that raised.
try, except, else, finally
Put code that might fail in a try block, and say how to recover in an except
block. Two optional clauses complete the statement:
elseruns only if thetryblock raised nothing. Put the "success" code here, so thetryblock contains only the risky line and you do not accidentally catch errors from the success code.finallyruns no matter what: success, handled exception, unhandled exception, even areturn. Use it for cleanup such as closing a connection.
flowchart TD
T[try block] -->|no exception| EL[else block]
T -->|exception matches an except| EX[except block]
T -->|exception matches nothing| F2[finally block] --> UP[exception propagates to the caller]
EL --> F[finally block]
EX --> F
F --> NEXT[code after the try statement]
Step through to watch the jumps. Notice how control leaves the try block the instant an
exception is raised, skipping the rest of it.
When an exception is not handled in the current function, it propagates: the function ends immediately and the exception is re-raised at the call site, then its caller, and so on up the stack. You can therefore handle errors at whatever level knows how to recover.
Catch specific exceptions
An except clause names the exception types it handles. Python checks clauses from top to bottom
and runs the first one that matches, so put specific classes before general ones. Use
as name to get the exception object.
try:
value = settings[key]
except KeyError as e:
print(f"missing setting: {e}")
To handle several types the same way, list them. Python 3.14 lets you drop the parentheses when you do not
use as:
try:
number = int(raw)
except ValueError, TypeError:
number = 0
try:
number = int(raw)
except (ValueError, TypeError) as e: # with 'as', parentheses are still required
print(e)try:
number = int(raw)
except (ValueError, TypeError):
number = 0Why never use a bare except:
A bare except: (or except BaseException:) catches everything, including
KeyboardInterrupt, SystemExit, and the typo NameError you would want to
see. It hides bugs. Even except Exception: is too broad unless you log or re-raise. Catch the
narrowest exception you know how to handle and let the rest propagate.
The function quietly returns 0, and the typo goes unnoticed. With
except KeyError: the NameError would surface immediately. If you must catch broadly
(for example at the very top of a long-running service), log the error with its traceback, never
pass.
EAFP vs LBYL
Two styles for code that might fail:
LBYL: Look Before You Leap. Check first, then act.
if key in config:
port = int(config[key])
else:
port = 8080
EAFP: Easier to Ask Forgiveness than Permission. Try it, handle failure.
try:
port = int(config[key])
except KeyError:
port = 8080
Python culture leans towards EAFP for three reasons. First, checks can be incomplete: key in
config does not tell you whether the value is a valid integer. Second, checks can be out of date: a
file that existed when you checked can be deleted before you open it (a race condition). Third, the
common path stays uncluttered. LBYL is still right when failure is common and cheap to detect, or when the
operation has side effects you cannot undo halfway.
The LBYL version disagrees with int() on "1_000" (which int accepts)
and needs extra work for signs. EAFP asks the real authority, int itself.
Raising exceptions and custom hierarchies
Your own functions should raise exceptions when they are called with values they cannot work with. Raising
is better than returning a special value like -1 or None, because the caller cannot
accidentally ignore it.
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError(f"amount must be positive, got {amount}")
if amount > balance:
raise ValueError(f"insufficient funds: balance {balance}, requested {amount}")
return balance - amount
Pick the built-in class that matches the problem: ValueError for a right-typed but wrong value,
TypeError for the wrong type, KeyError/IndexError for missing items,
NotImplementedError for a method still to be written. Inside an except block, a bare
raise re-raises the current exception unchanged, which is useful after logging.
Custom exceptions
For a library or application, define your own exception classes. A custom class is a one-liner that
subclasses Exception (classes are taught in Part 2; for now copy the pattern). Build a small
hierarchy with one base class for your package, so callers can catch everything from your
code with one clause, or specific failures individually.
Chaining and notes
Often you catch a low-level error and raise a higher-level one that makes sense to your caller: a
KeyError deep in config parsing becomes a ConfigError("missing 'port'"). Use
raise New(...) from original to chain them. The original is stored as
__cause__ and printed in the traceback under "The above exception was the direct cause of the
following exception", so no information is lost.
If you raise inside an except block without from, Python still records the
original as __context__ and prints "During handling of the above exception, another exception
occurred", which suggests a bug in the handler. Write from e to say "this is deliberate", or
from None to hide the original when it is pure noise.
Notes (Python 3.11+) attach extra context to an exception as it travels up, without changing
its type: e.add_note("while reading row 17"). Notes are printed after the message.
Exception groups and except*
Sometimes several independent things fail at once: validating ten fields of a form, or running five tasks
concurrently. Reporting only the first failure is frustrating. An ExceptionGroup bundles several
exceptions into one, and the except* clause handles the members by type. Each
except* clause receives a sub-group of the matching exceptions; anything unhandled is re-raised.
You will meet exception groups again with asyncio.TaskGroup in
M38, where several concurrent tasks can fail together.
Cleanup, and the new finally rule
finally exists for cleanup that must happen whatever occurs: releasing a lock, closing a file,
printing "done". In M09 you will see that the with statement (a context manager) packages
this pattern so you rarely write finally by hand.
A return, break, or continue that jumps out of a
finally block is a trap: it silently discards any exception in flight and overrides any earlier
return value.
Assertions, warnings, and good error messages
assert: checks for programmers
assert condition, "message" raises AssertionError if the condition is false. Use it
to state assumptions that should always be true if your code is correct, an internal sanity check.
Never use it to validate user input or data from outside: running Python with -O removes every
assert. Tests are the main place you will write asserts (pytest is built on them, M17).
def mean(values):
result = sum(values) / len(values)
assert min(values) <= result <= max(values), "mean must lie within the data" # internal invariant
return result
warnings: problems that should not stop the program
A warning tells the developer "this works, but you should know something", for example that a function is
deprecated. Warnings are printed once per location by default and can be turned into errors (the
-W error flag) for testing.
Writing error messages that help
A good message answers three questions: what was wrong, which value caused it, and ideally what is expected.
| Unhelpful | Helpful |
|---|---|
ValueError("bad input") | ValueError("age must be between 0 and 150, got -4") |
KeyError("x") from deep inside | ConfigError("missing required setting 'port' in settings.toml") |
Exception("error") | LedgerError("jan.csv:17: invalid amount '12,3.4'") |
Practice
Write safe_divide(a, b) that returns a / b, or None when
b is zero. Use try/except, catching only
ZeroDivisionError, so that other mistakes such as passing a string still raise.
Hint
except ZeroDivisionError: return None
Show solution
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None
Write get_path(data, *keys, default=None) that follows keys through nested
dicts and lists, for example get_path(cfg, "db", "hosts", 0). Return default if
any step is missing (KeyError, IndexError) or impossible
(TypeError, such as indexing an int). Use EAFP, not in checks.
Hint
Loop over keys, doing data = data[key]
inside one try; catch the three exception types together.
Show solution
def get_path(data, *keys, default=None):
try:
for key in keys:
data = data[key]
except KeyError, IndexError, TypeError:
return default
return data
Create ValidationError(Exception) and two subclasses: MissingFieldError and
RangeError. MissingFieldError(field) must store .field and have the
message "missing field: <field>". Then write validate_age(record) that returns
record["age"] as an int, raising MissingFieldError("age") if the key is absent and
RangeError if the age is outside 0–150.
Hint
In __init__, call
super().__init__(f"missing field: {field}") and then set self.field = field.
Show solution
class ValidationError(Exception):
"""Base class for validation problems."""
class MissingFieldError(ValidationError):
def __init__(self, field):
super().__init__(f"missing field: {field}")
self.field = field
class RangeError(ValidationError):
pass
def validate_age(record):
try:
age = int(record["age"])
except KeyError:
raise MissingFieldError("age") from None
if not 0 <= age <= 150:
raise RangeError(f"age must be between 0 and 150, got {age}")
return age
Define ConfigError(Exception) and write load_port(config) that returns
int(config["port"]). A missing key or a non-numeric value must raise ConfigError
chained with from to the original exception. Ports outside 1–65535 raise
ConfigError too.
Hint
Use two except clauses, each with
raise ConfigError(...) from e. Do the range check after the try.
Show solution
class ConfigError(Exception):
pass
def load_port(config):
try:
port = int(config["port"])
except KeyError as e:
raise ConfigError("missing setting 'port'") from e
except ValueError as e:
raise ConfigError(f"port must be a number, got {config['port']!r}") from e
if not 1 <= port <= 65535:
raise ConfigError(f"port must be 1-65535, got {port}")
return port
Write parse_all(values) that converts every string in values to an int. If any
fail, raise one ExceptionGroup with the message "invalid values" containing the
ValueError for each bad item. If all succeed, return the list of ints.
Hint
Collect results and errors in two lists inside the loop; raise
ExceptionGroup("invalid values", errors) afterwards if errors is not empty.
Show solution
def parse_all(values):
results, errors = [], []
for value in values:
try:
results.append(int(value))
except ValueError as e:
errors.append(e)
if errors:
raise ExceptionGroup("invalid values", errors)
return results
Mini-project: a robust input validator
Quiz
Recap
- Exceptions are objects in a class hierarchy; your code deals with subclasses of
Exception. - Read tracebacks bottom-up: what, where, then how.
trysmall;exceptspecific;elsefor success;finallyfor cleanup.- Python 3.14:
except A, B:without parentheses (not withas); aSyntaxWarningforreturn/break/continueout offinally. - Prefer EAFP when the operation itself validates best.
- Raise precise exceptions; give your package one base exception with specific subclasses.
- Chain with
from, hide noise withfrom None, add context withadd_note. ExceptionGroup+except*report many failures at once.assertfor invariants,warningsfor non-fatal notices, and messages that include the bad value.
Going further
- Python tutorial: Errors and exceptions
- Built-in exceptions and the full hierarchy
- PEP 758: Allow except and except* without parentheses
- PEP 765: Disallow return/break/continue that exit a finally block
- PEP 654: Exception groups and except*
- warnings: warning control
- Next: M08 Modules, Packages & Pixi Projects