🎯 Learning Objectives
- Understand what exceptions are and why Python uses them
- Use
try / except / else / finallycorrectly - Catch specific exceptions and access their details
- Raise exceptions intentionally and re-raise them cleanly
- Define and use custom exception classes
- Apply EAFP style and know when to use LBYL instead
What is an Exception?
An exception is an event that disrupts the normal flow of a program.
When Python encounters an error it cannot recover from automatically — dividing by zero,
opening a missing file, calling a method on None — it raises an
exception object and unwinds the call stack until something handles it,
or the program crashes with a traceback.
print(10 / 0)
# ZeroDivisionError: division by zero
name = None
print(name.upper())
# AttributeError: 'NoneType' object has no attribute 'upper'
items = [1, 2, 3]
print(items[10])
# IndexError: list index out of range
exception_examples.py
BaseException. This means they carry information (a message, a traceback,
even custom attributes) and can be caught, inspected, and re-raised.
The Exception Hierarchy
All built-in exceptions form an inheritance tree. Catching a parent class catches all its children:
BaseException
├── SystemExit ← raised by sys.exit()
├── KeyboardInterrupt ← Ctrl+C
├── GeneratorExit
└── Exception ← catch THIS for "normal" errors
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError (IOError)
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── TimeoutError
├── TypeError
├── ValueError
├── NameError
│ └── UnboundLocalError
├── AttributeError
├── RuntimeError
│ └── RecursionError
└── StopIteration
exception_hierarchy.txt
BaseException or bare except: in production code —
you'll accidentally swallow SystemExit and KeyboardInterrupt,
making your program impossible to stop. Always catch Exception or a more
specific subclass.
Basic try / except
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero!")
result = 0
print(result) # 0
basic_try.py
Catching multiple exception types
def parse_index(data, index_str):
try:
index = int(index_str) # may raise ValueError
return data[index] # may raise IndexError
except ValueError:
print(f"'{index_str}' is not a valid integer")
except IndexError:
print(f"Index {index_str} is out of range (len={len(data)})")
return None
items = ["a", "b", "c"]
parse_index(items, "1") # "b"
parse_index(items, "ten") # 'ten' is not a valid integer
parse_index(items, "99") # Index 99 is out of range
multiple_except.py
Catching multiple types in one handler
try:
value = int(input("Enter a number: "))
result = 100 / value
except (ValueError, ZeroDivisionError) as e:
print(f"Input error: {e}")
else:
print(f"100 / {value} = {result}")
tuple_except.py
The else and finally Clauses
try:
f = open("data.txt", "r")
content = f.read()
except FileNotFoundError:
print("File not found — using defaults")
content = ""
else:
# Runs ONLY if no exception was raised in the try block
print(f"File read successfully ({len(content)} chars)")
f.close()
finally:
# Runs ALWAYS — exception or not
print("Done attempting file read")
else_finally.py
| Clause | Runs when | Use for |
|---|---|---|
try | Always (first) | The code that might fail |
except | Only if an exception matched | Handling / recovering from errors |
else | Only if NO exception was raised | Code that should run on success (keeps try block minimal) |
finally | Always (last) — even after return | Cleanup — releasing resources, closing connections |
else to keep the try block minimal — only put the
code that can actually raise the exception there. Success-path code belongs in
else. This makes it crystal-clear what you expect to fail.
Accessing Exception Details
try:
open("missing.txt")
except FileNotFoundError as e:
print(type(e).__name__) # FileNotFoundError
print(e) # [Errno 2] No such file or directory: 'missing.txt'
print(e.args) # (2, 'No such file or directory')
print(e.filename) # missing.txt (OSError-specific attribute)
print(e.errno) # 2
exception_info.py
import traceback
try:
int("not a number")
except ValueError:
# Print the full traceback to stderr (same as unhandled exception output)
traceback.print_exc()
# Or capture it as a string
tb_str = traceback.format_exc()
print(tb_str)
traceback_info.py
Raising Exceptions
Use raise to signal that something has gone wrong from your own code:
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"age must be int, got {type(age).__name__}")
if age < 0 or age > 150:
raise ValueError(f"age must be between 0 and 150, got {age}")
return age
set_age(25) # fine
set_age("old") # TypeError: age must be int, got str
set_age(-1) # ValueError: age must be between 0 and 150, got -1
raise_basic.py
Re-raising an exception
import logging
def load_data(path):
try:
with open(path) as f:
return f.read()
except OSError as e:
logging.error("Failed to load %s: %s", path, e)
raise # re-raise the SAME exception, preserving the traceback
# Raise a different exception, chaining the original as cause
def process(path):
try:
data = load_data(path)
except OSError as e:
raise RuntimeError(f"Cannot process {path}") from e
# ^^^^^ exception chaining
re_raise.py
raise X from Y) preserves the
original exception as __cause__. Python displays both exceptions in
the traceback with "The above exception was the direct cause of the following exception."
Use raise X from None to suppress the chain when the original error
is an implementation detail.
Custom Exceptions
Define your own exception classes by subclassing Exception.
Custom exceptions let callers catch your library's errors specifically,
without also catching unrelated errors.
# Define a hierarchy for your application
class AppError(Exception):
"""Base class for all application errors."""
class ValidationError(AppError):
"""Raised when user input fails validation."""
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"Validation failed on '{field}': {message}")
class NotFoundError(AppError):
"""Raised when a requested resource cannot be found."""
def __init__(self, resource, identifier):
self.resource = resource
self.identifier = identifier
super().__init__(f"{resource} '{identifier}' not found")
# Using the custom exceptions
def get_user(user_id):
if not isinstance(user_id, int):
raise ValidationError("user_id", "must be an integer")
users = {1: "Alice", 2: "Bob"}
if user_id not in users:
raise NotFoundError("User", user_id)
return users[user_id]
try:
print(get_user(99))
except NotFoundError as e:
print(e) # User '99' not found
print(e.resource) # User
print(e.identifier) # 99
except ValidationError as e:
print(f"Bad input for {e.field}: {e.message}")
except AppError as e:
print(f"Application error: {e}")
custom_exceptions.py
AppError) lets callers catch all your errors with one handler;
specific subclasses let them handle individual cases precisely.
EAFP vs LBYL
There are two philosophies for dealing with potential errors:
| Style | Stands for | Approach |
|---|---|---|
| EAFP | Easier to Ask Forgiveness than Permission | Try it; catch the exception if it fails |
| LBYL | Look Before You Leap | Check preconditions before acting |
data = {"name": "Alice", "age": 30}
# ── LBYL (Look Before You Leap) ──
if "score" in data:
score = data["score"]
else:
score = 0
# ── EAFP (Easier to Ask Forgiveness than Permission) ──
try:
score = data["score"]
except KeyError:
score = 0
# EAFP shortcut for this specific case:
score = data.get("score", 0) # dict.get() is idiomatic Python
eafp_lbyl.py
from pathlib import Path
# ── LBYL — race condition risk! ──
# Another process might delete the file between the check and the open
if Path("data.txt").exists():
with open("data.txt") as f:
content = f.read()
# ── EAFP — safe and idiomatic ──
try:
with open("data.txt") as f:
content = f.read()
except FileNotFoundError:
content = ""
eafp_files.py
Common Patterns
Retry with back-off
import time
def retry(func, retries=3, delay=1.0, exceptions=(Exception,)):
"""Call func up to `retries` times; wait `delay` seconds between attempts."""
last_exc = None
for attempt in range(1, retries + 1):
try:
return func()
except exceptions as e:
last_exc = e
print(f"Attempt {attempt} failed: {e}")
if attempt < retries:
time.sleep(delay)
raise last_exc
# Usage
import random
def flaky():
if random.random() < 0.7:
raise ConnectionError("timeout")
return "success"
result = retry(flaky, retries=5, delay=0.1, exceptions=(ConnectionError,))
print(result)
retry.py
Suppress specific exceptions
from contextlib import suppress
# Silently ignore FileNotFoundError — equivalent to try/except/pass
with suppress(FileNotFoundError):
Path("temp.txt").unlink()
# Also works with multiple exception types
with suppress(KeyError, AttributeError):
value = data["key"].strip()
suppress.py
Convert exceptions at an API boundary
import json
class ConfigError(Exception):
pass
def load_config(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
raise ConfigError(f"Config file not found: {path}") from None
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid JSON in {path}: {e}") from e
# Callers only need to know about ConfigError, not OSError or JSONDecodeError
exception_boundary.py
Best Practices
- Be specific. Catch the most precise exception type you expect. Avoid
except Exceptionas a catch-all. - Never use bare
except:— it catchesSystemExitandKeyboardInterrupt. - Keep
tryblocks small. Only wrap the line(s) that can actually raise the exception. - Don't silence errors silently. If you catch an exception without acting on it, at least log it.
- Use
raise(bare) to re-raise, notraise e— the latter resets the traceback. - Use exception chaining (
raise X from Y) when converting exceptions at API boundaries. - Define a base exception class for your library/application so callers can catch all your errors with one handler.
- Use
finallyfor cleanup, but preferwithstatements (context managers) where possible.
# ❌ Too broad — hides bugs
try:
result = complex_operation()
except Exception:
pass # Silently swallowed!
# ❌ Bare except
try:
result = complex_operation()
except: # Catches EVERYTHING including KeyboardInterrupt
result = None
# ✓ Specific, logged, re-raised where appropriate
import logging
try:
result = complex_operation()
except ValueError as e:
logging.warning("Bad input to complex_operation: %s", e)
result = default_value
except RuntimeError:
logging.exception("Unexpected error in complex_operation")
raise # re-raise — let it propagate
best_practices.py
Primary sources: Python Docs — Errors and Exceptions · Python Docs — Built-in Exceptions · Python Docs — contextlib.suppress
Ask your AI tutor! Getting an unexpected traceback? Not sure which exception type to catch? Designing a custom exception hierarchy for your project? Talk it through — understanding tracebacks is one of the highest-leverage skills in Python.
💻 Exercises
Write a function safe_divide(a, b) that:
- Returns
a / bif both are valid numbers andb != 0 - Raises
TypeErrorwith a helpful message if either argument is not a number - Raises
ValueErrorwith a helpful message ifbis zero
Then write a main() that reads two numbers from the user (using
input()), calls safe_divide, and prints the result —
handling all exceptions gracefully with user-friendly messages.
Show solution
def safe_divide(a, b):
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeError(f"Both arguments must be numbers, got {type(a).__name__} and {type(b).__name__}")
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def main():
try:
a = float(input("Enter numerator: "))
b = float(input("Enter denominator: "))
result = safe_divide(a, b)
except ValueError as e:
print(f"Error: {e}")
except TypeError as e:
print(f"Type error: {e}")
else:
print(f"{a} / {b} = {result:.4f}")
if __name__ == "__main__":
main()
Write a decorator @retry(times=3, exceptions=(Exception,), delay=0)
that retries the decorated function up to times times whenever one of
the specified exceptions is raised. After all attempts are exhausted,
it should re-raise the last exception.
@retry(times=3, exceptions=(ValueError,), delay=0)
def unreliable():
import random
if random.random() < 0.7:
raise ValueError("random failure")
return "ok"
print(unreliable())
Show solution
import time
import functools
def retry(times=3, exceptions=(Exception,), delay=0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
print(f"[retry] attempt {attempt}/{times} failed: {e}")
if attempt < times and delay:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
import random
@retry(times=5, exceptions=(ValueError,), delay=0)
def unreliable():
if random.random() < 0.7:
raise ValueError("random failure")
return "ok"
print(unreliable())
Build a mini user-registration validator:
- Define a base
RegistrationError(Exception)and subclassesUsernameErrorandPasswordError, each storing afieldandreasonattribute. - Write
validate_username(name): must be 3–20 chars, alphanumeric + underscores only. - Write
validate_password(pwd): must be ≥8 chars, contain at least one digit. - Write
register(username, password)that calls both validators and catches/collects all validation errors (not just the first), then raises a singleRegistrationErrorlisting them all if any failed.
Show solution
import re
class RegistrationError(Exception):
pass
class UsernameError(RegistrationError):
def __init__(self, reason):
self.field = "username"
self.reason = reason
super().__init__(f"username: {reason}")
class PasswordError(RegistrationError):
def __init__(self, reason):
self.field = "password"
self.reason = reason
super().__init__(f"password: {reason}")
def validate_username(name):
if not (3 <= len(name) <= 20):
raise UsernameError("must be 3–20 characters")
if not re.fullmatch(r"[A-Za-z0-9_]+", name):
raise UsernameError("only letters, digits, and underscores allowed")
def validate_password(pwd):
if len(pwd) < 8:
raise PasswordError("must be at least 8 characters")
if not any(c.isdigit() for c in pwd):
raise PasswordError("must contain at least one digit")
def register(username, password):
errors = []
for validate, arg in [(validate_username, username), (validate_password, password)]:
try:
validate(arg)
except RegistrationError as e:
errors.append(str(e))
if errors:
raise RegistrationError("Registration failed:\n " + "\n ".join(errors))
return f"User '{username}' registered successfully"
# Test
try:
print(register("ab", "short"))
except RegistrationError as e:
print(e)
# Registration failed:
# username: must be 3–20 characters
# password: must be at least 8 characters
print(register("alice_99", "secure123"))
# User 'alice_99' registered successfully