Error Handling¶
What You'll Learn
- ✅ What exceptions are and how Python raises them
- ✅ How to use
try,except,else, andfinally - ✅ Catching specific vs multiple vs all exceptions
- ✅ How to raise exceptions manually with
raise - ✅ Creating custom exception classes
- ✅ Exception chaining and the
__cause__/__context__attributes - ✅ Best practices for writing robust, readable error handling code
When something goes wrong at runtime, Python doesn't just crash silently — it raises an exception, an object that describes what went wrong and where. Your job is to decide which exceptions to expect, how to recover from them, and when to let them bubble up.
New to Python?
Think of exceptions like a fire alarm. When something breaks, Python "pulls the alarm" (raises an exception). A try/except block is your fire drill plan — you decide what to do when the alarm goes off instead of letting the building burn down.
Already know Python?
Focus on Exception Chaining, Custom Exceptions, and the Best Practices section — these are the areas most developers skip and then regret later.
Keep in mind
Catching too broadly (e.g. except Exception) can silently swallow bugs. Always catch the most specific exception you can handle.
How Exception Handling Works¶
graph TD
A[try block executes] --> B{Exception raised?}
B -->|No| C[else block runs]
B -->|Yes| D{Matching except?}
D -->|Yes| E[except block handles it]
D -->|No| F[Exception propagates up call stack]
C --> G[finally block runs]
E --> G
F --> G
G --> H{Was exception handled?}
H -->|Yes| I[Program continues]
H -->|No| J[Program crashes with traceback]
1️⃣ Basic try / except¶
Wrap risky code in a try block; handle the failure in except:
Output:
Output:
The as e alias
except ValueError as e binds the exception object to e. You can inspect e for the error message, type, and traceback. Always name it e or something descriptive.
2️⃣ The Full try / except / else / finally Block¶
try:
file = open("data.txt", "r")
content = file.read()
except FileNotFoundError:
print("File not found.")
else:
# Runs only if NO exception was raised in try
print(f"Read {len(content)} characters.")
finally:
# ALWAYS runs — exception or not
print("Done attempting file read.")
Output (file missing):
Output (file exists):
Block execution order
─────────────────────────────────────────────────
try → always attempted
except → only if exception raised in try
else → only if NO exception in try
finally → ALWAYS (cleanup code goes here)
─────────────────────────────────────────────────
When to use else
Put code that should run only on success in else, not at the end of try. This makes it explicit that the code isn't protected by the except clause.
3️⃣ Catching Multiple Exceptions¶
Handle different exceptions differently — the most readable approach:
Handle multiple exceptions the same way:
Order matters
Python checks except clauses top to bottom and uses the first match. Always put specific exceptions before general ones. Putting Exception first would catch everything and skip your specific handlers.
4️⃣ Common Built-in Exceptions¶
Exception Hierarchy (simplified)
─────────────────────────────────────────────────────────
BaseException
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── ValueError
├── TypeError
├── AttributeError
├── NameError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── TimeoutError
├── RuntimeError
├── StopIteration
└── ImportError
└── ModuleNotFoundError
─────────────────────────────────────────────────────────
| Exception | When it's raised |
|---|---|
ValueError |
Right type, wrong value: int("abc") |
TypeError |
Wrong type entirely: "a" + 1 |
KeyError |
Dict key doesn't exist: d["missing"] |
IndexError |
List index out of range: lst[99] |
AttributeError |
Attribute doesn't exist: None.strip() |
FileNotFoundError |
File path doesn't exist |
ZeroDivisionError |
Division or modulo by zero |
ImportError |
Module can't be imported |
StopIteration |
Iterator has no more items |
RuntimeError |
Generic runtime failure |
5️⃣ Raising Exceptions with raise¶
You can raise exceptions yourself to signal invalid states:
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"Age must be an int, got {type(age).__name__}")
if age < 0 or age > 150:
raise ValueError(f"Age {age} is out of valid range (0–150)")
return age
set_age(-5)
Output:
Re-raising an exception¶
try:
risky()
except ValueError as e:
log_error(e)
raise # re-raises the SAME exception without losing the traceback
Bare raise vs raise e
Use bare raise (no argument) to re-raise the current exception — it preserves the original traceback. raise e creates a new traceback starting at that line, making debugging harder.
6️⃣ assert Statements¶
Assertions are quick sanity checks — they raise AssertionError if the condition is False:
def calculate_discount(price, discount):
assert 0 <= discount <= 1, f"Discount must be between 0 and 1, got {discount}"
return price * (1 - discount)
calculate_discount(100, 1.5)
Output:
Assertions are for development, not validation
Python can be run with -O (optimise) flag which disables all assertions. Never use assert for input validation in production code — use raise ValueError instead. Use assert for internal invariants and debugging.
7️⃣ Custom Exceptions¶
Define your own exception classes by inheriting from Exception (or a more specific built-in):
# Define custom exceptions
class AppError(Exception):
"""Base class for all application exceptions."""
pass
class DatabaseError(AppError):
"""Raised when a database operation fails."""
def __init__(self, message, query=None):
super().__init__(message)
self.query = query
class AuthenticationError(AppError):
"""Raised when user authentication fails."""
pass
# Using custom exceptions
def get_user(user_id):
query = f"SELECT * FROM users WHERE id = {user_id}"
result = db.execute(query)
if not result:
raise DatabaseError("User not found", query=query)
return result
try:
user = get_user(999)
except DatabaseError as e:
print(f"DB Error: {e}")
print(f"Failed query: {e.query}")
except AppError as e:
print(f"App Error: {e}")
Output:
Custom exception hierarchy
Always create a base AppError class for your project. Callers can catch AppError to handle any app-level failure, or catch specific subclasses for fine-grained handling.
Custom Exception Hierarchy
──────────────────────────────────
AppError (your base)
├── DatabaseError
│ ├── ConnectionError
│ └── QueryError
├── AuthenticationError
└── ValidationError
──────────────────────────────────
Catch specific → broad as needed
8️⃣ Exception Chaining¶
When you catch an exception and raise a new one, Python can link them with raise ... from:
def load_config(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
raise RuntimeError(f"Config file missing: {path}") from e
Output (when file missing):
FileNotFoundError: [Errno 2] No such file or directory: 'config.json'
The above exception was the direct cause of the following exception:
RuntimeError: Config file missing: config.json
When to use exception chaining
Chain exceptions when you're wrapping a low-level error into a higher-level one. It gives callers both the context ("what went wrong at my level") and the root cause ("why it went wrong underneath").
9️⃣ Context Managers and Exception Safety¶
Use with statements for resources that must be cleaned up even if an exception occurs:
# Without context manager — unsafe
f = open("data.txt")
data = f.read() # If this raises, f.close() never runs
f.close()
# With context manager — safe
with open("data.txt") as f:
data = f.read() # f.close() guaranteed even on exception
# Multiple context managers
with open("input.txt") as src, open("output.txt", "w") as dst:
dst.write(src.read().upper())
Creating your own context manager¶
from contextlib import contextmanager
@contextmanager
def managed_transaction(db):
try:
yield db.connection()
db.commit()
except Exception:
db.rollback()
raise # re-raise after cleanup
finally:
db.close()
with managed_transaction(db) as conn:
conn.execute("INSERT INTO ...")
contextlib.suppress
Cleanly ignore specific exceptions without a try/except:
🔟 Best Practices¶
# ✅ Catch specific exceptions
try:
value = config["timeout"]
except KeyError:
value = 30 # sensible default
# ❌ Too broad — hides real bugs
try:
value = config["timeout"]
except Exception:
value = 30
# ✅ Use finally for cleanup
conn = None
try:
conn = db.connect()
conn.execute(query)
except DatabaseError as e:
log.error(e)
finally:
if conn:
conn.close()
# ✅ Better — use a context manager instead
with db.connect() as conn:
conn.execute(query)
# ✅ Include useful context in exception messages
raise ValueError(
f"Expected positive integer for 'retries', got {retries!r}"
)
# ❌ Vague — unhelpful for debugging
raise ValueError("Invalid value")
# ✅ Don't silence exceptions without logging
try:
send_notification(user)
except NotificationError as e:
logger.warning("Notification failed for user %s: %s", user.id, e)
# intentionally not re-raising — notifications are non-critical
# ❌ Silent swallow — bugs disappear
try:
send_notification(user)
except Exception:
pass
Never catch BaseException or KeyboardInterrupt silently
KeyboardInterrupt (Ctrl+C) and SystemExit inherit from BaseException, not Exception. Catching BaseException will trap those too, making your program unresponsive to user interruption.
✅ Quick Reference Summary¶
| Keyword / Pattern | Purpose |
|---|---|
try: ... except E: |
Catch exception E |
except (E1, E2) as e: |
Catch multiple exceptions the same way |
else: |
Runs only if try succeeded |
finally: |
Always runs — use for cleanup |
raise ValueError("msg") |
Raise a specific exception |
raise (bare) |
Re-raise current exception, preserving traceback |
raise NewErr() from e |
Chain exceptions explicitly |
raise NewErr() from None |
Suppress original exception in traceback |
assert cond, "msg" |
Dev-time sanity check (disabled with -O) |
class MyError(Exception) |
Define a custom exception |
with suppress(E): |
Silently ignore a specific exception |
@contextmanager |
Create a context manager with yield |