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()
Theelseclause ontryis often overlooked but very useful: code inelseonly runs if there was no exception intry. It separates “success path” code from “error handling” code, making the intent clearer than putting everything insidetry.
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: passis 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 ofExceptionor a bareexcept. 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.elsefor the success path — code inelseonly runs if there was no exception intry, separating success logic from error logic.finallyfor resource cleanup — close files, connections, and other resources infinallyso they always run even when an exception occurs.raise fromfor exception chaining — convert low-level exceptions into domain exceptions while keeping the original context for debugging.- Define a custom exception hierarchy —
AppErroras the base, thenValidationError,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.suppressas a clean alternative totry/except: passwhen you genuinely want to ignore a specific exception.ExceptionGroupandexcept*(Python 3.11+) for scenarios where several exceptions occur together — relevant for batch validation and async code.