16 minLesson 16 of 34
Advanced Python
Error Handling & Custom Exceptions
Error Handling & Custom Exceptions: Writing Resilient Python Code
Good error handling is what separates scripts from production software. When something goes wrong — network failure, invalid input, missing file — your code should fail gracefully with clear messages, not with cryptic tracebacks.
The Exception Hierarchy
BaseException
├── SystemExit — sys.exit() was called
├── KeyboardInterrupt — Ctrl+C
├── GeneratorExit — generator/coroutine closed
└── Exception ← Most errors you'll catch
├── ValueError — wrong value type/format
├── TypeError — wrong type
├── KeyError — dict key not found
├── IndexError — sequence index out of range
├── AttributeError — object has no attribute
├── NameError — name not defined
├── OSError — system errors (FileNotFoundError, PermissionError)
├── RuntimeError — misc runtime errors
├── StopIteration — iterator exhausted
└── ArithmeticError
├── ZeroDivisionError
└── OverflowError
try / except / else / finally
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Cannot divide by zero")
return None
except TypeError as e:
print(f"Wrong types: {e}")
return None
else:
# Runs only if NO exception occurred
print(f"Success: {result}")
return result
finally:
# ALWAYS runs — even if exception was raised
print("divide() finished")
divide(10, 2) # Success: 5.0 / divide() finished
divide(10, 0) # Cannot divide by zero / divide() finished
divide(10, "x") # Wrong types / divide() finished
Catching Multiple Exceptions
def parse_config(data):
try:
config = json.loads(data) # JSONDecodeError if invalid JSON
port = int(config['port']) # KeyError if 'port' missing; ValueError if not int
assert 1 <= port <= 65535 # AssertionError if out of range
return config
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}") from e
except KeyError as e:
raise ValueError(f"Missing required key: {e}") from e
except (ValueError, AssertionError) as e:
raise ValueError(f"Invalid config: {e}") from e
# Catch any exception (usually too broad — use sparingly)
try:
risky_operation()
except Exception as e:
print(f"Something went wrong: {type(e).__name__}: {e}")
raise # Re-raise the same exception after logging
Re-raising and Chaining Exceptions
import logging
logger = logging.getLogger(__name__)
def process_payment(amount, card_number):
try:
return charge_card(card_number, amount)
except ConnectionError as e:
logger.error(f"Payment gateway unreachable: {e}")
# Raise a business-level exception that hides implementation details
raise PaymentError("Payment service temporarily unavailable") from e
except AuthError as e:
raise PaymentError("Card declined — please try another card") from e
# 'from e' sets __cause__ — Python shows both exceptions in traceback
# Use 'from None' to suppress the original exception in the traceback
try:
result = some_function()
except SpecificError:
raise PublicError("Something went wrong") from None # Hides implementation detail
Custom Exceptions: Building a Clear Error Hierarchy
# Define a hierarchy of custom exceptions
class AppError(Exception):
"""Base class for all application errors."""
def __init__(self, message, code=None, details=None):
super().__init__(message)
self.message = message
self.code = code
self.details = details or {}
def __str__(self):
parts = [self.message]
if self.code:
parts.append(f"(code: {self.code})")
return " ".join(parts)
def to_dict(self):
return {
"error": type(self).__name__,
"message": self.message,
"code": self.code,
"details": self.details
}
class ValidationError(AppError):
"""Input validation failed."""
pass
class AuthenticationError(AppError):
"""Authentication failed."""
pass
class AuthorizationError(AppError):
"""Insufficient permissions."""
pass
class NotFoundError(AppError):
"""Resource not found."""
pass
class ExternalServiceError(AppError):
"""External API or service failed."""
pass
# Usage
def get_user(user_id):
if not isinstance(user_id, int) or user_id <= 0:
raise ValidationError(
f"Invalid user ID: {user_id}",
code="INVALID_ID",
details={"user_id": user_id, "expected": "positive integer"}
)
user = database.find(user_id)
if user is None:
raise NotFoundError(
f"User {user_id} not found",
code="USER_NOT_FOUND",
details={"user_id": user_id}
)
return user
# API error handler
def handle_request(request):
try:
result = process(request)
return {"status": "success", "data": result}
except ValidationError as e:
return {"status": "error", "http_status": 400, **e.to_dict()}
except AuthenticationError as e:
return {"status": "error", "http_status": 401, **e.to_dict()}
except NotFoundError as e:
return {"status": "error", "http_status": 404, **e.to_dict()}
except AppError as e:
return {"status": "error", "http_status": 500, **e.to_dict()}
Context Managers for Cleanup
# Using contextlib.suppress
from contextlib import suppress
# Ignore specific exceptions
with suppress(FileNotFoundError, PermissionError):
import os
os.remove("temp_file.txt")
# Equivalent to:
try:
os.remove("temp_file.txt")
except (FileNotFoundError, PermissionError):
pass
Exception Best Practices
Don't Catch What You Can't Handle
# BAD: swallowing exceptions silently
try:
result = complex_operation()
except Exception:
pass # Silent failure — bugs become mysteries
# BAD: catching too broadly
try:
user = get_user(user_id)
except Exception as e:
return "Error" # Which error? What happened?
# GOOD: catch specific exceptions, handle them specifically
try:
user = get_user(user_id)
except NotFoundError:
return None # User doesn't exist — caller handles this
except ValidationError as e:
raise # Programming error — let it propagate
# GOOD: log and re-raise if you can't handle it
try:
result = risky_operation()
except SomeSpecificError as e:
logger.error(f"Operation failed: {e}", exc_info=True)
raise # Don't swallow it
Use EAFP Over LBYL in Python
# LBYL (Look Before You Leap) — more common in other languages
if 'key' in my_dict:
value = my_dict['key']
else:
value = default
# EAFP (Easier to Ask Forgiveness than Permission) — more Pythonic
try:
value = my_dict['key']
except KeyError:
value = default
# Even better — use .get()
value = my_dict.get('key', default)
Logging vs Printing Errors
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(name)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('app.log'),
logging.StreamHandler() # Also print to console
]
)
logger = logging.getLogger(__name__)
def process_order(order):
try:
validate_order(order)
payment = charge_payment(order)
logger.info(f"Order {order['id']} processed: ${order['amount']:.2f}")
return payment
except ValidationError as e:
logger.warning(f"Invalid order {order.get('id')}: {e}")
raise
except PaymentError as e:
logger.error(f"Payment failed for order {order.get('id')}: {e}")
raise
except Exception as e:
logger.critical(f"Unexpected error processing order: {e}", exc_info=True)
raise
# exc_info=True adds the full traceback to the log
The traceback Module
import traceback
def debug_error():
try:
risky()
except Exception:
# Get full traceback as a string
error_text = traceback.format_exc()
print(error_text)
# Or get the traceback info structured
tb_info = traceback.extract_tb(sys.exc_info()[2])
for frame in tb_info:
print(f" File: {frame.filename}, Line: {frame.lineno}, "
f"Function: {frame.name}")
Well-structured error handling makes debugging fast, makes APIs clear about what went wrong, and prevents cryptic failures from reaching users.
Next lesson: Modules, Packages & pip — organizing code and using the Python ecosystem.
📱
Get Notes Free →Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises