Exceptions #

Exceptions are Python’s way of signaling that something unexpected happened while the program runs — a file not found, a dropped connection, invalid input, or an impossible math operation. The try/except mechanism lets you catch these signals and respond appropriately, instead of letting the program crash. Just as important is understanding when not to catch — catching too broadly or too silently is a dangerous anti-pattern that hides bugs. This article covers Python exception handling comprehensively: from the built-in hierarchy, the try/except/else/finally pattern, custom exceptions, to exception chaining and ExceptionGroup in Python 3.11+.

The Built-in Exception Hierarchy #

Every exception in Python inherits from BaseException. Knowing this hierarchy matters so you catch the right type — not too broad, not too narrow.

BaseException
├── SystemExit              ← sys.exit() — don't catch unless necessary
├── KeyboardInterrupt       ← Ctrl+C — don't catch unless necessary
├── GeneratorExit           ← generator/coroutine closed
└── Exception               ← parent of all "normal" exceptions
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   ├── OverflowError
    │   └── FloatingPointError
    ├── AttributeError       ← accessing a missing attribute
    ├── ImportError
    │   └── ModuleNotFoundError
    ├── LookupError
    │   ├── IndexError       ← list index out of range
    │   └── KeyError         ← dict key not found
    ├── NameError
    │   └── UnboundLocalError
    ├── OSError (IOError)
    │   ├── FileNotFoundError
    │   ├── PermissionError
    │   └── TimeoutError
    ├── RuntimeError
    │   └── RecursionError
    ├── StopIteration
    ├── TypeError            ← wrong type
    ├── ValueError           ← invalid value for that type
    └── ...
# Catching a parent class also catches all of its subclasses
try:
    number = int("not a number")
except ValueError:
    print("ValueError caught")   # ← caught

try:
    d = {"a": 1}
    print(d["b"])
except LookupError:
    print("LookupError caught")  # ← KeyError is a subclass of LookupError

try / except / else / finally #

Python’s exception-handling block has four clauses that work together.

To visualize how control flow moves through these clauses — whether an exception occurs (and is caught or not) or the program runs successfully — look at the exception lifecycle diagram below:

flowchart TD
    Start["Enter try Block"] --> RunTry["Run the Code inside try"]
    RunTry --> CheckError{"Did an Exception Occur?"}
    
    CheckError -->|No| RunElse["Run the else Block"]
    RunElse --> RunFinally["Run the finally Block"]
    
    CheckError -->|Yes| CheckCatch{"Is the Exception Caught by except?"}
    CheckCatch -->|Yes| RunExcept["Run the except Block"]
    RunExcept --> RunFinally
    
    CheckCatch -->|No| RunFinallyUnhandled["Run the finally Block"]
    RunFinallyUnhandled --> Propagate["Propagate the Exception Up the Call Stack (Crash/Uncaught)"]
    
    RunFinally --> End["Continue the Program Below try-except"]

The diagram makes it clear that the finally block is guaranteed to run before program control leaves the exception-handling structure, even if the exception isn’t caught by any except block and propagates up the call stack.

Here’s a complete example using all four clauses:

try:
    # Code that might raise an exception
    result = 10 / 2
except ZeroDivisionError:
    # Runs ONLY if a ZeroDivisionError occurs
    print("Division by zero!")
except (TypeError, ValueError) as e:
    # Catching several types at once
    print(f"Type or value error: {e}")
else:
    # Runs ONLY if there was NO exception in the try block
    print(f"Success: {result}")
finally:
    # Always runs — exception or not
    print("Done")

The Role of Each Clause #

import os

def read_config_file(path: str) -> dict:
    """Reads a config file and returns a dict."""
    file = None
    try:
        file = open(path, "r", encoding="utf-8")   # may raise FileNotFoundError
        content = file.read()
        return parse_config(content)                  # may raise ValueError
    except FileNotFoundError:
        print(f"File not found: {path}")
        return {}
    except ValueError as e:
        print(f"Invalid config format: {e}")
        return {}
    else:
        # Only runs if try completed without an exception
        # Useful for success logging
        print(f"Config loaded successfully from {path}")
    finally:
        # Cleanup — always close the file even if an exception occurred
        if file is not None:
            file.close()
The else clause on try is often overlooked but very useful: code in else only runs if there was no exception in try. It separates “success path” code from “error handling” code, making the intent clearer than putting everything inside try.

Exception Handling Anti-Patterns #

This is the most important part, and it rarely gets enough serious attention.

# ANTI-PATTERN 1: catching Exception too broadly (bare except)
try:
    result = process_data(input_data)
except:                          # ← catches EVERYTHING including SystemExit, KeyboardInterrupt
    print("An error occurred")   # ← hides bugs, hard to debug

# ANTI-PATTERN 2: catching Exception and staying silent (silent swallow)
try:
    connection = create_db_connection()
