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
- Python Docs: Errors and Exceptions
- Python Docs: Built-in Exceptions
- Python Docs: Exception Groups (3.11+)
Comments