Skip to main content
โšก Calmops

Exception Handling in Python: A Complete Guide

Introduction

Exception handling is how Python programs deal with errors gracefully โ€” catching problems at runtime, responding appropriately, and ensuring resources are cleaned up. Good exception handling is the difference between a program that crashes with a cryptic traceback and one that fails gracefully with a useful message.

Basic try/except

try:
    result = 1 / 0
except ZeroDivisionError as e:
    print(f"Error: {e}")
# => Error: division by zero

# Catch any exception (use sparingly)
try:
    x = int("abc")
except Exception as e:
    print(f"{type(e).__name__}: {e}")
# => ValueError: invalid literal for int() with base 10: 'abc'

Catching Specific Exceptions

Always catch the most specific exception you can:

def parse_config(filename):
    try:
        with open(filename) as f:
            return json.load(f)
    except FileNotFoundError:
        print(f"Config file not found: {filename}")
        return {}
    except json.JSONDecodeError as e:
        print(f"Invalid JSON in {filename}: {e}")
        return {}
    except PermissionError:
        print(f"No permission to read: {filename}")
        raise  # re-raise โ€” caller should handle this

Catching Multiple Exceptions

# Catch multiple exception types with a tuple
try:
    value = int(user_input)
except (ValueError, TypeError) as e:
    print(f"Invalid input: {e}")

# Or handle them separately
try:
    result = risky_operation()
except ValueError:
    handle_value_error()
except TypeError:
    handle_type_error()
except Exception as e:
    # Catch-all for unexpected errors
    logger.error(f"Unexpected error: {e}", exc_info=True)
    raise

else and finally

def read_file(path):
    f = None
    try:
        f = open(path)
        content = f.read()
    except FileNotFoundError:
        print(f"File not found: {path}")
        return None
    else:
        # Runs only if no exception was raised
        print(f"Successfully read {len(content)} bytes")
        return content
    finally:
        # Always runs โ€” perfect for cleanup
        if f:
            f.close()
            print("File closed")

The else block runs when the try block completes without raising an exception. The finally block always runs โ€” even if an exception was raised and not caught.

Context Managers (Preferred for Cleanup)

Use with statements instead of try/finally for resource management:

# Better than manual try/finally
with open("data.txt") as f:
    content = f.read()
# File is automatically closed, even if an exception occurs

# Multiple context managers
with open("input.txt") as infile, open("output.txt", "w") as outfile:
    for line in infile:
        outfile.write(line.upper())

Raising Exceptions

def divide(a, b):
    if b == 0:
        raise ValueError("Divisor cannot be zero")
    return a / b

def get_user(user_id):
    user = db.find(user_id)
    if user is None:
        raise LookupError(f"User {user_id} not found")
    return user

# Re-raise the current exception
try:
    risky()
except SomeError:
    log_error()
    raise  # re-raises the same exception with original traceback

Exception Chaining

Python 3 supports chaining exceptions to preserve context:

try:
    connect_to_database()
except ConnectionError as e:
    raise RuntimeError("Failed to initialize app") from e
    # The original ConnectionError is preserved as __cause__

# Suppress the original exception context
try:
    connect_to_database()
except ConnectionError:
    raise RuntimeError("Failed to initialize app") from None

Custom Exceptions

Define custom exceptions for your application’s error domain:

# Base exception for your application
class AppError(Exception):
    """Base class for application errors."""
    pass

# Specific error types
class ValidationError(AppError):
    def __init__(self, field, message):
        self.field = field
        self.message = message
        super().__init__(f"{field}: {message}")

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

class AuthenticationError(AppError):
    pass

class PermissionError(AppError):
    def __init__(self, action, resource):
        super().__init__(f"Not allowed to {action} {resource}")

# Usage
def create_user(data):
    if not data.get("email"):
        raise ValidationError("email", "is required")
    if "@" not in data["email"]:
        raise ValidationError("email", "is invalid")
    # ...

