Codetail

Article 11 of 15

Error Handling

Exceptions as control flow. Defensive coding.

18 min read

try / except

Wrap code that might fail in a try block. If an exception is raised, Python jumps to the matching except clause and runs it. Execution continues after the entire try/except structure.

Python
1# Basic form
2try:
3 result = 10 / 0
4except ZeroDivisionError:
5 print("cannot divide by zero")
6
7# Catch multiple exception types separately
8def parse_int(value):
9 try:
10 return int(value)
11 except ValueError:
12 print(f"not a valid integer: {value!r}")
13 return None
14 except TypeError:
15 print(f"expected a string, got {type(value).__name__}")
16 return None
17
18print(parse_int("42")) # 42
19print(parse_int("hello")) # not a valid integer: 'hello'
20print(parse_int(None)) # expected a string, got NoneType

Accessing the exception object

Python
1try:
2 data = {"key": "value"}
3 print(data["missing"])
4except KeyError as e:
5 print(f"key not found: {e}") # key not found: 'missing'
6 print(f"type: {type(e).__name__}") # type: KeyError
7
8# Catching multiple types in one clause
9try:
10 result = int("bad") + None
11except (ValueError, TypeError) as e:
12 print(f"{type(e).__name__}: {e}")

else and finally

The else block runs only if no exception was raised. finally always runs, exception or not, making it the right place for cleanup code.

Python
1def read_config(path):
2 f = None
3 try:
4 f = open(path)
5 data = f.read()
6 except FileNotFoundError:
7 print(f"config not found: {path}")
8 return {}
9 else:
10 print("config loaded successfully")
11 return data # only reached if no exception
12 finally:
13 if f:
14 f.close() # always runs, even if return is hit in else

In practice, use with open(path) as f instead of manual finally: f.close(). Context managers handle this automatically.

Exception hierarchy

Python exceptions form a class hierarchy. except SomeType catches that type and all its subclasses. Catching Exception catches almost everything, but not SystemExit, KeyboardInterrupt, or GeneratorExit, which all inherit from BaseException directly. This is intentional. Those three should almost never be silenced.

Python
1# Catching a parent catches all children
2try:
3 open("missing.txt")
4except OSError as e:
5 # catches FileNotFoundError, PermissionError, etc.
6 print(type(e).__name__, e) # FileNotFoundError: ...
7
8# Specific before general, Python checks clauses top to bottom
9try:
10 result = {}["key"]
11except KeyError:
12 print("specific: key not found")
13except Exception:
14 print("general: something went wrong") # not reached
15
16# Never do this, swallows everything including bugs
17try:
18 do_something()
19except: # bare except catches BaseException, avoid
20 pass

Click any exception class below to see exactly what that except clause would catch.

Exception Hierarchy

Click an exception class to see what except ExcType would catch.

Raising exceptions

Use raise to signal that something went wrong. Pick the most specific built-in exception that fits, or define a custom one. A clear exception with a descriptive message saves hours of debugging.

Python
1def set_age(age: int) -> None:
2 if not isinstance(age, int):
3 raise TypeError(f"age must be int, got {type(age).__name__}")
4 if age < 0 or age > 150:
5 raise ValueError(f"age must be between 0 and 150, got {age}")
6
7set_age(25) # fine
8set_age("old") # TypeError: age must be int, got str
9set_age(-1) # ValueError: age must be between 0 and 150, got -1

raise from, chaining exceptions

raise NewException from original attaches the original exception as the cause. The traceback shows both. This is important for library code: translate low-level exceptions into domain-level ones without hiding the root cause.

Python
1class DatabaseError(Exception):
2 pass
3
4def get_user(user_id: int):
5 try:
6 # simulated DB lookup
7 raw = {"1": "Alice"}[str(user_id)]
8 return raw
9 except KeyError as e:
10 raise DatabaseError(f"user {user_id} not found") from e
11
12try:
13 get_user(99)
14except DatabaseError as e:
15 print(e) # user 99 not found
16 print(e.__cause__) # '99', the original KeyError

Custom exceptions

Define custom exceptions by subclassing Exception. Group related exceptions under a base class so callers can catch them broadly or specifically.

Python
1class AppError(Exception):
2 """Base class for all application errors."""
3
4class ValidationError(AppError):
5 def __init__(self, field: str, message: str):
6 self.field = field
7 super().__init__(f"{field}: {message}")
8
9class NotFoundError(AppError):
10 def __init__(self, resource: str, id):
11 super().__init__(f"{resource} with id={id} not found")
12
13def get_product(product_id: int):
14 if product_id <= 0:
15 raise ValidationError("product_id", "must be positive")
16 if product_id > 100:
17 raise NotFoundError("Product", product_id)
18 return {"id": product_id, "name": "Widget"}
19
20try:
21 get_product(999)
22except ValidationError as e:
23 print(f"validation: {e.field}, {e}")
24except NotFoundError as e:
25 print(f"not found: {e}")
26except AppError as e:
27 print(f"app error: {e}") # catches any other AppError

