Python Error Handling ā Exceptions Done Right
Errors are inevitable in programming. What separates beginner code from production-quality code is how you handle them. Python's exception system gives you powerful tools to catch, recover from, and communicate errors gracefully.
Learning Objectives
By the end of this tutorial, you will be able to:
- Distinguish between syntax errors and runtime exceptions
- Use
try/except/else/finallyblocks correctly - Catch specific exceptions rather than using bare
except - Raise exceptions with meaningful messages and re-raise with tracebacks
- Create custom exception classes for domain-specific errors
- Apply the EAFP principle for cleaner, more Pythonic code
- Implement retry logic, validation, and fallback patterns
- Avoid common exception-handling mistakes that hide bugs
Syntax Errors vs Exceptions
SyntaxError: Code That Can't Parse
A SyntaxError means Python can't even read your code. It's caught during parsing, before anything runs.
def calculate_total(items
return sum(items)
Output:
File "example.py", line 1
def calculate_total(items
^
SyntaxError: '(' was never closed
You cannot catch SyntaxError with try/except because the code never executes. You must fix the syntax first.
Exceptions: Runtime Errors
Exceptions occur when syntactically correct code runs into a problem. These are the errors you can catch and handle.
result = 10 / 0
Output:
Traceback (most recent call last):
File "example.py", line 1, in <module>
result = 10 / 0
ZeroDivisionError: division by zero
Common Built-in Exceptions
| Exception | When It Occurs | Example |
|---|---|---|
ValueError | Wrong value type | int("hello") |
TypeError | Wrong operand type | "2" + 2 |
KeyError | Dict key not found | {"a": 1}["b"] |
IndexError | List index out of range | [1, 2][5] |
FileNotFoundError | File doesn't exist | open("missing.txt") |
AttributeError | Object lacks attribute | "hi".push("!") |
ZeroDivisionError | Division by zero | 10 / 0 |
StopIteration | Iterator exhausted | next(iter([])) |
ImportError | Module not found | import nonexistent |
RuntimeError | Generic runtime error | Various |
OverflowError | Number too large | math.exp(1000) |
MemoryError | Out of memory | Huge data structures |
Try/Except
Basic Syntax
Wrap code that might fail in a try block and handle errors in except:
try:
result = int(input("Enter a number: "))
print(f"You entered: {result}")
except ValueError:
print("That's not a valid number!")
If the user types abc, output is:
Enter a number: abc
That's not a valid number!
Catching Specific Exceptions
Always catch the most specific exception you can handle meaningfully:
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
print("Cannot divide by zero")
return None
print(divide(10, 0)) # Cannot divide by zero ā None
print(divide(10, 2)) # 5.0
Catching Multiple Exceptions
Handle different exceptions separately or together:
def process_data(data):
try:
value = int(data)
result = 100 / value
return result
except ValueError:
print(f"Cannot convert '{data}' to integer")
except ZeroDivisionError:
print("Cannot divide by zero")
return None
# Or group related exceptions:
def process_data_v2(data):
try:
value = int(data)
result = 100 / value
return result
except (ValueError, ZeroDivisionError) as e:
print(f"Error: {e}")
return None
print(process_data("abc")) # Cannot convert 'abc' to integer
print(process_data("0")) # Cannot divide by zero
print(process_data("5")) # 20.0
Bare Except: Why to Avoid It
# BAD: Catches everything including SystemExit, KeyboardInterrupt
try:
risky_operation()
except:
print("Something went wrong")
# GOOD: Catch specific exceptions
try:
risky_operation()
except Exception as e:
print(f"Error: {e}")
Bare except catches KeyboardInterrupt, SystemExit, and GeneratorExit, which means users can't Ctrl+C to stop your program. Always use except Exception at minimum.
Exception Hierarchy
Exceptions form a class hierarchy. Catching a parent catches all children:
# This catches ALL exceptions (use sparingly):
except Exception:
pass
# This catches arithmetic-related errors:
except ArithmeticError:
pass
# ArithmeticError catches: ZeroDivisionError, OverflowError, FloatingPointError
Try/Except/Else/Finally
Complete Block Structure
try:
# Code that might raise an exception
file = open("data.txt", "r")
except FileNotFoundError:
# Runs only if an exception occurs
print("File not found, creating it")
file = open("data.txt", "w")
except PermissionError:
print("No permission to access file")
else:
# Runs ONLY if no exception occurred
content = file.read()
print(f"File content: {content}")
finally:
# ALWAYS runs, regardless of what happened
file.close()
print("File handle closed")
Execution Flow
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā try ā
ā āāāāāāāāāāāāāāāā ā
ā ā Code runs ā ā
ā āāāāāāāā¬āāāāāāāā ā
ā ā ā
ā āāāāāā“āāāāā ā
ā ā Success? ā ā
ā āāāāāā¬āāāāā ā
ā Yes ā No ā
ā ā āāāā except ā
ā ā ā ā
ā ā ā ā
ā else handle error ā
ā ā ā ā
ā āāāāāā¬āāāāā ā
ā ā ā
ā finally ā
ā ā ā
ā return ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Practical Example
def read_config(path):
config = {}
try:
with open(path, "r") as f:
for line in f:
key, value = line.strip().split("=")
config[key] = value
except FileNotFoundError:
print(f"Config file '{path}' not found, using defaults")
config = {"host": "localhost", "port": "8080"}
except ValueError:
print("Config file has malformed lines, using defaults")
config = {"host": "localhost", "port": "8080"}
else:
print(f"Loaded {len(config)} settings from '{path}'")
finally:
print("Config initialization complete")
return config
settings = read_config("app.ini")
print(settings)
Exception Hierarchy
Understanding the hierarchy helps you catch the right level:
BaseException
āāā SystemExit
āāā KeyboardInterrupt
āāā GeneratorExit
āāā Exception
āāā StopIteration
āāā ArithmeticError
ā āāā ZeroDivisionError
ā āāā OverflowError
ā āāā FloatingPointError
āāā AssertionError
āāā AttributeError
āāā EOFError
āāā ImportError
ā āāā ModuleNotFoundError
āāā LookupError
ā āāā IndexError
ā āāā KeyError
āāā NameError
ā āāā UnboundLocalError
āāā OSError
ā āāā FileNotFoundError
ā āāā PermissionError
ā āāā FileExistsError
ā āāā IsADirectoryError
āāā RuntimeError
ā āāā NotImplementedError
āāā SyntaxError
ā āāā IndentationError
āāā TypeError
āāā ValueError
When to Catch Specific vs General
# TOO SPECIFIC: Redundant, both are subclasses of LookupError
try:
result = data[key]
except (KeyError, IndexError):
pass
# BETTER: Use parent class
try:
result = data[key]
except LookupError:
pass
# TOO BROAD: Hides unrelated errors
try:
result = data[key]
except Exception:
pass
# GOOD: Catch what you can handle
try:
result = data[key]
except KeyError:
result = default_value
Raising Exceptions
The raise Statement
def set_age(age):
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0 or age > 150:
raise ValueError(f"Age must be between 0 and 150, got {age}")
return age
try:
set_age(-5)
except ValueError as e:
print(f"Invalid age: {e}")
# Invalid age: Age must be between 0 and 150, got -5
Re-raising Exceptions
Preserve the original traceback when re-raising:
def process_file(filename):
try:
with open(filename) as f:
data = f.read()
return int(data.strip())
except (FileNotFoundError, ValueError):
print(f"Failed to process {filename}")
raise # Re-raises with original traceback
Without raise, you'd lose the traceback context.
Exception Chaining with raise from
When one exception causes another, chain them:
def load_user(user_id):
try:
import json
with open(f"users/{user_id}.json") as f:
return json.load(f)
except FileNotFoundError as original:
raise RuntimeError(f"User {user_id} not found") from original
# Output shows the chain:
# Traceback (most recent call last):
# ...
# FileNotFoundError: [Errno 2] No such file or directory: 'users/123.json'
#
# During handling of the above exception, another exception occurred:
#
# Traceback (most recent call last):
# ...
# RuntimeError: User 123 not found
Suppressing the Chain
Use from None when the original exception is not useful:
def read_int(path):
try:
with open(path) as f:
return int(f.read().strip())
except (FileNotFoundError, ValueError) as e:
raise ValueError(f"Could not read integer from {path}") from None
Custom Exceptions
Creating Exception Classes
class AppError(Exception):
"""Base exception for the application."""
pass
class ValidationError(AppError):
"""Raised when input fails validation."""
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"Validation failed for '{field}': {message}")
class DatabaseError(AppError):
"""Raised when a database operation fails."""
def __init__(self, operation, query=None):
self.operation = operation
self.query = query
super().__init__(f"Database error during '{operation}'")
class NotFoundError(AppError):
"""Raised when a requested resource doesn't exist."""
def __init__(self, resource_type, resource_id):
self.resource_type = resource_type
self.resource_id = resource_id
super().__init__(f"{resource_type} with id '{resource_id}' not found")
Using Custom Exceptions
def create_user(name, email, age):
if not name:
raise ValidationError("name", "cannot be empty")
if "@" not in email:
raise ValidationError("email", "must contain @")
if not isinstance(age, int) or age < 0:
raise ValidationError("age", "must be a non-negative integer")
return {"name": name, "email": email, "age": age}
# Catching at different levels
try:
user = create_user("", "alice@example.com", 25)
except ValidationError as e:
print(f"Validation error: {e.field} ā {e.message}")
# Validation error: name ā cannot be empty
# Catching the base class
try:
user = create_user("Alice", "not-an-email", 25)
except AppError as e:
print(f"Application error: {e}")
# Application error: Validation failed for 'email': must contain @
When to Create Custom Exceptions
Create custom exceptions when:
- You need to distinguish between error types programmatically
- You want to attach domain-specific data to errors
- You're building a library and want users to catch specific errors
- Business logic requires special error handling paths
Context Managers and Exceptions
The with Statement
Context managers handle resource cleanup even when exceptions occur:
# Without context manager (fragile):
f = open("data.txt")
try:
data = f.read()
process(data)
finally:
f.close()
# With context manager (robust):
with open("data.txt") as f:
data = f.read()
process(data)
# File is automatically closed, even if an exception occurs
Custom Context Managers
class DatabaseConnection:
def __init__(self, host, port):
self.host = host
self.port = port
self.connection = None
def __enter__(self):
print(f"Connecting to {self.host}:{self.port}")
self.connection = {"host": self.host, "port": self.port}
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
print(f"Exception occurred: {exc_val}")
self.connection = None
print("Connection closed")
return False # Don't suppress exceptions
# Usage:
with DatabaseConnection("localhost", 5432) as conn:
print(f"Using connection: {conn}")
# Even if an exception occurs here, __exit__ runs
The __exit__ Parameters
exc_type: Exception class (None if no exception)exc_val: Exception instance (None if no exception)exc_tb: Traceback object (None if no exception)- Return
Trueto suppress the exception,Falseto re-raise it
Exception Best Practices
EAFP vs LBYL
LBYL (Look Before You Leap): Check before acting.
# LBYL approach
if key in my_dict:
value = my_dict[key]
else:
value = default
if os.path.exists(filename):
with open(filename) as f:
data = f.read()
EAFP (Easier to Ask Forgiveness than Permission): Try and handle failure.
# EAFP approach
try:
value = my_dict[key]
except KeyError:
value = default
try:
with open(filename) as f:
data = f.read()
except FileNotFoundError:
data = ""
EAFP is generally preferred in Python because:
- It avoids race conditions (the state could change between check and use)
- It's more readable when the "happy path" is the focus
- It handles edge cases automatically
Don't Catch System Exceptions
# NEVER catch these:
except KeyboardInterrupt: # Let users Ctrl+C
pass
except SystemExit: # Let sys.exit() work
pass
except GeneratorExit: # Let generators close properly
pass
Log Before Handling
import logging
def process_order(order):
try:
validate_order(order)
charge_payment(order)
send_confirmation(order)
except PaymentError as e:
logging.error(f"Payment failed for order {order.id}: {e}")
notify_admin(order, e)
order.status = "payment_failed"
except Exception as e:
logging.exception(f"Unexpected error processing order {order.id}")
raise
Don't Swallow Exceptions Silently
# BAD: Hides bugs
try:
risky_operation()
except Exception:
pass
# BETTER: At minimum log it
try:
risky_operation()
except Exception as e:
logging.warning(f"Operation failed: {e}")
# BEST: Handle it meaningfully
try:
risky_operation()
except SpecificError as e:
recover_from_error(e)
Common Patterns
Validation Functions
class ValidationError(Exception):
def __init__(self, errors):
self.errors = errors
super().__init__(f"Validation failed: {', '.join(errors)}")
def validate_email(email):
if not isinstance(email, str):
raise ValidationError(["Email must be a string"])
if "@" not in email:
raise ValidationError(["Email must contain @"])
if "." not in email.split("@")[-1]:
raise ValidationError(["Email must have a valid domain"])
return True
try:
validate_email("not-an-email")
except ValidationError as e:
print(e.errors) # ['Email must contain @']
Retry Logic
import time
def retry(func, max_attempts=3, delay=1):
"""Retry a function on failure."""
last_exception = None
for attempt in range(max_attempts):
try:
return func()
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
print(f"Attempt {attempt + 1} failed: {e}")
print(f"Retrying in {delay} seconds...")
time.sleep(delay)
raise last_exception
def unreliable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("API timeout")
return {"status": "success"}
try:
result = retry(unreliable_api_call, max_attempts=3, delay=0.5)
print(result)
except ConnectionError as e:
print(f"All attempts failed: {e}")
Fallback Values
def get_user_preference(user_id, key, default=None):
"""Try cache ā database ā default."""
try:
return cache.get(f"user:{user_id}:{key}")
except CacheError:
pass
try:
return database.query(f"SELECT {key} FROM users WHERE id = ?", user_id)
except DatabaseError:
pass
return default
# Usage
theme = get_user_preference(123, "theme", default="light")
Resource Cleanup
from contextlib import contextmanager
@contextmanager
def temporary_directory():
import tempfile, shutil
path = tempfile.mkdtemp()
try:
yield path
finally:
shutil.rmtree(path)
# Usage:
with temporary_directory() as tmp:
# Work with files in tmp
with open(f"{tmp}/data.txt", "w") as f:
f.write("hello")
# tmp directory is automatically cleaned up
Exception Groups (Python 3.11+)
ExceptionGroup
When multiple independent errors occur simultaneously:
# Python 3.11+
def validate_registration(data):
errors = []
if not data.get("username"):
errors.append(ValidationError("username", "required"))
if not data.get("email"):
errors.append(ValidationError("email", "required"))
if not data.get("password"):
errors.append(ValidationError("password", "required"))
if errors:
raise ExceptionGroup("Registration validation failed", errors)
try:
validate_registration({})
except* ValidationError as eg:
for error in eg.exceptions:
print(f" {error.field}: {error.message}")
Output:
username: required
email: required
password: required
When to Use ExceptionGroups
Use them when:
- You're validating multiple independent fields
- You're running parallel operations that can each fail
- You want to report all errors at once, not just the first one
Common Mistakes
1. Bare Except Catching Everything
# BAD
try:
operation()
except:
pass
# GOOD
try:
operation()
except Exception as e:
logger.error(f"Operation failed: {e}")
2. Catching and Ignoring Silently
# BAD: Hides bugs forever
try:
result = int(user_input)
except ValueError:
pass # User never knows something went wrong
# GOOD: At least inform the user
try:
result = int(user_input)
except ValueError:
print("Please enter a valid number")
result = None
3. Overly Broad Exception Catching
# BAD: Catches unrelated errors
try:
data = json.loads(response.text)
user = find_user(data["user_id"])
user.update_profile(data)
except Exception as e:
print(f"Error: {e}")
# GOOD: Handle each failure point specifically
try:
data = json.loads(response.text)
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}")
return
try:
user = find_user(data["user_id"])
except UserNotFoundError:
print(f"User not found: {data['user_id']}")
return
user.update_profile(data)
4. Not Preserving Traceback
# BAD: Loses original traceback
try:
complex_operation()
except Exception as e:
raise RuntimeError("Operation failed")
# GOOD: Preserve traceback with chaining
try:
complex_operation()
except Exception as e:
raise RuntimeError("Operation failed") from e
# GOOD: Or just re-raise
try:
complex_operation()
except Exception:
print("Logging the error...")
raise
5. Mutable Default Arguments in Exception Classes
# BAD: Shared mutable default
class MyAppError(Exception):
def __init__(self, message, details=[]):
self.details = details
super().__init__(message)
# GOOD: Create new list each time
class MyAppError(Exception):
def __init__(self, message, details=None):
self.details = details if details is not None else []
super().__init__(message)
Practice Exercises
Exercise 1: Safe Division Function
Write a function that safely divides two numbers and handles all possible errors.
Solution:
def safe_divide(a, b, default=None):
"""Safely divide a by b, returning default on error."""
try:
result = a / b
except ZeroDivisionError:
print(f"Warning: Cannot divide {a} by zero")
return default
except TypeError as e:
print(f"Warning: Invalid types for division: {e}")
return default
else:
return result
print(safe_divide(10, 3)) # 3.3333333333333335
print(safe_divide(10, 0)) # Warning: Cannot divide 10 by zero ā None
print(safe_divide(10, "a")) # Warning: Invalid types... ā None
print(safe_divide(10, 2, 0)) # 5.0
Exercise 2: Robust File Reader
Write a function that reads a JSON file and returns the data, with appropriate error handling for all common failure modes.
Solution:
import json
def read_json_file(filepath, default=None):
"""Read and parse a JSON file with comprehensive error handling."""
try:
with open(filepath, "r", encoding="utf-8") as f:
try:
return json.load(f)
except json.JSONDecodeError as e:
print(f"JSON parse error in '{filepath}': {e}")
return default
except FileNotFoundError:
print(f"File not found: '{filepath}'")
return default
except PermissionError:
print(f"Permission denied: '{filepath}'")
return default
except UnicodeDecodeError:
print(f"Encoding error reading '{filepath}'")
return default
except OSError as e:
print(f"OS error reading '{filepath}': {e}")
return default
# Test cases
print(read_json_file("config.json")) # File not found ā None
print(read_json_file("malformed.json")) # JSON parse error ā None
print(read_json_file("valid.json", default={})) # Parsed data or {}
Exercise 3: Custom Exception Hierarchy
Build an exception hierarchy for a banking application with classes for insufficient funds, invalid account, and transaction limit errors.
Solution:
class BankingError(Exception):
"""Base exception for banking operations."""
def __init__(self, message, account_id=None):
self.account_id = account_id
super().__init__(message)
class AccountNotFoundError(BankingError):
"""Raised when an account doesn't exist."""
def __init__(self, account_id):
super().__init__(f"Account '{account_id}' not found", account_id)
class InsufficientFundsError(BankingError):
"""Raised when balance is too low."""
def __init__(self, account_id, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Insufficient funds: have ${balance:.2f}, need ${amount:.2f}",
account_id
)
class TransactionLimitError(BankingError):
"""Raised when daily transaction limit exceeded."""
def __init__(self, account_id, limit, daily_total):
self.limit = limit
self.daily_total = daily_total
super().__init__(
f"Daily limit ${limit:.2f} exceeded (current: ${daily_total:.2f})",
account_id
)
# Usage
def transfer(from_account, to_account, amount, accounts, daily_totals):
if from_account not in accounts:
raise AccountNotFoundError(from_account)
if to_account not in accounts:
raise AccountNotFoundError(to_account)
balance = accounts[from_account]
if balance < amount:
raise InsufficientFundsError(from_account, balance, amount)
daily_total = daily_totals.get(from_account, 0)
if daily_total + amount > 10000:
raise TransactionLimitError(from_account, 10000, daily_total + amount)
accounts[from_account] -= amount
accounts[to_account] += amount
daily_totals[from_account] = daily_total + amount
return True
# Test
accounts = {"A1": 500, "B2": 200}
daily_totals = {}
try:
transfer("A1", "B2", 300, accounts, daily_totals)
print("Transfer successful")
except BankingError as e:
print(f"Transfer failed: {e}")
try:
transfer("A1", "B2", 400, accounts, daily_totals)
except InsufficientFundsError as e:
print(f"Transfer failed: {e}")
print(f" Current balance: ${e.balance:.2f}")
print(f" Attempted: ${e.amount:.2f}")
Key Takeaways
- Catch specific exceptions ā never use bare
except:or catchExceptionunless you have a good reason - Use try/except/else/finally ā put only risky code in
try, put success logic inelse - Always re-raise or handle meaningfully ā swallowing exceptions hides bugs
- Create custom exceptions for domain-specific errors with useful context
- Prefer EAFP ā try the operation and handle failure rather than checking first
- Chain exceptions with
raise fromto preserve the full error context - Context managers guarantee cleanup even when exceptions occur
- Log exceptions before handling so you can debug production issues
- Never catch
KeyboardInterrupt,SystemExit, orGeneratorExit - Structure exception hierarchies to allow both specific and general catching
Error handling isn't about preventing all errors ā it's about handling the ones you expect gracefully and letting the ones you don't expect crash loudly so you can fix them.