def get_post(post_id, current_user):
    post = Post.find(post_id)
    if post is None:
        raise NotFoundError("Post", post_id)
    if post.author_id != current_user.id:
        raise PermissionError("edit", "this post")
    return post

# Catch at the right level
try:
    post = get_post(42, user)
except NotFoundError as e:
    return {"error": str(e)}, 404
except PermissionError as e:
    return {"error": str(e)}, 403
except AppError as e:
    return {"error": str(e)}, 400

Exception Hierarchy

Python’s built-in exception hierarchy:

BaseException
โ”œโ”€โ”€ SystemExit
โ”œโ”€โ”€ KeyboardInterrupt
โ”œโ”€โ”€ GeneratorExit
โ””โ”€โ”€ Exception
    โ”œโ”€โ”€ ArithmeticError
    โ”‚   โ”œโ”€โ”€ ZeroDivisionError
    โ”‚   โ””โ”€โ”€ OverflowError
    โ”œโ”€โ”€ LookupError
    โ”‚   โ”œโ”€โ”€ IndexError
    โ”‚   โ””โ”€โ”€ KeyError
    โ”œโ”€โ”€ OSError
    โ”‚   โ”œโ”€โ”€ FileNotFoundError
    โ”‚   โ”œโ”€โ”€ PermissionError
    โ”‚   โ””โ”€โ”€ TimeoutError
    โ”œโ”€โ”€ ValueError
    โ”œโ”€โ”€ TypeError
    โ”œโ”€โ”€ AttributeError
    โ”œโ”€โ”€ NameError
    โ”œโ”€โ”€ RuntimeError
    โ””โ”€โ”€ StopIteration

Catching a parent class catches all its children:

try:
    data[key]
except LookupError:
    # Catches both IndexError and KeyError
    print("Key or index not found")

Logging Exceptions

import logging

logger = logging.getLogger(__name__)

def process_payment(order_id, amount):
    try:
        result = payment_gateway.charge(amount)
        logger.info("Payment successful: order=%s amount=%s", order_id, amount)
        return result
    except PaymentDeclinedError as e:
        logger.warning("Payment declined: order=%s reason=%s", order_id, e)
        raise
    except Exception as e:
        # Log with full traceback
        logger.error(
            "Unexpected payment error: order=%s",
            order_id,
            exc_info=True  # includes traceback in log
        )
        raise

Exception Groups (Python 3.11+)

Python 3.11 introduced ExceptionGroup for handling multiple concurrent exceptions (e.g., from asyncio.gather):

import asyncio

async def fetch_all(urls):
    tasks = [fetch(url) for url in urls]
    try:
        results = await asyncio.gather(*tasks, return_exceptions=False)
    except* ConnectionError as eg:
        print(f"Connection errors: {eg.exceptions}")
    except* TimeoutError as eg:
        print(f"Timeouts: {eg.exceptions}")
    return results

Best Practices

# 1. Be specific โ€” catch the narrowest exception possible
# Bad
try:
    process()
except Exception:
    pass  # silently swallows ALL errors

# Good
try:
    process()
except ValueError as e:
    handle_invalid_input(e)

# 2. Never use bare except
# Bad
try:
    risky()
except:  # catches SystemExit, KeyboardInterrupt too!
    pass

# Good
try:
    risky()
except Exception:
    pass

# 3. Don't use exceptions for flow control
# Bad
try:
    value = my_dict[key]
except KeyError:
    value = default

# Good
value = my_dict.get(key, default)

# 4. Always log unexpected exceptions
try:
    critical_operation()
except Exception:
    logger.exception("Critical operation failed")
    raise

# 5. Clean up with context managers, not finally
# Bad
f = open("file.txt")
try:
    data = f.read()
finally:
    f.close()

# Good
with open("file.txt") as f:
    data = f.read()

Resources

Comments