Re-raising

Python
1import logging
2
3def process(data):
4 try:
5 return int(data)
6 except ValueError as e:
7 logging.error("failed to parse data: %s", e)
8 raise # re-raise the same exception, preserving traceback
9
10# Or raise a different exception
11def load(path):
12 try:
13 with open(path) as f:
14 return f.read()
15 except OSError as e:
16 raise RuntimeError(f"failed to load {path}") from e

Context managers

The with statement guarantees cleanup. Whatever happens inside the block, normal exit or an exception, the context manager's exit code always runs. This replaces the try/finally pattern for resource management.

Python
1# File, most common use case
2with open("data.txt", "w") as f:
3 f.write("hello")
4# file is closed here, even if write raised
5
6# Multiple context managers on one line (Python 3.10+ prefers parentheses)
7with open("input.txt") as src, open("output.txt", "w") as dst:
8 dst.write(src.read().upper())

contextlib.contextmanager

The @contextmanager decorator lets you write a context manager as a generator. Everything before the yield is setup (called on entry), the yielded value becomes the as variable, and everything after is teardown (called on exit).

Python
1from contextlib import contextmanager
2import time
3
4@contextmanager
5def timer(label: str):
6 start = time.perf_counter()
7 try:
8 yield # block runs here
9 finally:
10 elapsed = time.perf_counter() - start
11 print(f"{label}: {elapsed:.4f}s")
12
13with timer("sorting"):
14 data = sorted(range(1_000_000), reverse=True)
15
16# Timer prints even if the body raises
17@contextmanager
18def managed_connection(host: str):
19 conn = connect(host) # setup
20 try:
21 yield conn # body gets the connection
22 except Exception:
23 conn.rollback()
24 raise
25 finally:
26 conn.close() # always runs

contextlib utilities

Python
1from contextlib import suppress, nullcontext
2
3# suppress, silently ignore specific exceptions
4with suppress(FileNotFoundError):
5 import os
6 os.remove("temp.txt") # no error if the file does not exist
7
8# nullcontext, placeholder when you conditionally need a context manager
9def process(file=None):
10 ctx = open(file) if file else nullcontext()
11 with ctx as f:
12 data = f.read() if f else get_default_data()
13 return data

Step through what happens inside a with block when the body succeeds or raises.

Context Manager Lifecycle
with open("file.txt") as f: # body
cm.__enter__()

Context manager enters. Resources are acquired.

Patterns

EAFP vs LBYL

Two philosophies for dealing with conditions that might fail. LBYL (Look Before You Leap) checks before acting. EAFP (Easier to Ask Forgiveness than Permission) just tries and catches the failure. Python culture favors EAFP. It is often more readable and avoids race conditions.

Python
1# LBYL, check first
2def get_value_lbyl(d, key):
3 if key in d: # check
4 return d[key] # then act
5 return None
6
7# EAFP, try and catch
8def get_value_eafp(d, key):
9 try:
10 return d[key] # try
11 except KeyError: # handle failure
12 return None
13
14# EAFP has fewer race conditions in concurrent code:
15# between "if file exists" and "open file", the file can be deleted
16# LBYL (prone to TOCTOU):
17import os
18if os.path.exists("data.txt"):
19 with open("data.txt") as f: # file could be gone now
20 data = f.read()
21
22# EAFP (safe):
23try:
24 with open("data.txt") as f:
25 data = f.read()
26except FileNotFoundError:
27 data = None

Graceful degradation

Catch exceptions at the right level, where you have enough context to handle them meaningfully. Catch too low and you suppress useful errors. Catch too high and you lose precision.

Python
1import json
2import logging
3
4def load_user_config(path: str) -> dict:
5 try:
6 with open(path) as f:
7 return json.load(f)
8 except FileNotFoundError:
9 logging.info("no config at %s, using defaults", path)
10 return {}
11 except json.JSONDecodeError as e:
12 logging.warning("invalid config at %s: %s", path, e)
13 return {}
14
15# The caller gets a clean dict regardless, it never sees the exception
16config = load_user_config("~/.myapp.json")

Exception groups (Python 3.11+)

Python
1# Python 3.11 introduced ExceptionGroup for handling multiple exceptions
2# raised concurrently (e.g., from asyncio task groups)
3
4try:
5 raise ExceptionGroup("multiple failures", [
6 ValueError("bad value"),
7 TypeError("wrong type"),
8 ])
9except* ValueError as eg:
10 print("value errors:", eg.exceptions)
11except* TypeError as eg:
12 print("type errors:", eg.exceptions)

What not to do

Python
1# Bad, bare except swallows everything
2try:
3 risky()
4except:
5 pass
6
7# Bad, catching Exception too broadly hides bugs
8try:
9 result = complex_calculation()
10except Exception:
11 result = 0 # was it a real 0 or a bug?
12
13# Bad, using exceptions for normal control flow
14def find_index(items, target):
15 try:
16 return items.index(target)
17 except ValueError:
18 return -1 # just use list.index() + a check, or next()