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, and finally correctly
  • 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")
  • else runs only on success.
  • finally always 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

  1. Using bare except:
  2. Swallowing errors silently with pass
  3. Putting too much code inside one try block
  4. Using exceptions for normal control flow
  5. 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.deque
  • collections.Counter
  • collections.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

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.