Python Exception Handling: The Complete Beginner's Guide
This chapter teaches robust error handling with \`try/except/else/finally\`, \`raise\`, custom exceptions, chaining, and practical patterns so programs fail gracefully.
Chapter 15 of 20 · Intermediate · 40 min · Python Programming Course
Every real program eventually encounters invalid input, missing files, network failures, or unexpected states. Exception handling lets your code respond gracefully instead of crashing.
What You Will Learn in This Chapter
By the end of this tutorial you will be able to:
- Understand what exceptions are and why they happen
- Use
try,except,else, andfinallycorrectly - Catch specific exceptions and avoid broad catches
- Access exception objects using
as - Raise exceptions intentionally with
raise - Define custom exception classes
- Chain exceptions with
raise ... from - Apply practical exception-handling patterns
- Decide when to handle and when to propagate
- Avoid common exception-handling mistakes
Estimated time: 40 minutes reading + 20 minutes practice
What Is an Exception?
An exception is an object raised when execution cannot continue normally. Python searches for a matching handler up the call stack.
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
Exception Hierarchy
BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception
├── ValueError
├── TypeError
├── NameError
├── AttributeError
├── IndexError
├── KeyError
├── FileNotFoundError
├── PermissionError
└── OSError
Catch specific exceptions whenever possible.
try/except/else/finally
try:
value = int(input("Enter a number: "))
result = 100 / value
except ValueError:
print("Not a valid integer")
except ZeroDivisionError:
print("Division by zero is not allowed")
else:
print(f"Result: {result}")
finally:
print("Done")
elseruns only on success.finallyalways runs (great for cleanup).
Catching Multiple Exceptions
def to_int(value, default=None):
try:
return int(value)
except (ValueError, TypeError):
return default
Accessing Exception Details
try:
with open("config.json") as f:
data = f.read()
except FileNotFoundError as e:
print(f"Missing file: {e.filename}")
Raising Exceptions
def set_age(age):
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0:
raise ValueError("Age cannot be negative")
Re-raising and Chaining
def load_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError as e:
raise RuntimeError("Configuration load failed") from e
Use bare raise inside an except block when you need to log and propagate unchanged.
Custom Exceptions
class RegistrationError(Exception):
pass
class StudentNotFoundError(RegistrationError):
def __init__(self, student_id):
self.student_id = student_id
super().__init__(f"Student not found: {student_id}")
Custom exceptions make domain errors explicit and easier to handle.
Practical Patterns
Input validation loop
def get_valid_integer(prompt):
while True:
try:
return int(input(prompt))
except ValueError:
print("Please enter a whole number")
Retry logic
import time
def with_retry(fn, attempts=3):
last = None
for i in range(attempts):
try:
return fn()
except Exception as e:
last = e
if i < attempts - 1:
time.sleep(0.5 * (2 ** i))
raise last
A Complete Working Program
Student registration system with custom exceptions:
class RegistrationError(Exception):
pass
class StudentNotFoundError(RegistrationError):
def __init__(self, student_id):
self.student_id = student_id
super().__init__(f"Student not found: {student_id}")
class AlreadyEnrolledError(RegistrationError):
pass
class RegistrationSystem:
def __init__(self):
self.students = {}
def add_student(self, sid, name):
if sid in self.students:
raise RegistrationError(f"Student ID {sid} already exists")
self.students[sid] = {"name": name, "courses": []}
return f"{name} added"
def enroll(self, sid, course):
if sid not in self.students:
raise StudentNotFoundError(sid)
student = self.students[sid]
if course in student["courses"]:
raise AlreadyEnrolledError(f"{student['name']} already in {course}")
student["courses"].append(course)
return f"{student['name']} enrolled in {course}"
5 Exception Handling Mistakes
- Using bare
except: - Swallowing errors silently with
pass - Putting too much code inside one try block
- Using exceptions for normal control flow
- Losing context instead of using
raise ... from
Practice Exercises
- Date parser with robust validation
- Safe CSV processor with row-level error reporting
- Custom bank exception hierarchy
- Retry decorator implementation
- Safe execution wrapper with structured results
-> See all Python practice exercises with solutions
What Comes Next - Day 16: Advanced Data Structures
Next you will focus on efficient structures:
collections.dequecollections.Countercollections.defaultdict- named tuples
- heaps with
heapq - choosing the right structure for performance
-> Continue to Day 16: Advanced Data Structures
Frequently Asked Questions
What is exception handling in Python?
Exception handling is the mechanism for managing runtime errors without crashing a program.
Difference between try, except, else, and finally?
try holds risky code, except handles errors, else runs on success, and finally always runs.
Difference between raise and raise ... from?
raise signals an error; raise ... from preserves causal context between exceptions.
Should I use bare except?
Almost never. Catch specific exceptions to avoid masking bugs and control-flow signals.
What are custom exceptions?
Application-specific exception classes representing meaningful domain errors.
What is exception chaining?
Linking a new exception to the original cause using raise NewError(...) from e.
How do I decide what to catch?
Catch only exceptions you can meaningfully recover from.
Difference between errors and exceptions?
In practice, both are exception objects; “error” is usually a conceptual label.
Chapter navigation
- Previous: Day 14: File Handling
- Next: Day 16: Advanced Data Structures
- Python Quiz: Take the Python quiz
- All Python exercises: Explore Python exercises
Context Managers and the with Statement
A context manager is the correct, Pythonic way to work with resources that need setup and teardown. The with statement calls __enter__ on entry and __exit__ on exit — even if an exception occurs.
You've already used one: with open('file.txt') as f:
Writing Your Own Context Manager
# Method 1: Class-based context manager
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # bound to 'as' variable
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.perf_counter() - self.start
print(f"Elapsed: {self.elapsed:.3f}s")
return False # Don't suppress exceptions
with Timer() as t:
sum(range(10_000_000))
print(f"Took {t.elapsed:.3f}s")
# Method 2: contextlib.contextmanager decorator (much simpler)
from contextlib import contextmanager
@contextmanager
def db_transaction(conn):
"""Automatically commit or rollback database transactions."""
try:
yield conn.cursor() # Code before yield = __enter__
conn.commit()
except Exception:
conn.rollback()
raise # Re-raise after rollback
suppress — Silently Ignore Specific Exceptions
from contextlib import suppress
# Instead of try/except/pass:
with suppress(FileNotFoundError):
os.remove('temp.txt')
Python Exception Hierarchy
BaseException
├── SystemExit ← sys.exit() — do NOT catch in normal code
├── KeyboardInterrupt ← Ctrl+C — do NOT catch unless intentional
└── Exception ← Catch at most this level
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── TimeoutError
├── ValueError
├── TypeError
├── AttributeError
├── ImportError
│ └── ModuleNotFoundError
└── RuntimeError
└── RecursionError
Practical rule: Be as specific as possible. Catching LookupError catches both IndexError and KeyError. Catching bare Exception is usually too broad.
Logging Instead of Print
In production code, use the logging module rather than print for error reporting:
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s — %(message)s',
)
logger = logging.getLogger(__name__)
def parse_config(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
logger.warning("Config file %s not found; using defaults", path)
return {}
except json.JSONDecodeError as e:
logger.error("Invalid JSON in %s: %s", path, e, exc_info=True)
raise RuntimeError(f"Config parse failed: {path}") from e
The exc_info=True argument logs the full traceback automatically.
Real-World Exception Handling Patterns
Pattern 1: Repository / Data Layer
class UserRepository:
def get_by_id(self, user_id: int):
try:
row = self.db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
if row is None:
raise UserNotFoundError(f"User {user_id} does not exist")
return User.from_row(row)
except DatabaseConnectionError as e:
logger.critical("DB connection lost", exc_info=True)
raise ServiceUnavailableError("Database unavailable") from e
except sqlite3.Error as e:
logger.error("DB query failed for user_id=%d: %s", user_id, e)
raise DataAccessError("Query failed") from e
Pattern 2: HTTP Client with Structured Errors
import urllib.request
import json
class ApiClient:
def get(self, url: str) -> dict:
try:
with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
raise ResourceNotFoundError(url) from e
if e.code == 429:
raise RateLimitError("Too many requests") from e
raise ApiError(f"HTTP {e.code}") from e
except urllib.error.URLError as e:
raise NetworkError(f"Cannot reach {url}") from e
except json.JSONDecodeError as e:
raise ApiError("Invalid JSON response") from e
Pattern 3: Input Validation with Custom Exceptions
class ValidationError(ValueError):
def __init__(self, field: str, message: str):
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
class MultiValidationError(Exception):
def __init__(self, errors: list[ValidationError]):
self.errors = errors
super().__init__(f"{len(errors)} validation error(s)")
def validate_user_data(data: dict) -> None:
errors = []
if not data.get('email') or '@' not in data['email']:
errors.append(ValidationError('email', 'Must be a valid email address'))
if len(data.get('password', '')) < 8:
errors.append(ValidationError('password', 'Must be at least 8 characters'))
age = data.get('age')
if age is not None and not isinstance(age, int):
errors.append(ValidationError('age', 'Must be an integer'))
if errors:
raise MultiValidationError(errors)
Decision Tree: Raise, Handle, or Propagate?
Should I handle this exception?
├── Can I recover meaningfully? → Yes → Handle it, return a good value or retry
├── Does the caller need to know? → Yes → Propagate (re-raise as-is or wrap)
├── Is it a programming error (TypeError, AttributeError)? → Let it crash visibly
└── Is it expected infrastructure noise (network, retry-able)? → Retry + log warning
Golden rules:
- Handle exceptions at the layer that has enough context to recover or explain
- Never catch an exception just to log it and re-raise without adding value
- Domain layers (service, repo) should throw domain exceptions, not raw DB/OS exceptions
Testing Exception Handling
import pytest
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
# pytest — testing that exceptions are raised
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"):
divide(10, 0)
def test_divide_normal():
assert divide(10, 2) == 5.0
# Testing custom exceptions
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Balance {balance} insufficient for withdrawal {amount}")
def test_insufficient_funds():
with pytest.raises(InsufficientFundsError) as exc_info:
raise InsufficientFundsError(100, 200)
assert exc_info.value.balance == 100
assert exc_info.value.amount == 200
# unittest — assertRaises
import unittest
class TestDivide(unittest.TestCase):
def test_zero(self):
self.assertRaises(ZeroDivisionError, divide, 10, 0)
def test_context_manager(self):
with self.assertRaises(ZeroDivisionError):
divide(10, 0)
ExceptionGroup (Python 3.11+)
Python 3.11 introduced ExceptionGroup for handling multiple simultaneous exceptions — particularly useful with async code:
# Raise a group of exceptions
def validate_all(fields):
errors = []
for field, value in fields.items():
if not value:
errors.append(ValueError(f"{field} is required"))
if errors:
raise ExceptionGroup("Validation failed", errors)
# Catch with except* (Python 3.11+)
try:
validate_all({"name": "", "email": "", "age": "25"})
except* ValueError as eg:
for err in eg.exceptions:
print(f"Validation error: {err}")
Frequently asked questions: Exception handling
What is exception handling in Python?
Exception handling is Python’s mechanism to catch runtime errors and respond gracefully instead of crashing.
What is the difference between try, except, else, and finally?
try contains risky code, except handles matching errors, else runs only if no error occurred, and finally always runs.
What is the difference between raise and raise ... from?
raise signals an exception; raise ... from links a new exception to an original cause to preserve debugging context.
When should I use a bare except clause?
Almost never. Bare except catches too much, including interrupts and system-exit signals.
What are custom exceptions in Python?
Custom exceptions are application-specific classes derived from Exception to model domain-specific error conditions.
What is exception chaining in Python?
Exception chaining connects related failures using raise NewError(...) from original_error.
How do I know which exceptions to catch?
Catch the most specific exceptions you can recover from and let others propagate.
What is the difference between errors and exceptions in Python?
Practically all runtime errors are represented as exception objects; the distinction is mostly conceptual.
Related Page
Day 14: File Handling
Learn about day 14: file handling
Learn MoreDay 16: Advanced Data Structures
Continue with day 16: advanced data structures
Learn MorePython exercises hub
165+ programs with solutions
Learn MorePython quiz
Multiple-choice checks with explanations
Learn MoreBasic Python programs
Exception handling practice programs
Learn More