Skip to main content
Python intermediate Lesson 12 of 28

Error Handling in Python

Handle exceptions gracefully with try/except/finally, create custom exceptions, and use logging and traceback effectively.

The Exception Hierarchy

Python’s built-in exceptions form a class hierarchy. Catching a parent class catches all its children.

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── ArithmeticError (ZeroDivisionError, OverflowError)
    ├── LookupError (IndexError, KeyError)
    ├── ValueError
    ├── TypeError
    ├── AttributeError
    ├── FileNotFoundError
    ├── OSError
    └── RuntimeError

try / except / else / finally

def read_config(path: str) -> dict:
    try:
        with open(path) as f:
            import json
            return json.load(f)
    except FileNotFoundError:
        print(f"Config file not found: {path}")
        return {}
    except json.JSONDecodeError as e:
        print(f"Invalid JSON in {path}: {e}")
        return {}
    else:
        # Runs only if no exception was raised
        print("Config loaded successfully")
    finally:
        # Always runs — cleanup goes here
        print("Done reading config")

Catching Multiple Exceptions

try:
    result = int(user_input) / divisor
except (ValueError, ZeroDivisionError) as e:
    print(f"Input error: {e}")

Re-raising Exceptions

def process_payment(amount):
    try:
        charge_card(amount)
    except TimeoutError:
        log_failure("payment timeout")
        raise  # re-raise the original exception with original traceback

# Or raise a different exception, chaining the original
def load_user(user_id):
    try:
        return db.query(user_id)
    except DatabaseError as e:
        raise ValueError(f"Invalid user ID: {user_id}") from e

Custom Exceptions

Define custom exceptions to give callers precise error types to handle:

class AppError(Exception):
    """Base class for application exceptions."""
    pass

class ValidationError(AppError):
    def __init__(self, field: str, message: str):
        self.field = field
        self.message = message
        super().__init__(f"{field}: {message}")

class NotFoundError(AppError):
    def __init__(self, resource: str, id_: int):
        self.resource = resource
        self.id = id_
        super().__init__(f"{resource} with id={id_} not found")

# Usage
def get_user(user_id: int):
    if user_id <= 0:
        raise ValidationError("user_id", "must be positive")
    user = db.find(user_id)
    if user is None:
        raise NotFoundError("User", user_id)
    return user

# Caller handles specific cases
try:
    user = get_user(-1)
except ValidationError as e:
    return {"error": str(e), "field": e.field}, 400
except NotFoundError as e:
    return {"error": str(e)}, 404

Exception Groups (Python 3.11+)

Handle multiple concurrent exceptions from async tasks:

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_data())
        tg.create_task(fetch_metadata())
except* ValueError as eg:
    for exc in eg.exceptions:
        print(f"Value error: {exc}")
except* TimeoutError as eg:
    print(f"{len(eg.exceptions)} tasks timed out")

Logging vs print

Never use print() for errors in production code. Use the logging module — it gives you levels, timestamps, file output, and structured context.

import logging

# Configure once at application startup
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    handlers=[
        logging.StreamHandler(),                        # stderr
        logging.FileHandler("app.log", encoding="utf-8"),  # file
    ]
)

logger = logging.getLogger(__name__)

def process_order(order_id: int):
    logger.info("Processing order %d", order_id)
    try:
        result = do_work(order_id)
        logger.debug("Order %d result: %s", order_id, result)
        return result
    except Exception:
        logger.exception("Failed to process order %d", order_id)
        raise

logger.exception() logs at ERROR level and automatically appends the full traceback — the most useful single call for error reporting.

Structured Logging with structlog

For production services, structured (JSON) logs are easier to query in tools like Datadog or CloudWatch:

import structlog

log = structlog.get_logger()

log.info("order.processed", order_id=42, amount=99.99, duration_ms=123)
# {"event": "order.processed", "order_id": 42, "amount": 99.99, "duration_ms": 123}

traceback Module

import traceback

try:
    1 / 0
except ZeroDivisionError:
    # Get traceback as string
    tb_str = traceback.format_exc()
    print(tb_str)

    # Print to stderr
    traceback.print_exc()

    # Get structured traceback info
    exc_type, exc_value, exc_tb = sys.exc_info()
    frames = traceback.extract_tb(exc_tb)
    for frame in frames:
        print(f"{frame.filename}:{frame.lineno} in {frame.name}")

Context Managers for Cleanup

The contextlib.suppress context manager silently ignores specific exceptions:

from contextlib import suppress
import os

# Instead of try/except just for cleanup:
with suppress(FileNotFoundError):
    os.remove("temp_file.txt")

Best Practices

Be specific — catch the narrowest exception type that makes sense:

# Too broad — hides bugs
try:
    result = process(data)
except Exception:
    return None

# Better
try:
    result = process(data)
except (ValueError, KeyError) as e:
    logger.warning("Invalid data: %s", e)
    return None

Don’t swallow exceptions silently. At minimum, log them:

# Silent swallow — very bad
try:
    send_email(user)
except Exception:
    pass

# Acceptable
try:
    send_email(user)
except Exception:
    logger.exception("Failed to send email to %s", user.email)

Use finally for resource cleanup — or better, use context managers (with statements) which call __exit__ automatically.

Add context when re-raising:

try:
    parse_config(path)
except json.JSONDecodeError as e:
    raise ConfigError(f"Malformed config at {path}") from e

The from e preserves the original exception as __cause__, which Python displays in the traceback.

Frequently Asked Questions

Should I catch Exception broadly?
Avoid bare 'except:' and broad 'except Exception:' catches in production code. Catch the most specific exception type you expect so unexpected errors aren't silently swallowed.
When should I raise vs return an error?
Raise exceptions for unexpected conditions that the caller needs to handle. Return error values (like None or a Result type) for expected, recoverable outcomes.
What's the difference between except and finally?
except runs only when a specific exception occurs. finally always runs — whether the try block succeeded, raised, or hit a return statement.