except Exception:
    pass                         # ← the bug is completely hidden!

# ANTI-PATTERN 3: catching too broadly and only logging
try:
    send_email(user)
except Exception as e:
    print(e)                     # ← still too broad, all errors treated the same
# CORRECT: catch the specific types you expect
try:
    connection = create_db_connection()
except ConnectionRefusedError:
    logger.error("Database unreachable")
    raise   # ← re-raise so the caller knows something's wrong
except TimeoutError:
    logger.warning("Connection timed out, retrying...")
    connection = create_db_connection(timeout=60)

# CORRECT: if you really need to catch broadly, at least log with a traceback
import logging
try:
    process_batch(data)
except Exception:
    logging.exception("Batch processing failed")  # ← logging.exception includes the traceback
    raise   # ← re-raise after logging
except Exception: pass is one of the most dangerous anti-patterns in Python. It swallows every error — including logic bugs, MemoryError, even programming mistakes — with no trace at all. The program keeps running as if nothing is wrong while the internal state is already corrupted.

raise — Raising Exceptions #

raise is used to raise an exception explicitly, either a new one or to forward an already-caught exception.

# Raising a new exception
def divide(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("The divisor can't be zero")
    return a / b

# Raising with an informative message
def save_user(data: dict) -> None:
    if "email" not in data:
        raise ValueError("The 'email' field is required in user data")
    if not isinstance(data.get("age"), int):
        raise TypeError(f"'age' must be an integer, not {type(data.get('age')).__name__}")
    # save to the database...

# Re-raising the exception being handled (no argument)
def load_config(path: str) -> dict:
    try:
        return json.load(open(path))
    except json.JSONDecodeError:
        logging.error(f"Invalid JSON format: {path}")
        raise   # ← forward the same exception to the caller

Exception Chaining with raise from #

raise X from Y creates an exception chain — the new exception carries the context of the original one. This is very useful when converting a low-level exception into a domain-level one:

class DatabaseError(Exception):
    """Domain exception for all database errors."""
    pass

def fetch_user(user_id: int) -> dict:
    try:
        return db.query(f"SELECT * FROM users WHERE id = {user_id}")
    except ConnectionError as e:
        # Convert to a domain exception, but keep the original context
        raise DatabaseError(f"Failed to fetch user {user_id}") from e

# When the error occurs, Python shows BOTH exceptions:
# ConnectionError: ...
# The above exception was the direct cause of the following exception:
# DatabaseError: Failed to fetch user 42
# raise ... from None — explicitly hide the original exception
def parse_date(text: str):
    try:
        return datetime.strptime(text, "%Y-%m-%d")
    except ValueError:
        # We don't want to show the internal ValueError details
        raise ValueError(f"Invalid date format: '{text}' (use YYYY-MM-DD)") from None

Custom Exceptions #

Defining your own exception classes makes errors more descriptive, easier to catch specifically, and able to carry relevant extra information.

Custom Exception Hierarchy #

# Define an exception hierarchy for your application domain
class AppError(Exception):
    """Base class for all exceptions in this application."""
    pass

class ValidationError(AppError):
    """User input fails validation requirements."""
    pass

class AuthenticationError(AppError):
    """Invalid credentials or expired session."""
    pass

class AuthorizationError(AppError):
    """The user isn't allowed to perform this action."""
    pass

class NotFoundError(AppError):
    """The requested resource wasn't found."""
    def __init__(self, resource: str, identifier):
        self.resource = resource
        self.identifier = identifier
        super().__init__(f"{resource} with id '{identifier}' not found")

class RateLimitError(AppError):
    """Too many requests within a given time window."""
    def __init__(self, limit: int, reset_seconds: int):
        self.limit = limit
        self.reset_seconds = reset_seconds
        super().__init__(
            f"Rate limit of {limit} requests reached. "
            f"Try again in {reset_seconds} seconds."
        )

Exceptions with Additional Attributes #

from dataclasses import dataclass
from typing import Any

class ValidationError(AppError):
    """Validation exception with details about the offending field."""

    def __init__(self, field: str, value: Any, message: str):
        self.field = field
        self.value = value
        self.message = message
        super().__init__(f"Validation failed on field '{field}': {message}")

    def __str__(self) -> str:
        return (
            f"ValidationError(\n"
            f"  field='{self.field}',\n"
            f"  value={self.value!r},\n"
            f"  message='{self.message}'\n"
            f")"
        )


# Usage
def validate_email(email: str) -> str:
    if not email:
        raise ValidationError("email", email, "Must not be empty")
    if "@" not in email:
        raise ValidationError("email", email, "Invalid format — must contain '@'")
    if len(email) > 254:
        raise ValidationError("email", email, f"Too long ({len(email)} chars, max 254)")
    return email.lower().strip()

try:
    validate_email("not-an-email")
except ValidationError as e:
    print(e.field)    # → email
    print(e.value)    # → not-an-email
    print(e.message)  # → Invalid format — must contain '@'
    print(e)          # → ValidationError(...)

Catching by Hierarchy #

def process_request(user_id: int, action: str) -> None:
    try:
        user = fetch_user(user_id)     # may raise NotFoundError
        check_permission(user, action) # may raise AuthorizationError
        execute_action(user, action)   # may raise ValidationError
    except NotFoundError as e:
        return {"status": 404, "message": str(e)}
    except AuthorizationError:
        return {"status": 403, "message": "Access denied"}
    except ValidationError as e:
        return {"status": 400, "message": str(e), "field": e.field}
    except AppError as e:
        # Catch all app errors not handled above
        logging.error(f"Unexpected AppError: {e}")
        return {"status": 500, "message": "An internal error occurred"}

contextlib.suppress — Ignoring Specific Exceptions #

If there genuinely are cases where you want to ignore a specific exception without a verbose try/except block:

from contextlib import suppress

# ANTI-PATTERN: try/except just to ignore
try:
    os.remove("temp_file.tmp")
except FileNotFoundError:
    pass   # fine if the file really doesn't exist

# CORRECT: suppress is more expressive for this case
with suppress(FileNotFoundError):
    os.remove("temp_file.tmp")

# You can suppress several types at once
with suppress(FileNotFoundError, PermissionError):
    os.remove("temp_file.tmp")

ExceptionGroup — Multiple Exceptions at Once (Python 3.11+) #

Python 3.11 introduced ExceptionGroup for situations where several exceptions occur together — highly relevant in async/concurrent contexts:

# Raising an ExceptionGroup
def validate_form(data: dict) -> None:
    errors = []
    if not data.get("name"):
        errors.append(ValidationError("name", data.get("name"), "Required"))
    if not data.get("email"):
        errors.append(ValidationError("email", data.get("email"), "Required"))
    if data.get("age", 0) < 18:
        errors.append(ValidationError("age", data.get("age"), "Must be at least 18"))

    if errors:
        raise ExceptionGroup("Form validation failed", errors)

# Catching with except* (new Python 3.11+ syntax)
try:
    validate_form({"name": "", "email": "", "age": 15})
except* ValidationError as eg:
    print(f"There are {len(eg.exceptions)} validation errors:")
    for err in eg.exceptions:
        print(f"  - {err.field}: {err.message}")
# → There are 3 validation errors:
# →   - name: Required
# →   - email: Required
# →   - age: Must be at least 18

Exception Handling Best Practices #

import logging

# 1. Catch as precisely as possible
try:
    data = json.loads(text)
except json.JSONDecodeError as e:     # ← specific, not Exception
    logging.warning(f"Invalid JSON on line {e.lineno}: {e.msg}")
    data = {}

# 2. Always include context in error messages
def load_product(product_id: int):
    try:
        return db.get("product", product_id)
    except KeyError:
        # ANTI-PATTERN: an uninformative message
        # raise ValueError("Product not found")

        # CORRECT: include information that helps debugging
        raise NotFoundError("Product", product_id)

# 3. Use finally for resource cleanup
def process_with_connection():
    connection = None
    try:
        connection = create_connection()
        return connection.query("SELECT ...")
    except DatabaseError as e:
        logging.error(f"Query failed: {e}")
        raise
    finally:
        if connection:
            connection.close()   # ← always close, even if an exception occurred

# 4. Better yet: use a context manager
from contextlib import contextmanager

@contextmanager
def db_connection():
    connection = create_connection()
    try:
        yield connection
    finally:
        connection.close()

# Clean usage
with db_connection() as db:
    result = db.query("SELECT ...")

Summary #

  • Catch as precisely as possible — use specific exception types (FileNotFoundError, ValueError) instead of Exception or a bare except. The more specific, the easier to debug.
  • Never except Exception: pass — this swallows every error including bugs, leaving the program running with corrupted state and no trace at all.
  • else for the success path — code in else only runs if there was no exception in try, separating success logic from error logic.
  • finally for resource cleanup — close files, connections, and other resources in finally so they always run even when an exception occurs.
  • raise from for exception chaining — convert low-level exceptions into domain exceptions while keeping the original context for debugging.
  • Define a custom exception hierarchyAppError as the base, then ValidationError, NotFoundError, etc. as subclasses. This enables flexible catching from broad to specific.
  • Give custom exceptions extra attributes — storing field, code, or other context data inside the exception helps the catcher take the right action.
  • contextlib.suppress as a clean alternative to try/except: pass when you genuinely want to ignore a specific exception.
  • ExceptionGroup and except* (Python 3.11+) for scenarios where several exceptions occur together — relevant for batch validation and async code.

← Previous: Interfaces   Next: Lists →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact