Python error handling uses exceptions — objects that inherit from BaseException, raised with raise and caught with try/except — with finally for guaranteed cleanup and context managers (the with statement) for resource management.
Writing code that doesn't crash
What you'll learn: How Python's exception system works, how to catch errors with try/except, and how to raise your own exceptions. After this tab your programs will handle unexpected situations gracefully instead of printing a traceback and dying.
How to read this tab: For each code example, look at what exception type is being caught and why. Think about: what could go wrong in this code? That's the question error handling answers.
Error handling is like a safety net under a tightrope. You hope you don't fall, but you have a plan for when you do. In Python, errors are not crashes waiting to happen — they're events your program can catch, handle, and recover from.
The hierarchy matters for catching. All exceptions inherit from BaseException. Most inherit from Exception. This means except Exception: catches almost everything (but not KeyboardInterrupt or SystemExit, which inherit directly from BaseException — deliberately). Always catch the most specific exception you can.
Python's exception hierarchy
In Python, all exceptions are objects that inherit from BaseException. Most exceptions you'll deal with inherit from Exception (which inherits from BaseException). System-exiting exceptions (SystemExit, KeyboardInterrupt, GeneratorExit) inherit directly from BaseException — they should almost never be caught.
| Exception | When it occurs |
|---|---|
ValueError | Right type, wrong value: int("hello") |
TypeError | Wrong type: "3" + 5 |
NameError | Undefined name: print(x) before assigning x |
IndexError | Out-of-bounds list access: lst[100] |
KeyError | Missing dict key: d["missing"] |
AttributeError | No such attribute: None.name |
FileNotFoundError | File does not exist |
ZeroDivisionError | 10 / 0 |
StopIteration | Iterator exhausted (normal — do not catch) |
ImportError | Module not found |
The four clauses and when each runs: try — the risky code. except — only if an exception was raised. else — only if NO exception was raised. finally — always, exception or not. The else clause is often forgotten but valuable: it's where you put code that should only run on success.
try / except / else / finally
# Basic try/except
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
# Multiple except clauses
def parse_number(text):
try:
return int(text)
except ValueError:
try:
return float(text)
except ValueError:
return None
# Catch multiple exceptions in one clause
try:
data = int(input("Enter a number: "))
except (ValueError, EOFError) as e:
print(f"Input error: {e}")
# else: runs only if no exception was raised
# finally: ALWAYS runs — cleanup code
def read_file(path):
f = None
try:
f = open(path)
return f.read()
except FileNotFoundError:
return None
else:
print("File read successfully") # only if no exception
finally:
if f: f.close() # ALWAYS closes the file
# Better: use context manager
def read_file_clean(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
return NoneRaise exceptions early and informatively. When your function receives bad input, don't silently ignore it or return None. Raise a specific exception with a clear message: raise ValueError(f"Score must be 0-100, got {score}"). The person calling your function will thank you — they get a clear error instead of a mysterious failure later.
Raising exceptions
# Raise a built-in exception
def divide(a, b):
if b == 0:
raise ZeroDivisionError("b cannot be zero")
return a / b
# Custom exception — inherit from Exception
class InsufficientFundsError(Exception):
def __init__(self, requested, available):
self.requested = requested
self.available = available
super().__init__(
f"Requested ₹{requested:,} but only ₹{available:,} available"
)
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(amount, balance)
return balance - amount
try:
withdraw(1000, 2000)
except InsufficientFundsError as e:
print(e) # Requested ₹2,000 but only ₹1,000 available
print(e.requested) # 2000
print(e.available) # 1000
# Re-raise — preserve original exception
try:
result = 1 / 0
except ZeroDivisionError:
print("Handling...")
raise # re-raises the same exception
# Exception chaining (Python 3)
try:
1 / 0
except ZeroDivisionError as original:
raise ValueError("Bad input") from original # chains context✅ Beginner tab complete — check your understanding
- I can wrap risky code in try/except and handle the error gracefully
- I know the 5 most common exception types: TypeError, ValueError, KeyError, IndexError, FileNotFoundError
- I can use the else clause (runs if no exception) and finally clause (always runs)
- I can raise my own exception with raise ValueError("message")
Context managers, custom exceptions, and exception groups
What you'll learn: The with statement (context managers) — the Python way to ensure cleanup always happens. How to write custom exception classes for your own code. Exception groups (Python 3.11+) for handling multiple simultaneous errors.
How to read this tab: The context manager section is essential for professional Python. After reading it, understand why with open(...) as f: is the only correct way to open files.
The with statement is essential Python. It guarantees that cleanup happens even if an exception occurs. Files, database connections, network sockets, locks — anything that needs to be closed or released should use a context manager. with open() is just the most common example. Any object with __enter__ and __exit__ methods works.
Context managers and the with statement
A context manager implements __enter__ (called on entry, can return a value bound by as) and __exit__ (called on exit, receives exception info if one occurred). If __exit__ returns truthy, the exception is suppressed. This is how with open(...) guarantees the file closes even if an exception is raised inside the block.
# Custom context manager using a class
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.perf_counter() - self.start
print(f"Elapsed: {self.elapsed:.4f}s")
return False # do not suppress exceptions
with Timer() as t:
sum(range(1_000_000))
# Elapsed: 0.0432s
# contextlib.contextmanager — generator-based context manager
from contextlib import contextmanager, suppress
@contextmanager
def managed_resource():
print("Acquiring resource")
resource = {"status": "active"}
try:
yield resource # body of the with block runs here
finally:
resource["status"] = "closed"
print("Released resource")
with managed_resource() as r:
print(r["status"]) # active
# Released resource
# suppress — silently ignore specific exceptions
with suppress(FileNotFoundError):
open("nonexistent.txt")
# No error raisedFor handling multiple simultaneous exceptions. Exception groups (PEP 654) let you raise and handle multiple unrelated exceptions that occurred at the same time — common in concurrent code where several tasks may fail independently. The except* syntax is specifically for this. If you're not using Python 3.11+ or concurrent code, skip this for now.
Exception groups (Python 3.11+)
PEP 654 (Python 3.11) introduced ExceptionGroup for handling multiple concurrent exceptions — essential for async code where many tasks may fail simultaneously. The except* syntax handles exception groups.
# Python 3.11+
import asyncio
async def failing_task(n):
raise ValueError(f"Task {n} failed")
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(failing_task(1))
tg.create_task(failing_task(2))
# If multiple tasks fail, raises ExceptionGroup
try:
asyncio.run(main())
except* ValueError as eg:
for exc in eg.exceptions:
print(exc) # Task 1 failed, Task 2 failed
# Create and handle manually
try:
raise ExceptionGroup("multiple errors", [
ValueError("bad value"),
TypeError("bad type"),
])
except* ValueError as eg:
print(f"ValueErrors: {eg.exceptions}")
except* TypeError as eg:
print(f"TypeErrors: {eg.exceptions}")except:. except: without a type catches everything — including KeyboardInterrupt, SystemExit, and programming errors. Use except Exception: at the minimum, or better, name specific exceptions. Catching everything silences bugs.StopIteration outside a generator, or raising exceptions in normal code paths, is an anti-pattern that harms performance and readability.finally runs even if there is no exception. finally also runs if the try block has a return, break, or continue. The finally block runs before the enclosing scope's return — and if finally itself has a return, it overrides the try block's return value.✅ Intermediate tab complete — check your understanding
- I understand what __enter__ and __exit__ do and why the with statement calls them
- I can write a custom exception class that inherits from Exception
- I know when to catch a specific exception vs catching Exception base class
- I know never to use bare except: (always specify an exception type)
CPython's zero-cost exceptions and PEP 3151
What you'll learn: How CPython implements exceptions as a zero-cost-unless-raised mechanism. Exception chaining (raise X from Y). PEP 3151's restructuring of the OS error hierarchy. The performance implications of exception-driven control flow.
How to read this tab: Read after Stage 2. Relevant when optimising hot code paths or understanding why exceptions should not be used for normal control flow.
Exception handling and the CPython implementation
In CPython, exception handling uses a zero-cost model at the bytecode level (since Python 3.11, PEP 654 and related changes): when no exception is raised, try/except blocks have essentially zero overhead — the interpreter does not set up any data structure on the hot path. When an exception is raised, CPython uses a combination of the exception table (generated by the compiler — a mapping from bytecode range to handler) and the exception value stored in the thread state. This is documented in CPython's ceval.c and the compiler module.
PEP 3151 — Reworking the OS and IO exception hierarchy
Python 3.3 (PEP 3151) unified the OS error hierarchy. Before 3.3, you had to check errno to distinguish ENOENT from EACCES. After 3.3, there are specific exception classes: FileNotFoundError, PermissionError, IsADirectoryError, TimeoutError, etc. — all subclasses of OSError. This is why you can write except FileNotFoundError instead of except OSError as e: if e.errno == errno.ENOENT:.
✅ Expert tab complete
- I know that Python's exceptions have zero overhead when NOT raised
- I can use raise X from Y to chain exceptions and preserve context
- I know the difference between __cause__ (explicit chaining) and __context__ (implicit chaining)