On this page
Error Handling
Error Handling
In the real world, programs fail. Files do not exist, network connections drop, users enter invalid data, and APIs return unexpected responses. Robust programs anticipate these failures and handle them gracefully — logging the problem, informing the user, and recovering where possible, rather than crashing with an incomprehensible traceback.
Python's exception system is one of its most elegant features. Unlike error codes (where you must check every return value), Python uses a "try first, handle failures separately" approach that keeps the happy path clean and groups error handling in one place.
The Exception Hierarchy
Python exceptions are classes that form a hierarchy. All exceptions inherit from BaseException, and most user-facing exceptions inherit from Exception:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── TypeError
├── ValueError
├── NameError
├── AttributeError
├── FileNotFoundError
├── PermissionError
├── RuntimeError
└── ... many moreThe `try/except` Statement
The basic structure:
try:
# Code that might raise an exception
result = 10 / 0
except ZeroDivisionError:
# Code that runs if ZeroDivisionError is raised
print("Cannot divide by zero!")Catching Multiple Exception Types
def safe_divide(a: float, b: float) -> float | None:
try:
return a / b
except ZeroDivisionError:
print("Error: Division by zero")
return None
except TypeError as e:
print(f"Error: Invalid types — {e}")
return None
print(safe_divide(10, 2)) # 5.0
print(safe_divide(10, 0)) # Error: Division by zero / None
print(safe_divide(10, "x")) # Error: Invalid types / None
# Catching multiple types in a single except
def parse_number(text: str) -> int | None:
try:
return int(text)
except (ValueError, TypeError):
return None
print(parse_number("42")) # 42
print(parse_number("hello")) # None
print(parse_number(None)) # NoneAccessing the Exception Object
import json
def load_config(path: str) -> dict:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
print(f"Config file not found: {e.filename}")
return {}
except json.JSONDecodeError as e:
print(f"Invalid JSON at line {e.lineno}: {e.msg}")
return {}
except PermissionError:
print("Permission denied when reading config")
return {}The as e clause binds the exception object to the name e, giving you access to its attributes and message.
`else` and `finally`
def read_integer(prompt: str) -> int | None:
try:
raw = input(prompt)
value = int(raw)
except ValueError:
print(f"'{raw}' is not a valid integer.")
return None
else:
# Runs ONLY if no exception was raised in try
print(f"Successfully parsed: {value}")
return value
finally:
# ALWAYS runs — even if an exception was raised and not caught
print("read_integer() finished.")The else clause is often overlooked but important: it lets you put code that should only run on success, without wrapping it in the try block where it might accidentally catch unintended exceptions. The finally clause is guaranteed to run — it is the right place for cleanup like closing files or releasing locks.
import time
def timed_operation(func, *args):
start = time.perf_counter()
try:
result = func(*args)
return result
except Exception as e:
print(f"Operation failed: {e}")
return None
finally:
elapsed = time.perf_counter() - start
print(f"Elapsed: {elapsed:.4f}s") # always printed, success or failuretip type: tip title: "Put minimal code in try blocks"
Only put the code that might raise the exception inside the
tryblock — not everything that comes after it. The broader yourtryblock, the harder it is to know which line actually raised the exception. Useelsefor code that should run only if thetrysucceeded.
Raising Exceptions
Use raise to raise an exception explicitly:
def set_age(age: int) -> None:
if not isinstance(age, int):
raise TypeError(f"Age must be an int, got {type(age).__name__!r}")
if age < 0:
raise ValueError(f"Age cannot be negative: {age}")
if age > 150:
raise ValueError(f"Age seems unrealistic: {age}")
print(f"Age set to {age}")
set_age(25) # Age set to 25
try:
set_age(-5)
except ValueError as e:
print(f"Caught: {e}") # Caught: Age cannot be negative: -5
# Re-raising an exception
def process(data: str) -> str:
try:
return data.strip().upper()
except AttributeError:
raise # re-raises the same exception with the original tracebackException Chaining
When one exception causes another, use raise ... from ... to link them:
class DatabaseError(Exception):
pass
def query_database(sql: str) -> list:
try:
# Simulate a database error
raise ConnectionError("Could not connect to PostgreSQL on port 5432")
except ConnectionError as e:
raise DatabaseError(f"Query failed: {sql!r}") from e
try:
query_database("SELECT * FROM users")
except DatabaseError as e:
print(f"Database error: {e}")
print(f"Caused by: {e.__cause__}")Custom Exceptions
Creating your own exception hierarchy makes error handling more precise and meaningful:
# Base exception for your application
class AppError(Exception):
"""Base class for all application errors."""
pass
# Domain-specific exceptions
class ValidationError(AppError):
"""Raised when data fails validation."""
def __init__(self, field: str, message: str) -> None:
self.field = field
self.message = message
super().__init__(f"Validation failed for '{field}': {message}")
class NotFoundError(AppError):
"""Raised when a requested resource does not exist."""
def __init__(self, resource: str, identifier: str | int) -> None:
self.resource = resource
self.identifier = identifier
super().__init__(f"{resource} not found: {identifier!r}")
class AuthorizationError(AppError):
"""Raised when a user lacks permission for an action."""
pass
# Using the custom exceptions
def get_user(user_id: int) -> dict:
users = {1: {"name": "Alice", "role": "admin"}, 2: {"name": "Bob", "role": "user"}}
if user_id not in users:
raise NotFoundError("User", user_id)
return users[user_id]
def update_user_email(user_id: int, email: str, requester_role: str) -> None:
if requester_role != "admin":
raise AuthorizationError("Only admins can update user emails")
if "@" not in email:
raise ValidationError("email", "Must contain '@'")
user = get_user(user_id)
user["email"] = email
print(f"Updated {user['name']}'s email to {email}")
# Handling custom exceptions
try:
update_user_email(99, "[email protected]", "admin")
except NotFoundError as e:
print(f"Not found: {e}") # Not found: User not found: 99
try:
update_user_email(1, "invalid-email", "admin")
except ValidationError as e:
print(f"Field '{e.field}': {e.message}") # Field 'email': Must contain '@'
try:
update_user_email(1, "[email protected]", "user")
except AuthorizationError as e:
print(f"Denied: {e}") # Denied: Only admins can update user emails
# Catching the base class catches all subclasses
try:
update_user_email(99, "x", "user")
except AppError as e:
print(f"App error: {type(e).__name__}: {e}")tip type: info title: "Never use bare except"
Avoid
except:(with no exception type) orexcept Exception:at the top level unless you absolutely must. A bareexcept:also catchesSystemExitandKeyboardInterrupt, which can prevent your program from responding to Ctrl+C. Always catch the most specific exception type you can handle.
Context Managers and `with`
The with statement ensures resources are cleaned up even if an exception occurs — without needing a try/finally:
# File handling without with — must manually close even on error
f = open("data.txt", "w")
try:
f.write("Hello")
finally:
f.close()
# File handling with with — automatically closed no matter what
with open("data.txt", "w") as f:
f.write("Hello") # f.close() is called automatically
# Multiple context managers
with open("input.txt") as src, open("output.txt", "w") as dst:
for line in src:
dst.write(line.upper())Creating Custom Context Managers
from contextlib import contextmanager
@contextmanager
def managed_resource(name: str):
print(f"Acquiring {name}")
try:
yield name.upper() # the value bound by `as`
except Exception as e:
print(f"Error while using {name}: {e}")
raise
finally:
print(f"Releasing {name}")
with managed_resource("database connection") as res:
print(f"Using {res}")
# Acquiring database connection
# Using DATABASE CONNECTION
# Releasing database connection`traceback` Module — Printing Full Tracebacks
import traceback
def risky_operation() -> None:
raise RuntimeError("Something went wrong deep inside")
try:
risky_operation()
except RuntimeError:
# Log the full traceback without re-raising
traceback.print_exc()
print("Recovered and continuing...")nextSteps
- modules-and-packages
Sign in to track your progress