Error handling
Python uses exceptions as the principal error-propagation mechanism. The exception hierarchy is rooted at BaseException; user-facing exceptions derive from Exception. The try/except/else/finally admits structured handling; raise produces exceptions; with admits resource management with exception-safe cleanup. Python’s EAFP style (“easier to ask forgiveness than permission”) favours attempting an operation and catching the failure over checking preconditions; the conventional Python idiom uses exceptions extensively for ordinary control flow that other languages might handle with return codes or option types.
This page covers the exception model, the principal exception types, the try statement variants, context managers, and the conventional discipline.
The exception model
An exception is raised with raise, propagates up the call stack, and is caught by an enclosing try/except:
def divide(a, b):
if b == 0:
raise ZeroDivisionError("denominator must not be zero")
return a / b
try:
result = divide(10, 0)
except ZeroDivisionError as e:
print(f"error: {e}")
The principal operations:
raise SomeException(args)— raise a new exception.raise(alone, in anexcept) — re-raise the current exception.raise NewException from original— chained re-raise; preserves the cause.try:…except:…else:…finally:— the structured form.
The exception hierarchy
The standard library’s exception hierarchy:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── AttributeError
├── EOFError
├── ImportError
│ └── ModuleNotFoundError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── MemoryError
├── NameError
│ └── UnboundLocalError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ ├── TimeoutError
│ └── (many more)
├── ReferenceError
├── RuntimeError
│ ├── NotImplementedError
│ └── RecursionError
├── StopIteration
├── StopAsyncIteration
├── SyntaxError
├── SystemError
├── TypeError
├── ValueError
│ └── UnicodeError
└── Warning
The convention:
BaseExceptionis the base of everything. Catching it interceptsKeyboardInterruptandSystemExit, which is rarely desirable.Exceptionis the conventional base for catching “any problem”. Most user code catches subclasses ofException.SystemExitandKeyboardInterruptderive fromBaseExceptionrather thanExceptionso thatexcept Exception:does not swallow them.
try/except
The basic form:
try:
risky_operation()
except SomeError as e:
handle(e)
Multiple handlers:
try:
risky_operation()
except FileNotFoundError:
handle_missing()
except PermissionError as e:
handle_permission(e)
except OSError as e:
handle_other_os(e)
The handlers are tried in order; the first matching one runs. The as e binds the exception to a name (for inspection); without it, the exception is caught but not bound.
A single handler may match multiple types:
try:
operation()
except (FileNotFoundError, PermissionError) as e:
handle(e)
The tuple form admits “any of these”.
else and finally
The full try admits two additional clauses:
try:
f = open(path)
except FileNotFoundError:
print("missing")
else:
# Runs only if no exception was raised
print("opened")
process(f)
finally:
# Runs always (success or exception)
f.close()
The semantics:
else— runs if thetryblock completes without an exception.finally— runs unconditionally, even on exception, return, or break/continue.
The conventional uses:
elsefor code that depends on thetrysucceeding (avoids putting it in thetryitself, which would catch its exceptions too).finallyfor cleanup (closing files, releasing locks, etc.).
For most cleanup cases, with (treated below) is the conventional contemporary form.
raise
Raising an exception:
raise ValueError("invalid input")
raise ValueError # exception class; instantiates with no args
raise ValueError("invalid", "input") # multiple args
Re-raising:
try:
operation()
except ValueError:
log("got a ValueError")
raise # re-raise the same exception
Bare raise re-raises the current exception (only valid inside an except block); preserves the original traceback.
Chained re-raise:
try:
parse(data)
except ValueError as e:
raise RuntimeError("could not parse") from e
The from e admits exception chaining — the new exception’s __cause__ is the original. Tracebacks show both:
ValueError: invalid format
The above exception was the direct cause of the following:
RuntimeError: could not parse
To suppress the chain (when the original is irrelevant):
raise RuntimeError("could not parse") from None
The from None admits a clean traceback.
Custom exceptions
User-defined exceptions inherit from Exception (or a more specific base):
class ParseError(Exception):
"""Raised when input cannot be parsed."""
pass
class ParseError(Exception):
def __init__(self, message: str, line: int):
super().__init__(message)
self.line = line
raise ParseError("bad syntax", line=42)
The conventional discipline:
- Inherit from a meaningful base (
ValueErrorfor value errors,RuntimeErrorfor runtime conditions,Exceptionfor ad-hoc). - Provide a docstring explaining when the exception is raised.
- Add attributes (
line,column,path) when contextual information matters.
For application-level errors, a hierarchy is conventional:
class AppError(Exception):
"""Base for all application errors."""
class ConfigError(AppError):
"""Configuration is invalid."""
class DatabaseError(AppError):
"""Database operation failed."""
# A caller can catch all application errors:
except AppError:
...
# Or be more specific:
except ConfigError:
...
The pattern admits both broad and narrow catching.
with and context managers
The with statement admits resource management with exception-safe cleanup:
with open(path) as f:
contents = f.read()
# f is closed automatically, even on exception
The with statement uses the context-manager protocol: an object with __enter__ and __exit__. Treated in Duck typing and protocols.
For resources that should be cleaned up, the conventional Python form is with:
with open(path) as f, open(other_path) as g: # multiple resources
process(f, g)
with lock:
do_critical_section()
with transaction(db):
db.execute(...)
The contextlib.suppress admits silently ignoring an exception:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("temp.txt")
# no exception raised if the file is missing
The suppress form is the conventional Python idiom for “remove this if it exists”.
EAFP vs LBYL
Python’s idiomatic style is EAFP — “easier to ask forgiveness than permission”. The pattern: try the operation; catch the failure.
# EAFP:
try:
value = mapping[key]
except KeyError:
value = default
The contrast is LBYL — “look before you leap”:
# LBYL:
if key in mapping:
value = mapping[key]
else:
value = default
The conventional Python preference is EAFP for several reasons:
- Race conditions: between the check and the use, another thread (or another process modifying the filesystem) may change the state.
- Performance: for the common case (the operation succeeds), EAFP has no overhead beyond the operation itself.
- Idiomatic: Python’s exception machinery is designed for this style; exceptions are not “exceptional” in Python’s idiom.
For dict access specifically, .get(key, default) is the conventional one-liner:
value = mapping.get(key, default)
The .get is neither EAFP nor LBYL; it’s a built-in API for the common case.
For attribute access:
# EAFP:
try:
name = obj.name
except AttributeError:
name = "anonymous"
# Or with getattr:
name = getattr(obj, "name", "anonymous")
The getattr(obj, "name", default) is the conventional Python form.
assert
The assert statement checks an invariant:
def divide(a: int, b: int) -> float:
assert b != 0, "denominator must not be zero"
return a / b
assert cond, message raises AssertionError(message) if cond is false.
A critical caveat: assert is disabled when Python runs with -O (optimised mode). The conventional discipline:
- Use
assertfor invariants — conditions that should always hold by construction. - Use
raisefor runtime checks — conditions that depend on input or external state.
# Invariant: after this block, list is non-empty:
result = compute(...)
assert len(result) > 0 # for the developer; not for production input
# Runtime check: input may be empty:
if not input_list:
raise ValueError("input must be non-empty")
The runtime check should never be disabled by -O.
Common patterns
Try-except-else
try:
response = http.get(url)
except RequestException as e:
log_error(e)
return None
else:
return response.json()
The else admits the success path without including it in the try (which would catch any of its exceptions too).
Resource cleanup
with open(path, "w") as f:
f.write(data)
# file is closed and flushed
The with is the conventional Python idiom for resource cleanup; preferable to try/finally for the common case.
Multiple resources
with open(in_path) as src, open(out_path, "w") as dst:
for line in src:
dst.write(transform(line))
The form admits opening multiple files with one with statement.
Custom context manager
from contextlib import contextmanager
@contextmanager
def timer(name):
import time
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{name} took {elapsed:.3f}s")
with timer("computation"):
do_work()
# computation took 0.123s
The @contextmanager admits writing context managers as generator functions; the yield is the entry point.
Suppressing specific exceptions
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove(temp_file)
The suppress form is shorter than try/except: pass.
Exception groups (Python 3.11+)
For handling multiple unrelated exceptions:
# Python 3.11+:
try:
do_work()
except* ValueError as eg:
for e in eg.exceptions:
log_value_error(e)
except* TypeError as eg:
for e in eg.exceptions:
log_type_error(e)
The except* admits handling ExceptionGroups — typically produced by asyncio.TaskGroup and similar concurrent contexts. The mechanism is rare in routine code.
Validation
def validate_input(data):
if not isinstance(data, dict):
raise TypeError("data must be a dict")
if "name" not in data:
raise KeyError("data must include 'name'")
if not data["name"]:
raise ValueError("name must not be empty")
The pattern admits early-return with specific error messages.
Result-style returns
For cases where exceptions feel heavy, return a result type:
from dataclasses import dataclass
@dataclass
class Success:
value: object
@dataclass
class Failure:
error: str
def parse(data) -> Success | Failure:
try:
return Success(value=int(data))
except ValueError as e:
return Failure(error=str(e))
The pattern is rare in Python (which conventionally uses exceptions); it admits cases where the caller wants explicit handling without try/except.
Common defects
| Defect | Description |
|---|---|
Catching Exception indiscriminately | Catches everything but KeyboardInterrupt and similar. Catch the specific types. |
Catching BaseException | Catches KeyboardInterrupt too — the user can’t interrupt the program. |
Empty except: pass | Swallows all errors silently. Either log, re-raise, or do not catch. |
raise e instead of raise | raise e resets the traceback. Bare raise preserves it. |
| Catching the wrong type | except IOError: was renamed to OSError in Python 3; the old name is an alias. |
Forgetting with for resources | Files, locks, sockets — leak resources without explicit cleanup. |
try/finally instead of with | The with form is conventional and clearer for the common case. |
A note on the discipline
The conventional contemporary Python error-handling advice:
- Use exceptions for exceptional cases — but Python’s idiom is broader than other languages’; ordinary control flow uses exceptions too.
- Catch specific exception types; rarely catch
Exceptionand never catchBaseException. - Use
withfor resource cleanup. - Use chained re-raise (
raise ... from e) to preserve the cause. - Use custom exception hierarchies for application-level errors.
- Use
assertfor invariants,raisefor runtime checks. - Use EAFP for ordinary access patterns; LBYL only when the check has substantive value beyond the operation.
The combination — explicit exception types, structured handling, with-based cleanup, and the EAFP idiom — is the substance of Python’s error-handling discipline.