🔵 Intermediate

Exceptions & Error Handling

📖 Lesson 15 ⏱ 35 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand what exceptions are and why Python uses them
  • Use try / except / else / finally correctly
  • 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
Exceptions are objects — instances of classes that inherit from 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
Never catch 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
ClauseRuns whenUse for
tryAlways (first)The code that might fail
exceptOnly if an exception matchedHandling / recovering from errors
elseOnly if NO exception was raisedCode that should run on success (keeps try block minimal)
finallyAlways (last) — even after returnCleanup — releasing resources, closing connections
Use 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
Exception chaining (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
Keep your custom exception hierarchy shallow and purposeful. One base class (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:

StyleStands forApproach
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
Python culture favours EAFP — it's often faster (no double lookup) and avoids race conditions. But LBYL is clearer when the check is cheap and the error handling would be complex. Use your judgement.

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 Exception as a catch-all.
  • Never use bare except: — it catches SystemExit and KeyboardInterrupt.
  • Keep try blocks 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, not raise 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 finally for cleanup, but prefer with statements (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
🤖

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

01 Safe Division Calculator

Write a function safe_divide(a, b) that:

  • Returns a / b if both are valid numbers and b != 0
  • Raises TypeError with a helpful message if either argument is not a number
  • Raises ValueError with a helpful message if b is 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()
02 Retry Decorator

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())
03 Custom Validation Exceptions

Build a mini user-registration validator:

  • Define a base RegistrationError(Exception) and subclasses UsernameError and PasswordError, each storing a field and reason attribute.
  • 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 single RegistrationError listing 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