What Are Exceptions?
Every program eventually encounters situations it cannot handle: a user types letters where a number is expected, a file is missing from disk, a network request times out. In Python these runtime problems are represented as exceptions -- special objects that interrupt the normal flow of execution and propagate up the call stack until something catches them.
Errors vs Exceptions
Python draws a distinction between two broad categories of problems.
Syntax errors (also called parsing errors) are caught before your program runs. Python reads your source code, finds something that violates the grammar rules, and refuses to execute at all.
# SyntaxError -- detected before execution
if True
print("missing colon")
Output:
File "example.py", line 1
if True
^
SyntaxError: expected ':'
Exceptions happen during execution. The code is syntactically valid, but something goes wrong at runtime.
# Runs fine until the division actually happens
x = 10
y = 0
result = x / y # ZeroDivisionError at runtime
The key takeaway: syntax errors must be fixed in your source code before the program can run. Exceptions can (and often should) be caught and handled while the program is running.
Why Error Handling Matters
Without error handling, a single unexpected condition can crash your entire application. Consider a web server that processes thousands of requests per minute. If one malformed request causes an unhandled exception, the whole server goes down. Good error handling provides:
- Resilience -- the program continues running even when individual operations fail.
- User-friendly feedback -- instead of a raw traceback, the user sees a helpful message.
- Debugging information -- errors are logged with context so developers can fix them.
- Resource safety -- files, database connections, and network sockets are properly closed even when something goes wrong.
What Happens When Exceptions Go Unhandled
When Python encounters an exception that no code catches, it terminates the program and prints a traceback -- a detailed report showing exactly where the error occurred.
def read_setting(settings, key):
return settings[key]
def configure():
config = {"host": "localhost", "port": 8080}
timeout = read_setting(config, "timeout")
return timeout
configure()
Output:
Traceback (most recent call last):
File "example.py", line 8, in <module>
configure()
File "example.py", line 6, in configure
timeout = read_setting(config, "timeout")
File "example.py", line 2, in read_setting
return settings[key]
KeyError: 'timeout'
Anatomy of a Traceback
A traceback reads bottom to top -- the last line is the actual error, and the lines above it show the chain of function calls that led there.
| Part | Meaning |
|---|---|
Traceback (most recent call last): | Header indicating this is a traceback |
File "example.py", line 8, in <module> | Where the top-level call was made |
File "example.py", line 6, in configure | The intermediate function call |
File "example.py", line 2, in read_setting | The function where the error occurred |
return settings[key] | The exact line of code that failed |
KeyError: 'timeout' | The exception type and its message |
Learning to read tracebacks is one of the most valuable debugging skills you can develop. Always start from the bottom and work your way up.
Common Built-in Exceptions
Python provides dozens of built-in exception types. Here is a comprehensive reference of the ones you will encounter most often.
| Exception | Description |
|---|---|
SyntaxError | Invalid Python syntax (caught at parse time) |
TypeError | Operation applied to an object of the wrong type |
ValueError | Right type but inappropriate value |
NameError | Local or global name not found |
IndexError | Sequence index out of range |
KeyError | Dictionary key not found |
AttributeError | Object does not have the requested attribute |
FileNotFoundError | File or directory does not exist |
ZeroDivisionError | Division or modulo by zero |
ImportError | Module import failed |
StopIteration | Iterator has no more items |
OverflowError | Arithmetic result too large to represent |
RecursionError | Maximum recursion depth exceeded |
PermissionError | Insufficient permissions for an operation |
OSError | Operating system related error (base for I/O errors) |
RuntimeError | Generic error that does not fit other categories |
NotImplementedError | Abstract method not yet implemented |
Let us look at code that triggers each one.
SyntaxError
# Missing closing parenthesis
print("hello"
Output:
SyntaxError: '(' was never closed
TypeError
# Adding incompatible types
result = "age: " + 25
Output:
TypeError: can only concatenate str (not "int") to str
# Calling a non-callable object
x = 42
x()
Output:
TypeError: 'int' object is not callable
ValueError
# Correct type (string), but wrong value for int conversion
number = int("hello")
Output:
ValueError: invalid literal for int() with base 10: 'hello'
# Unpacking the wrong number of values
a, b = [1, 2, 3]
Output:
ValueError: too many values to unpack (expected 2)
NameError
# Using a variable that was never defined
print(username)
Output:
NameError: name 'username' is not defined
IndexError
# Accessing beyond the list length
fruits = ["apple", "banana", "cherry"]
print(fruits[5])
Output:
IndexError: list index out of range
KeyError
# Accessing a dictionary key that does not exist
student = {"name": "Aarav", "age": 20}
print(student["grade"])
Output:
KeyError: 'grade'
AttributeError
# Calling a method that does not exist on the object
number = 42
number.append(10)
Output:
AttributeError: 'int' object has no attribute 'append'
FileNotFoundError
# Opening a file that does not exist
with open("nonexistent_file.txt", "r") as f:
content = f.read()
Output:
FileNotFoundError: [Errno 2] No such file or directory: 'nonexistent_file.txt'
ZeroDivisionError
# Dividing by zero
result = 100 / 0
Output:
ZeroDivisionError: division by zero
# Modulo by zero also triggers this
result = 10 % 0
Output:
ZeroDivisionError: integer modulo by zero
ImportError
# Importing a module that does not exist
import nonexistent_module
Output:
ModuleNotFoundError: No module named 'nonexistent_module'
# Importing a name that does not exist in a module
from math import square_root
Output:
ImportError: cannot import name 'square_root' from 'math'
StopIteration
# Manually calling next() past the end of an iterator
my_iter = iter([1, 2])
next(my_iter) # 1
next(my_iter) # 2
next(my_iter) # StopIteration
Output:
StopIteration
OverflowError
# Float overflow
import math
result = math.exp(1000)
Output:
OverflowError: math range error
RecursionError
# Infinite recursion
def infinite():
return infinite()
infinite()
Output:
RecursionError: maximum recursion depth exceeded
PermissionError
# Trying to write to a read-only location (OS dependent)
with open("/etc/shadow", "r") as f:
content = f.read()
Output:
PermissionError: [Errno 13] Permission denied: '/etc/shadow'
OSError
# General OS-level error (e.g., disk full, broken pipe)
import os
os.remove("/nonexistent/path/file.txt")
Output:
FileNotFoundError: [Errno 2] No such file or directory: '/nonexistent/path/file.txt'
Note: FileNotFoundError and PermissionError are both subclasses of OSError.
RuntimeError
# Generic runtime error (often raised manually)
raise RuntimeError("Something unexpected happened")
Output:
RuntimeError: Something unexpected happened
NotImplementedError
# Used in abstract base classes to signal missing implementations
class Shape:
def area(self):
raise NotImplementedError("Subclasses must implement area()")
class Circle(Shape):
pass
c = Circle()
c.area()
Output:
NotImplementedError: Subclasses must implement area()
The Exception Hierarchy
Python organizes all exceptions into a class hierarchy. Understanding this hierarchy is crucial because when you catch an exception type, you also catch all of its subclasses.
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ ├── FileExistsError
│ ├── IsADirectoryError
│ └── ConnectionError
│ ├── ConnectionRefusedError
│ ├── ConnectionResetError
│ └── BrokenPipeError
├── TypeError
├── ValueError
│ └── UnicodeError
├── AttributeError
├── NameError
├── ImportError
│ └── ModuleNotFoundError
├── RuntimeError
│ ├── RecursionError
│ └── NotImplementedError
├── StopIteration
└── Warning
├── DeprecationWarning
├── FutureWarning
└── UserWarning
Why the Hierarchy Matters
Because of inheritance, catching a parent class also catches all of its children.
# Catching LookupError catches both IndexError and KeyError
try:
my_dict = {"a": 1}
print(my_dict["z"]) # KeyError
except LookupError as e:
print(f"Lookup failed: {e}")
Output:
Lookup failed: 'z'
# Catching OSError catches FileNotFoundError, PermissionError, etc.
try:
with open("missing.txt") as f:
data = f.read()
except OSError as e:
print(f"OS error: {e}")
Output:
OS error: [Errno 2] No such file or directory: 'missing.txt'
BaseException vs Exception
Notice that BaseException sits at the very top, and Exception is one of its children.
Three siblings of Exception sit directly under BaseException:
SystemExit-- raised bysys.exit().KeyboardInterrupt-- raised when the user presses Ctrl+C.GeneratorExit-- raised when a generator is closed.
These are deliberately placed outside Exception so that a normal except Exception
block does not accidentally catch them. You almost never want to prevent Ctrl+C from working.
# This is safe -- KeyboardInterrupt is NOT caught
try:
while True:
pass
except Exception:
print("This won't catch Ctrl+C")
# This is dangerous -- catches everything, including Ctrl+C
try:
while True:
pass
except BaseException:
print("Even Ctrl+C is caught -- very bad practice!")
try/except Basics
The try/except statement is the foundation of error handling in Python. Code that might
fail goes inside the try block, and code that handles the failure goes in the except
block.
Basic Syntax
try:
# Code that might raise an exception
number = int(input("Enter a number: "))
print(f"You entered: {number}")
except ValueError:
# Code that runs if a ValueError is raised
print("That is not a valid number!")
If the user enters "abc", the int() call raises a ValueError. Python immediately
jumps to the except ValueError block, skipping any remaining code in the try block.
Catching Specific Exceptions
Always specify which exception you expect.
try:
result = 10 / int(input("Enter divisor: "))
print(f"Result: {result}")
except ZeroDivisionError:
print("Cannot divide by zero!")
except ValueError:
print("Please enter a valid integer!")
Why Bare except Is Bad
A bare except: (with no exception type) catches everything, including KeyboardInterrupt
and SystemExit. This makes your program very hard to debug and can prevent it from being
terminated properly.
# BAD -- never do this
try:
result = 10 / 0
except:
pass # Silently swallows ALL errors
# BETTER -- catch Exception at minimum
try:
result = 10 / 0
except Exception as e:
print(f"An error occurred: {e}")
# BEST -- catch the specific exception you expect
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero!")
Catching Multiple Exception Types in One Line
When you want the same handler for several exception types, use a tuple.
def parse_value(raw):
try:
return int(raw)
except (ValueError, TypeError):
return None
print(parse_value("42")) # 42
print(parse_value("hello")) # None
print(parse_value(None)) # None
Accessing the Exception Object
Use the as keyword to bind the exception to a variable so you can inspect it.
try:
int("xyz")
except ValueError as e:
print(f"Exception type : {type(e).__name__}")
print(f"Exception message: {e}")
print(f"Exception args : {e.args}")
Output:
Exception type : ValueError
Exception message: invalid literal for int() with base 10: 'xyz'
Exception args : ("invalid literal for int() with base 10: 'xyz'",)
Multiple except Blocks
When a try block might raise different kinds of exceptions, you can provide multiple
except blocks to handle each one differently.
def process_data(data, index):
try:
value = data[index]
result = 100 / value
formatted = "Result: " + str(result)
return formatted
except IndexError:
return "Error: index out of range"
except ZeroDivisionError:
return "Error: cannot divide by zero (value at index is 0)"
except TypeError:
return "Error: data contains non-numeric value"
numbers = [10, 0, "abc", 5]
print(process_data(numbers, 0)) # Result: 10.0
print(process_data(numbers, 1)) # Error: cannot divide by zero ...
print(process_data(numbers, 2)) # Error: data contains non-numeric value
print(process_data(numbers, 9)) # Error: index out of range
Order Matters: Specific Before General
Python checks except blocks from top to bottom and uses the first one that matches.
Because of the exception hierarchy, a parent class will match all of its children. If you
put the parent first, the more specific child handler will never run.
# WRONG -- the specific handler is never reached
try:
my_list = [1, 2, 3]
print(my_list[10])
except LookupError:
print("General lookup error") # This matches first
except IndexError:
print("Index out of range") # Never reached!
# CORRECT -- specific exceptions first, general ones last
try:
my_list = [1, 2, 3]
print(my_list[10])
except IndexError:
print("Index out of range") # Matches first
except LookupError:
print("General lookup error") # Catches KeyError and others
A Practical Ordering Example
import json
def load_config(filepath):
"""Load a JSON config file with thorough error handling."""
try:
with open(filepath, "r") as f:
config = json.load(f)
db_host = config["database"]["host"]
return db_host
except FileNotFoundError:
print(f"Config file not found: {filepath}")
except PermissionError:
print(f"No permission to read: {filepath}")
except json.JSONDecodeError as e:
print(f"Invalid JSON in {filepath}: {e}")
except KeyError as e:
print(f"Missing config key: {e}")
except OSError as e:
# Catches any other OS-level errors (after more specific ones)
print(f"OS error reading {filepath}: {e}")
except Exception as e:
# Last resort -- catches anything else that inherits from Exception
print(f"Unexpected error: {e}")
return None
The else Clause
The else block in a try statement runs only if no exception was raised in the try
block. It provides a clean way to separate the code that might fail from the code that
should run on success.
try:
number = int(input("Enter a number: "))
except ValueError:
print("Invalid input!")
else:
# This only runs if int() succeeded
print(f"The square of {number} is {number ** 2}")
Why Use else Instead of Putting Code in try?
You might wonder why not just put the success code at the end of the try block. The
reason is precision: code inside try is monitored for exceptions, and you only want to
catch exceptions from the lines you expect to fail.
# WITHOUT else -- accidental catch
try:
data = json.loads(raw_json)
# If process_data raises ValueError, it gets caught too!
process_data(data)
except ValueError:
print("Invalid JSON")
# WITH else -- only json.loads is protected
try:
data = json.loads(raw_json)
except ValueError:
print("Invalid JSON")
else:
# ValueError from process_data will NOT be caught here
# It will propagate normally, which is what we want
process_data(data)
A Complete Example
def get_user_age():
"""Prompt the user for their age with validation."""
try:
raw = input("Enter your age: ")
age = int(raw)
except ValueError:
print(f"'{raw}' is not a valid integer.")
return None
else:
if age < 0 or age > 150:
print("Age out of reasonable range.")
return None
print(f"Age recorded: {age}")
return age
The finally Clause
The finally block always runs, regardless of whether an exception was raised, whether
it was caught, or whether the function returns from inside the try or except blocks.
This makes it the perfect place for cleanup operations.
def read_file(path):
f = None
try:
f = open(path, "r")
return f.read()
except FileNotFoundError:
print(f"File not found: {path}")
return None
finally:
# This runs even after a return statement above
if f is not None:
f.close()
print("File closed.")
finally Runs Even When Exceptions Propagate
def risky_operation():
try:
print("Starting operation...")
result = 1 / 0 # Will raise ZeroDivisionError
return result
finally:
print("Cleanup: this always runs!")
try:
risky_operation()
except ZeroDivisionError:
print("Caught the error in the outer handler.")
Output:
Starting operation...
Cleanup: this always runs!
Caught the error in the outer handler.
Notice that finally ran even though the exception was not caught inside risky_operation.
Common Use Cases for finally
Closing database connections:
def query_database(sql):
connection = None
try:
connection = create_database_connection()
cursor = connection.cursor()
cursor.execute(sql)
return cursor.fetchall()
except DatabaseError as e:
print(f"Query failed: {e}")
return []
finally:
if connection is not None:
connection.close()
print("Database connection closed.")
Releasing locks:
import threading
lock = threading.Lock()
def critical_section():
lock.acquire()
try:
# Perform operations that require exclusive access
update_shared_resource()
finally:
lock.release() # Always release the lock
try/except/else/finally Together
All four blocks can be combined in a single try statement. Here is the execution flow.
try:
# Step 1: Code that might fail
...
except SomeException:
# Step 2a: Runs ONLY if SomeException was raised in try
...
else:
# Step 2b: Runs ONLY if NO exception was raised in try
...
finally:
# Step 3: ALWAYS runs, no matter what happened above
...
Execution Flow Summary
| Scenario | try | except | else | finally |
|---|---|---|---|---|
| No exception raised | Runs | Skipped | Runs | Runs |
| Matching exception raised | Runs (partial) | Runs | Skipped | Runs |
| Non-matching exception raised | Runs (partial) | Skipped | Skipped | Runs |
Complete Example: Safe File Processor
import json
def load_user_data(filepath):
"""Load and validate user data from a JSON file."""
data = None
try:
f = open(filepath, "r")
raw = f.read()
data = json.loads(raw)
except FileNotFoundError:
print(f"[ERROR] File not found: {filepath}")
return None
except json.JSONDecodeError as e:
print(f"[ERROR] Invalid JSON in {filepath}: {e}")
return None
else:
# Only runs if file was read and parsed successfully
print(f"[OK] Loaded {len(data)} records from {filepath}")
return data
finally:
# Always runs -- good for logging
print(f"[INFO] load_user_data('{filepath}') completed.")
# Test with different scenarios
result = load_user_data("users.json")
Another Example: Database Transaction
def transfer_funds(from_account, to_account, amount):
"""Transfer money between accounts with full error handling."""
connection = get_db_connection()
try:
connection.begin_transaction()
from_account.debit(amount)
to_account.credit(amount)
except InsufficientFundsError:
connection.rollback()
print(f"Transfer failed: insufficient funds.")
return False
except DatabaseError as e:
connection.rollback()
print(f"Transfer failed: database error -- {e}")
return False
else:
connection.commit()
print(f"Transferred {amount} successfully.")
return True
finally:
connection.close()
print("Database connection closed.")
Raising Exceptions
Use the raise keyword to throw an exception intentionally. This is how you signal that
something has gone wrong in your own code.
Raising Built-in Exceptions with Messages
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, got {type(age).__name__}")
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age cannot exceed 150")
return age
# These all raise exceptions:
# set_age("twenty") -> TypeError: Age must be an integer, got str
# set_age(-5) -> ValueError: Age cannot be negative
# set_age(200) -> ValueError: Age cannot exceed 150
Conditional Raising
Often you validate inputs at the top of a function and raise if something is wrong.
def send_email(to, subject, body):
if not to:
raise ValueError("Recipient email address is required")
if "@" not in to:
raise ValueError(f"Invalid email address: {to}")
if not subject:
raise ValueError("Email subject cannot be empty")
# ... proceed to send email ...
print(f"Email sent to {to}")
# Usage
try:
send_email("", "Hello", "Test body")
except ValueError as e:
print(f"Cannot send email: {e}")
Output:
Cannot send email: Recipient email address is required
Re-raising Exceptions
Sometimes you want to catch an exception, do something (like logging), and then let it
continue propagating. Use a bare raise inside the except block.
import logging
logger = logging.getLogger(__name__)
def process_payment(amount):
try:
# ... payment processing logic ...
if amount <= 0:
raise ValueError(f"Invalid payment amount: {amount}")
charge_credit_card(amount)
except ValueError:
logger.error(f"Payment processing failed for amount: {amount}")
raise # Re-raise the exact same exception
# The caller will still see the ValueError
try:
process_payment(-50)
except ValueError as e:
print(f"Payment error: {e}")
Raising a Different Exception
You can catch one exception and raise a different one.
def get_config_value(config, key):
try:
return config[key]
except KeyError:
raise RuntimeError(f"Required configuration key missing: '{key}'")
config = {"host": "localhost"}
try:
port = get_config_value(config, "port")
except RuntimeError as e:
print(e)
Output:
Required configuration key missing: 'port'
Custom Exceptions
While Python's built-in exceptions cover many situations, real applications often need domain-specific error types. Custom exceptions make your code more readable and allow callers to catch exactly the errors they care about.
Creating a Simple Custom Exception
All custom exceptions should inherit from Exception (not BaseException).
class InvalidEmailError(Exception):
"""Raised when an email address is invalid."""
pass
def validate_email(email):
if "@" not in email or "." not in email:
raise InvalidEmailError(f"Invalid email format: {email}")
return True
try:
validate_email("not-an-email")
except InvalidEmailError as e:
print(e)
Output:
Invalid email format: not-an-email
Adding Attributes to Custom Exceptions
Custom exceptions become truly powerful when you attach extra data to them.
class InsufficientFundsError(Exception):
"""Raised when an account has insufficient funds for a withdrawal."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
self.deficit = amount - balance
super().__init__(
f"Cannot withdraw {amount}: balance is {balance} "
f"(short by {self.deficit})"
)
# Usage
try:
raise InsufficientFundsError(balance=500, amount=750)
except InsufficientFundsError as e:
print(e)
print(f" Balance : {e.balance}")
print(f" Requested: {e.amount}")
print(f" Deficit : {e.deficit}")
Output:
Cannot withdraw 750: balance is 500 (short by 250)
Balance : 500
Requested: 750
Deficit : 250
Building an Exception Hierarchy for Your Application
For larger applications, create a hierarchy of exceptions. Start with a base exception for your app, then create specific ones that inherit from it.
# Base exception for the entire application
class AppError(Exception):
"""Base exception for MyApp."""
pass
# Authentication exceptions
class AuthError(AppError):
"""Base exception for authentication failures."""
pass
class InvalidCredentialsError(AuthError):
"""Raised when username/password combination is wrong."""
pass
class AccountLockedError(AuthError):
"""Raised when too many failed login attempts."""
def __init__(self, username, lockout_minutes):
self.username = username
self.lockout_minutes = lockout_minutes
super().__init__(
f"Account '{username}' is locked for {lockout_minutes} minutes"
)
# Data exceptions
class DataError(AppError):
"""Base exception for data-related failures."""
pass
class RecordNotFoundError(DataError):
"""Raised when a database record is not found."""
def __init__(self, model, record_id):
self.model = model
self.record_id = record_id
super().__init__(f"{model} with id={record_id} not found")
class DuplicateRecordError(DataError):
"""Raised when attempting to create a duplicate record."""
pass
Now callers can catch errors at different levels of specificity.
try:
authenticate(username, password)
except InvalidCredentialsError:
print("Wrong username or password.")
except AccountLockedError as e:
print(f"Account locked. Try again in {e.lockout_minutes} minutes.")
except AuthError:
print("Authentication failed for an unknown reason.")
except AppError:
print("An application error occurred.")
Custom str and repr
Customize how your exception appears when printed or in debugging contexts.
class ValidationError(Exception):
def __init__(self, field, value, message):
self.field = field
self.value = value
self.message = message
super().__init__(message)
def __str__(self):
return f"ValidationError on '{self.field}': {self.message} (got: {self.value!r})"
def __repr__(self):
return (
f"ValidationError(field={self.field!r}, "
f"value={self.value!r}, message={self.message!r})"
)
err = ValidationError("age", -5, "must be a positive integer")
print(str(err))
# ValidationError on 'age': must be a positive integer (got: -5)
print(repr(err))
# ValidationError(field='age', value=-5, message='must be a positive integer')
Exception Chaining
Python 3 supports exception chaining, which lets you preserve the original exception when raising a new one. This provides a complete trail of what went wrong.
Explicit Chaining with raise ... from ...
Use raise NewException() from original_exception to explicitly link the new exception to
the original cause.
class ConfigError(Exception):
pass
def load_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError as e:
raise ConfigError(f"Cannot load configuration from {path}") from e
try:
load_config("missing_config.yaml")
except ConfigError as e:
print(f"Error: {e}")
print(f"Caused by: {e.__cause__}")
Output:
Error: Cannot load configuration from missing_config.yaml
Caused by: [Errno 2] No such file or directory: 'missing_config.yaml'
The full traceback shows both exceptions with a clear "caused by" chain.
Implicit Chaining (context)
When you raise an exception inside an except block without using from, Python
automatically sets __context__ on the new exception.
try:
int("abc")
except ValueError:
# This new exception implicitly links back to the ValueError
raise RuntimeError("Failed to process input")
The traceback will show:
During handling of the above exception, another exception occurred:
Suppressing Context with from None
Sometimes the original exception is not useful or contains sensitive information. Use
from None to suppress the chain.
def get_user(user_id):
try:
return database_lookup(user_id)
except DatabaseInternalError:
# Don't expose database internals to the caller
raise UserNotFoundError(f"User {user_id} not found") from None
Without from None, the traceback would show the original DatabaseInternalError.
With from None, only the UserNotFoundError is shown.
Comparing cause and context
| Attribute | Set by | Meaning |
|---|---|---|
__cause__ | raise X from Y | The explicit cause of this exception |
__context__ | Implicit (raising inside except) | The exception that was being handled |
__suppress_context__ | from None sets this to True | Whether to hide the context in tracebacks |
Context Managers and Exceptions
The with statement provides a clean way to manage resources that need setup and teardown,
and it integrates naturally with exception handling.
How the with Statement Handles Exceptions
When you use with, Python calls __enter__() at the start and __exit__() at the end.
If an exception occurs inside the with block, it is passed to __exit__(), which can
choose to suppress it or let it propagate.
# The 'with' statement ensures the file is closed even if an exception occurs
with open("data.txt", "r") as f:
content = f.read()
# Even if an exception happens here, f.close() is called automatically
This is equivalent to:
f = open("data.txt", "r")
try:
content = f.read()
finally:
f.close()
Writing a Custom Context Manager
Implement __enter__ and __exit__ methods on a class.
class Timer:
"""A context manager that measures execution time."""
def __enter__(self):
import time
self.start = time.time()
return self # This is bound to the 'as' variable
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.time() - self.start
print(f"Elapsed time: {self.elapsed:.4f} seconds")
# Return False (or None) to let any exception propagate
# Return True to suppress the exception
return False
# Usage
with Timer() as t:
total = sum(range(1_000_000))
print(f"Sum: {total}")
Output:
Sum: 499999500000
Elapsed time: 0.0312 seconds
The exit Parameters
The __exit__ method receives three arguments about any exception that occurred:
| Parameter | Value when no exception | Value when exception occurs |
|---|---|---|
exc_type | None | The exception class (e.g., ValueError) |
exc_val | None | The exception instance |
exc_tb | None | The traceback object |
class SuppressErrors:
"""A context manager that suppresses specified exception types."""
def __init__(self, *exception_types):
self.exception_types = exception_types
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None and issubclass(exc_type, self.exception_types):
print(f"Suppressed {exc_type.__name__}: {exc_val}")
return True # Suppress the exception
return False # Let other exceptions propagate
# Usage
with SuppressErrors(ZeroDivisionError, ValueError):
result = 10 / 0 # Suppressed instead of crashing
print("Program continues!")
Output:
Suppressed ZeroDivisionError: division by zero
Program continues!
Using contextlib.contextmanager
The contextlib module provides a decorator that lets you write context managers as
generator functions, which is often more readable than a full class.
from contextlib import contextmanager
@contextmanager
def managed_file(path, mode="r"):
"""Open a file and ensure it is closed afterward."""
f = None
try:
f = open(path, mode)
yield f # Everything before yield is __enter__
except FileNotFoundError:
print(f"File not found: {path}")
yield None # Provide a fallback value
finally:
if f is not None:
f.close() # Everything in finally is __exit__
print(f"Closed: {path}")
# Usage
with managed_file("example.txt") as f:
if f is not None:
content = f.read()
A Practical Context Manager: Database Transaction
from contextlib import contextmanager
@contextmanager
def transaction(connection):
"""Manage a database transaction with automatic commit/rollback."""
try:
yield connection
connection.commit()
print("Transaction committed.")
except Exception:
connection.rollback()
print("Transaction rolled back.")
raise # Re-raise so the caller knows something went wrong
finally:
connection.close()
# Usage
# with transaction(get_connection()) as conn:
# conn.execute("INSERT INTO users ...")
# conn.execute("UPDATE accounts ...")
Assertions
The assert statement is a debugging aid that tests a condition and raises an
AssertionError if the condition is False.
Basic Syntax
assert condition, "Optional error message"
This is roughly equivalent to:
if not condition:
raise AssertionError("Optional error message")
Examples
def calculate_average(numbers):
assert len(numbers) > 0, "Cannot calculate average of empty list"
return sum(numbers) / len(numbers)
print(calculate_average([10, 20, 30])) # 20.0
print(calculate_average([])) # AssertionError!
Output:
20.0
AssertionError: Cannot calculate average of empty list
def apply_discount(price, discount_percent):
assert 0 <= discount_percent <= 100, (
f"Discount must be between 0 and 100, got {discount_percent}"
)
return price * (1 - discount_percent / 100)
print(apply_discount(100, 20)) # 80.0
print(apply_discount(100, 150)) # AssertionError!
When to Use Assertions
Use assertions to check conditions that should never happen if the code is correct. They are internal sanity checks for the developer, not validation of user input.
Good uses:
- Checking function preconditions during development
- Verifying invariants inside algorithms
- Ensuring impossible states are truly impossible
def merge_sorted_lists(a, b):
# These should always be true if the caller followed the contract
assert all(a[i] <= a[i+1] for i in range(len(a)-1)), "List 'a' is not sorted"
assert all(b[i] <= b[i+1] for i in range(len(b)-1)), "List 'b' is not sorted"
# ... merge logic ...
When NOT to Use Assertions
Never use assertions for:
- Validating user input
- Checking data from external sources
- Enforcing business rules in production
The reason is critical: assertions can be disabled.
The -O Flag Disables Assertions
When you run Python with the -O (optimize) flag, all assert statements are removed
from the bytecode. They simply do not execute.
python -O my_program.py
This means any validation you put in an assert will silently vanish in optimized mode.
# DANGEROUS -- if run with python -O, no validation occurs
def withdraw(account, amount):
assert amount > 0, "Amount must be positive" # Removed by -O!
account.balance -= amount
# SAFE -- proper validation that always runs
def withdraw(account, amount):
if amount <= 0:
raise ValueError("Amount must be positive") # Always enforced
account.balance -= amount
Logging Exceptions
In production applications, you should log exceptions rather than printing them. The
logging module provides a structured way to record errors.
Basic Logging Setup
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
Using logger.exception()
The logger.exception() method automatically includes the full traceback in the log output.
It should be called from inside an except block.
import logging
logger = logging.getLogger(__name__)
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
logger.exception("Division by zero in divide(%s, %s)", a, b)
return None
result = divide(10, 0)
Output (in the log):
2026-03-15 10:30:45 - __main__ - ERROR - Division by zero in divide(10, 0)
Traceback (most recent call last):
File "example.py", line 7, in divide
return a / b
ZeroDivisionError: division by zero
Different Logging Levels
try:
process_data(data)
except ValueError as e:
logger.warning("Skipping invalid data: %s", e)
except ConnectionError as e:
logger.error("Failed to connect: %s", e)
except Exception:
logger.exception("Unexpected error during data processing")
| Level | When to use |
|---|---|
logger.debug() | Detailed diagnostic info |
logger.info() | General operational messages |
logger.warning() | Something unexpected but recoverable |
logger.error() | A failure that prevents an operation |
logger.exception() | Like error() but includes the traceback |
logger.critical() | A severe failure; the program may not continue |
The traceback Module
For more control over traceback formatting, use the traceback module.
import traceback
def risky_operation():
return 1 / 0
try:
risky_operation()
except ZeroDivisionError:
# Get the traceback as a string
tb_string = traceback.format_exc()
print("Captured traceback:")
print(tb_string)
Output:
Captured traceback:
Traceback (most recent call last):
File "example.py", line 7, in <module>
risky_operation()
File "example.py", line 4, in risky_operation
return 1 / 0
ZeroDivisionError: division by zero
import traceback
import sys
try:
int("abc")
except ValueError:
# Get structured traceback info
exc_type, exc_value, exc_tb = sys.exc_info()
# Format just the stack frames
frames = traceback.extract_tb(exc_tb)
for frame in frames:
print(f" File: {frame.filename}, Line: {frame.lineno}, "
f"Function: {frame.name}")
LBYL vs EAFP
Two fundamental approaches to handling potential errors exist in programming. Python strongly favors one of them.
Look Before You Leap (LBYL)
Check for conditions before attempting an operation.
# LBYL style
def get_value_lbyl(dictionary, key):
if key in dictionary:
return dictionary[key]
else:
return None
# LBYL with files
import os
def read_file_lbyl(path):
if os.path.exists(path):
if os.path.isfile(path):
if os.access(path, os.R_OK):
with open(path) as f:
return f.read()
else:
print("No read permission")
else:
print("Path is not a file")
else:
print("File does not exist")
return None
Easier to Ask Forgiveness than Permission (EAFP)
Just try the operation and handle the exception if it fails.
# EAFP style
def get_value_eafp(dictionary, key):
try:
return dictionary[key]
except KeyError:
return None
# EAFP with files
def read_file_eafp(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
print("File does not exist")
except PermissionError:
print("No read permission")
except IsADirectoryError:
print("Path is a directory, not a file")
return None
Why Python Prefers EAFP
| Aspect | LBYL | EAFP |
|---|---|---|
| Readability | Deeply nested conditions | Flat try/except structure |
| Race conditions | Check and action are separate; state can change between them | Atomic -- the operation either succeeds or fails |
| Performance (happy path) | Always pays the cost of checking | Slightly faster when exceptions are rare |
| Performance (error path) | Fast -- just a condition check | Slower -- exception handling has overhead |
| Pythonic? | Less idiomatic | More idiomatic |
The race condition issue is particularly important. Consider file operations:
# LBYL -- race condition possible
# Another process could delete the file between os.path.exists and open()
if os.path.exists("data.txt"):
f = open("data.txt") # Could still fail!
# EAFP -- no race condition
try:
f = open("data.txt")
except FileNotFoundError:
handle_missing_file()
A Side-by-Side Comparison
# Converting a string to integer
# LBYL
def to_int_lbyl(value):
if isinstance(value, str) and value.lstrip("-").isdigit():
return int(value)
return None
# EAFP
def to_int_eafp(value):
try:
return int(value)
except (ValueError, TypeError):
return None
# The EAFP version handles more edge cases correctly:
print(to_int_lbyl(" 42 ")) # None (spaces cause isdigit to fail)
print(to_int_eafp(" 42 ")) # 42 (int() handles whitespace)
Best Practices
1. Catch Specific Exceptions
Always catch the most specific exception type possible.
# Bad -- too broad
try:
process(data)
except Exception:
pass
# Good -- specific
try:
process(data)
except ValueError as e:
handle_invalid_data(e)
except ConnectionError as e:
handle_network_failure(e)
2. Do Not Silence Exceptions
At minimum, log the error. Never use except: pass in production code.
# Terrible -- errors vanish without a trace
try:
important_operation()
except:
pass
# Acceptable -- log and continue
try:
optional_operation()
except SpecificError as e:
logger.warning("Optional step failed: %s", e)
3. Use Custom Exceptions for Business Logic
Custom exceptions make error handling more meaningful and maintainable.
# Vague
raise ValueError("Not enough inventory")
# Clear
class InsufficientInventoryError(Exception):
def __init__(self, product, requested, available):
self.product = product
self.requested = requested
self.available = available
super().__init__(
f"Cannot fulfill order: requested {requested} of '{product}', "
f"only {available} available"
)
4. Keep try Blocks Small
Only wrap the code that might fail, not your entire function.
# Bad -- the try block is too big; any line could trigger the except
try:
data = load_data()
cleaned = clean_data(data)
result = analyze(cleaned)
report = generate_report(result)
send_email(report)
except Exception as e:
print(f"Something failed: {e}")
# Good -- each risky operation has its own handler
try:
data = load_data()
except FileNotFoundError:
data = load_default_data()
cleaned = clean_data(data) # Let this raise naturally if it fails
result = analyze(cleaned)
try:
send_email(generate_report(result))
except ConnectionError:
save_report_locally(result)
5. Log, Do Not Print, in Production
import logging
logger = logging.getLogger(__name__)
# Development -- print is fine for debugging
print(f"Debug: value = {value}")
# Production -- use structured logging
logger.info("Processing value: %s", value)
logger.error("Failed to process value: %s", value, exc_info=True)
6. Use Context Managers for Resource Cleanup
Prefer with statements over manual try/finally for resource management.
# Manual -- error prone, easy to forget
f = open("data.txt")
try:
content = f.read()
finally:
f.close()
# Context manager -- clean and safe
with open("data.txt") as f:
content = f.read()
7. Document the Exceptions Your Functions Raise
def fetch_user(user_id):
"""Fetch a user from the database.
Args:
user_id: The unique identifier of the user.
Returns:
A User object.
Raises:
ValueError: If user_id is not a positive integer.
UserNotFoundError: If no user exists with the given ID.
DatabaseConnectionError: If the database is unreachable.
"""
if not isinstance(user_id, int) or user_id <= 0:
raise ValueError(f"user_id must be a positive integer, got {user_id!r}")
# ...
Practical Examples
Example 1: Robust User Input Validator
class ValidationError(Exception):
"""Raised when input validation fails."""
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
class InputValidator:
"""Validates user input with detailed error reporting."""
def __init__(self):
self.errors = []
def validate_required(self, field, value):
"""Check that a field is not empty."""
if value is None or (isinstance(value, str) and value.strip() == ""):
self.errors.append(ValidationError(field, "This field is required"))
return False
return True
def validate_integer(self, field, value, min_val=None, max_val=None):
"""Check that a field is a valid integer within an optional range."""
try:
num = int(value)
except (ValueError, TypeError):
self.errors.append(
ValidationError(field, f"Must be an integer, got {value!r}")
)
return False
if min_val is not None and num < min_val:
self.errors.append(
ValidationError(field, f"Must be at least {min_val}, got {num}")
)
return False
if max_val is not None and num > max_val:
self.errors.append(
ValidationError(field, f"Must be at most {max_val}, got {num}")
)
return False
return True
def validate_email(self, field, value):
"""Basic email format validation."""
if not self.validate_required(field, value):
return False
if "@" not in value or "." not in value.split("@")[-1]:
self.errors.append(
ValidationError(field, f"Invalid email format: {value!r}")
)
return False
return True
def is_valid(self):
"""Return True if no validation errors were found."""
return len(self.errors) == 0
def get_error_report(self):
"""Return a formatted string of all validation errors."""
if self.is_valid():
return "No errors."
lines = ["Validation failed:"]
for err in self.errors:
lines.append(f" - {err.field}: {err.message}")
return "\n".join(lines)
# Usage
validator = InputValidator()
validator.validate_required("name", "")
validator.validate_integer("age", "abc")
validator.validate_integer("score", "150", min_val=0, max_val=100)
validator.validate_email("email", "not-an-email")
print(validator.get_error_report())
Output:
Validation failed:
- name: This field is required
- age: Must be an integer, got 'abc'
- score: Must be at most 100, got 150
- email: Invalid email format: 'not-an-email'
Example 2: Safe File Processor
import json
import logging
import os
logger = logging.getLogger(__name__)
class FileProcessingError(Exception):
"""Raised when file processing fails."""
def __init__(self, filepath, reason):
self.filepath = filepath
self.reason = reason
super().__init__(f"Failed to process '{filepath}': {reason}")
def process_json_files(directory):
"""Process all JSON files in a directory, collecting results and errors."""
results = []
errors = []
try:
filenames = os.listdir(directory)
except FileNotFoundError:
raise FileProcessingError(directory, "Directory does not exist")
except PermissionError:
raise FileProcessingError(directory, "No permission to read directory")
json_files = [f for f in filenames if f.endswith(".json")]
if not json_files:
logger.warning("No JSON files found in %s", directory)
return results, errors
for filename in json_files:
filepath = os.path.join(directory, filename)
try:
with open(filepath, "r") as f:
data = json.load(f)
except json.JSONDecodeError as e:
error_msg = f"Invalid JSON: {e}"
errors.append(FileProcessingError(filepath, error_msg))
logger.warning("Skipping %s: %s", filename, error_msg)
continue
except PermissionError:
error_msg = "Permission denied"
errors.append(FileProcessingError(filepath, error_msg))
logger.warning("Skipping %s: %s", filename, error_msg)
continue
except OSError as e:
error_msg = f"OS error: {e}"
errors.append(FileProcessingError(filepath, error_msg))
logger.warning("Skipping %s: %s", filename, error_msg)
continue
else:
results.append({"file": filename, "data": data})
logger.info("Successfully processed %s", filename)
return results, errors
# Usage
# results, errors = process_json_files("./data")
# print(f"Processed: {len(results)}, Errors: {len(errors)}")
Example 3: API Response Handler
import json
import time
import logging
logger = logging.getLogger(__name__)
class APIError(Exception):
"""Base exception for API errors."""
def __init__(self, status_code, message, response_body=None):
self.status_code = status_code
self.message = message
self.response_body = response_body
super().__init__(f"API Error {status_code}: {message}")
class RateLimitError(APIError):
"""Raised when the API returns 429 Too Many Requests."""
def __init__(self, retry_after=60):
self.retry_after = retry_after
super().__init__(429, f"Rate limited. Retry after {retry_after}s")
class AuthenticationError(APIError):
"""Raised when the API returns 401 or 403."""
pass
def parse_api_response(response_text, expected_keys=None):
"""Parse and validate an API response.
Args:
response_text: The raw response body as a string.
expected_keys: Optional list of keys that must be present in the response.
Returns:
The parsed response data as a dictionary.
Raises:
ValueError: If the response is not valid JSON.
KeyError: If expected keys are missing from the response.
"""
# Step 1: Parse JSON
try:
data = json.loads(response_text)
except json.JSONDecodeError as e:
raise ValueError(f"Response is not valid JSON: {e}") from e
# Step 2: Check for API-level errors
if isinstance(data, dict) and "error" in data:
error_info = data["error"]
status = error_info.get("code", 500)
message = error_info.get("message", "Unknown error")
if status == 429:
retry_after = error_info.get("retry_after", 60)
raise RateLimitError(retry_after)
elif status in (401, 403):
raise AuthenticationError(status, message)
else:
raise APIError(status, message, response_body=data)
# Step 3: Validate expected keys
if expected_keys:
missing = [k for k in expected_keys if k not in data]
if missing:
raise KeyError(f"Response missing expected keys: {missing}")
return data
def fetch_with_retry(url, max_retries=3, backoff_factor=2):
"""Fetch data from an API with retry logic.
Simulated for this example -- replace the inner try with actual HTTP calls.
"""
last_exception = None
for attempt in range(1, max_retries + 1):
try:
logger.info("Attempt %d: fetching %s", attempt, url)
# Simulated API call -- replace with actual HTTP request
# response = requests.get(url)
# response_text = response.text
response_text = '{"users": [{"id": 1, "name": "Aarav"}]}'
data = parse_api_response(response_text, expected_keys=["users"])
return data
except RateLimitError as e:
logger.warning("Rate limited. Waiting %ds...", e.retry_after)
time.sleep(e.retry_after)
last_exception = e
except AuthenticationError as e:
# Don't retry auth failures -- they won't fix themselves
logger.error("Authentication failed: %s", e)
raise
except (ValueError, ConnectionError) as e:
wait_time = backoff_factor ** attempt
logger.warning(
"Attempt %d failed: %s. Retrying in %ds...",
attempt, e, wait_time
)
time.sleep(wait_time)
last_exception = e
raise APIError(
0, f"Failed after {max_retries} attempts: {last_exception}"
)
# Usage
# data = fetch_with_retry("https://api.example.com/users")
# print(data["users"])
Example 4: Bank Account with Custom Exceptions
class BankError(Exception):
"""Base exception for banking operations."""
pass
class InsufficientFundsError(BankError):
"""Raised when a withdrawal exceeds the account balance."""
def __init__(self, account_number, balance, amount):
self.account_number = account_number
self.balance = balance
self.amount = amount
self.deficit = amount - balance
super().__init__(
f"Account {account_number}: cannot withdraw {amount:.2f}, "
f"balance is {balance:.2f} (short by {self.deficit:.2f})"
)
class InvalidAmountError(BankError):
"""Raised when a transaction amount is invalid."""
def __init__(self, amount, reason="must be positive"):
self.amount = amount
self.reason = reason
super().__init__(f"Invalid amount {amount}: {reason}")
class AccountClosedError(BankError):
"""Raised when performing operations on a closed account."""
def __init__(self, account_number):
self.account_number = account_number
super().__init__(f"Account {account_number} is closed")
class DailyLimitExceededError(BankError):
"""Raised when a withdrawal exceeds the daily limit."""
def __init__(self, limit, attempted):
self.limit = limit
self.attempted = attempted
super().__init__(
f"Daily withdrawal limit ({limit:.2f}) exceeded. "
f"Attempted: {attempted:.2f}"
)
class BankAccount:
"""A bank account with robust error handling."""
DAILY_WITHDRAWAL_LIMIT = 10000.00
def __init__(self, account_number, owner, initial_balance=0):
if initial_balance < 0:
raise InvalidAmountError(initial_balance, "initial balance cannot be negative")
self.account_number = account_number
self.owner = owner
self.balance = initial_balance
self.is_open = True
self.daily_withdrawn = 0.0
self.transaction_history = []
def _check_open(self):
"""Ensure the account is open."""
if not self.is_open:
raise AccountClosedError(self.account_number)
def _validate_amount(self, amount):
"""Validate that a transaction amount is positive."""
if not isinstance(amount, (int, float)):
raise TypeError(f"Amount must be numeric, got {type(amount).__name__}")
if amount <= 0:
raise InvalidAmountError(amount)
def deposit(self, amount):
"""Deposit money into the account.
Raises:
AccountClosedError: If the account is closed.
InvalidAmountError: If the amount is not positive.
TypeError: If the amount is not numeric.
"""
self._check_open()
self._validate_amount(amount)
self.balance += amount
self.transaction_history.append(("deposit", amount, self.balance))
return self.balance
def withdraw(self, amount):
"""Withdraw money from the account.
Raises:
AccountClosedError: If the account is closed.
InvalidAmountError: If the amount is not positive.
InsufficientFundsError: If the balance is too low.
DailyLimitExceededError: If the daily limit would be exceeded.
TypeError: If the amount is not numeric.
"""
self._check_open()
self._validate_amount(amount)
if self.daily_withdrawn + amount > self.DAILY_WITHDRAWAL_LIMIT:
raise DailyLimitExceededError(
self.DAILY_WITHDRAWAL_LIMIT,
self.daily_withdrawn + amount
)
if amount > self.balance:
raise InsufficientFundsError(
self.account_number, self.balance, amount
)
self.balance -= amount
self.daily_withdrawn += amount
self.transaction_history.append(("withdrawal", amount, self.balance))
return self.balance
def close(self):
"""Close the account."""
self._check_open()
if self.balance > 0:
print(f"Returning remaining balance: {self.balance:.2f}")
self.is_open = False
self.transaction_history.append(("closed", 0, self.balance))
def get_statement(self):
"""Return a formatted account statement."""
lines = [f"Statement for Account {self.account_number} ({self.owner})"]
lines.append("-" * 50)
for action, amount, balance in self.transaction_history:
if action == "deposit":
lines.append(f" + {amount:>10.2f} Balance: {balance:.2f}")
elif action == "withdrawal":
lines.append(f" - {amount:>10.2f} Balance: {balance:.2f}")
elif action == "closed":
lines.append(f" [Account Closed] Final: {balance:.2f}")
lines.append("-" * 50)
lines.append(f" Current Balance: {self.balance:.2f}")
return "\n".join(lines)
# Demonstration
account = BankAccount("ACC-001", "Aarav Sharma", 5000.00)
# Successful operations
try:
account.deposit(2000)
print(f"Balance after deposit: {account.balance:.2f}")
except BankError as e:
print(f"Error: {e}")
try:
account.withdraw(1500)
print(f"Balance after withdrawal: {account.balance:.2f}")
except BankError as e:
print(f"Error: {e}")
# Trigger InsufficientFundsError
try:
account.withdraw(100000)
except InsufficientFundsError as e:
print(f"\nInsufficient funds!")
print(f" Requested: {e.amount:.2f}")
print(f" Available: {e.balance:.2f}")
print(f" Short by : {e.deficit:.2f}")
# Trigger InvalidAmountError
try:
account.deposit(-500)
except InvalidAmountError as e:
print(f"\nInvalid amount: {e}")
# Close and try to use
account.close()
try:
account.deposit(100)
except AccountClosedError as e:
print(f"\nCannot deposit: {e}")
# Print statement
print("\n" + account.get_statement())
Output:
Balance after deposit: 7000.00
Balance after withdrawal: 5500.00
Insufficient funds!
Requested: 100000.00
Available: 5500.00
Short by : 94500.00
Invalid amount: Invalid amount -500: must be positive
Returning remaining balance: 5500.00
Cannot deposit: Account ACC-001 is closed
Statement for Account ACC-001 (Aarav Sharma)
--------------------------------------------------
+ 2000.00 Balance: 7000.00
- 1500.00 Balance: 5500.00
[Account Closed] Final: 5500.00
--------------------------------------------------
Current Balance: 5500.00
Common Mistakes
Understanding what not to do is just as important as knowing the right patterns. Here are the most common exception handling mistakes and how to fix them.
Mistake 1: Bare except
# BAD -- catches everything including KeyboardInterrupt and SystemExit
try:
data = fetch_data()
except:
pass
# FIX -- catch specific exceptions
try:
data = fetch_data()
except ConnectionError as e:
logger.error("Connection failed: %s", e)
data = cached_data()
Mistake 2: Catching Too Broadly
# BAD -- catches things you did not intend to handle
try:
user = get_user(user_id)
email = user.email
send_notification(email)
except Exception:
print("User not found") # Wrong! The error might be something else entirely
# FIX -- catch only what you expect
try:
user = get_user(user_id)
except UserNotFoundError:
print("User not found")
else:
send_notification(user.email)
Mistake 3: Using Exceptions for Normal Flow Control
Exceptions should represent exceptional conditions, not expected program flow.
# BAD -- using exception as a loop termination signal
def find_item(items, target):
try:
index = 0
while True:
if items[index] == target:
return index
index += 1
except IndexError:
return -1
# FIX -- use normal control flow
def find_item(items, target):
for index, item in enumerate(items):
if item == target:
return index
return -1
However, note that Python does use StopIteration internally to end iteration -- this is
an accepted Pythonic pattern, not something you should avoid.
Mistake 4: Ignoring Exceptions Entirely
# BAD -- the error vanishes and you spend hours debugging why data is None
try:
data = process(raw_input)
except ValueError:
pass # What happened? No one knows.
# FIX -- at minimum, log the error
try:
data = process(raw_input)
except ValueError as e:
logger.warning("Failed to process input: %s", e)
data = None # Explicit fallback with a logged reason
Mistake 5: Catching and Re-raising Incorrectly
# BAD -- creates a new exception, losing the original traceback
try:
result = compute()
except ValueError as e:
raise ValueError(str(e)) # New exception object -- traceback lost
# FIX -- use bare raise to preserve the traceback
try:
result = compute()
except ValueError:
logger.error("Computation failed")
raise # Same exception, original traceback preserved
# ALSO GOOD -- chain exceptions to preserve context
try:
result = compute()
except ValueError as e:
raise ComputationError("Processing failed") from e
Mistake 6: Returning Inside finally
# BAD -- the return in finally silently swallows the exception
def bad_example():
try:
raise ValueError("Something went wrong")
finally:
return 42 # The ValueError is silently suppressed!
result = bad_example()
print(result) # 42, with no indication that an error occurred
# FIX -- never return from a finally block
def good_example():
try:
raise ValueError("Something went wrong")
except ValueError:
return None
finally:
print("Cleanup done.") # Cleanup only, no return
Practice Exercises
Exercise 1: Safe Division Calculator
Write a function safe_divide(a, b) that:
- Returns the result of
a / bif successful. - Handles
ZeroDivisionErrorby returningNone. - Handles
TypeError(e.g.,safe_divide("10", 2)) by converting both to floats first and retrying. If conversion fails, returnNone. - Prints a message indicating what happened in each case.
# Expected behavior:
# safe_divide(10, 3) -> 3.333...
# safe_divide(10, 0) -> None (printed: "Cannot divide by zero")
# safe_divide("10", "2") -> 5.0 (printed: "Converted strings to numbers")
# safe_divide("abc", 2) -> None (printed: "Cannot convert to numbers")
Exercise 2: Robust CSV Reader
Write a function read_csv_safely(filepath) that:
- Opens and reads a CSV file.
- Handles
FileNotFoundError,PermissionError, andUnicodeDecodeError. - Returns a list of rows (each row is a list of strings) on success.
- Returns an empty list on failure with an appropriate logged message.
- Uses a
finallyblock to print whether the operation succeeded or failed.
Exercise 3: Custom Exception Hierarchy
Create an exception hierarchy for an e-commerce application:
ShopError(base)ProductError(ShopError)with subclassesProductNotFoundErrorandOutOfStockErrorPaymentError(ShopError)with subclassesPaymentDeclinedErrorandInvalidCardErrorOutOfStockErrorshould store the product name and requested quantity.- Write a function
place_order(product, quantity, card_number)that raises different exceptions based on input conditions, and a caller that handles each one.
Exercise 4: Retry Decorator
Write a decorator @retry(max_attempts=3, exceptions=(Exception,), delay=1) that:
- Retries the decorated function up to
max_attemptstimes. - Only retries on the specified exception types.
- Waits
delayseconds between retries. - Raises the last exception if all attempts fail.
# Expected usage:
@retry(max_attempts=3, exceptions=(ConnectionError,), delay=2)
def connect_to_server():
# ... might raise ConnectionError ...
pass
Exercise 5: Context Manager for Temporary Changes
Write a context manager class TemporaryValue that:
- Takes an object, an attribute name, and a temporary value.
- Sets the attribute to the temporary value on entering the context.
- Restores the original value on exiting, even if an exception occurred.
# Expected usage:
class Config:
debug = False
config = Config()
print(config.debug) # False
with TemporaryValue(config, "debug", True):
print(config.debug) # True
print(config.debug) # False (restored)
Exercise 6: Input Validator with Multiple Rules
Write a Validator class where you can register validation rules for fields and then
validate a dictionary of data. If validation fails, it should raise a custom
ValidationError that contains all the errors (not just the first one).
# Expected usage:
v = Validator()
v.add_rule("name", required=True, min_length=2)
v.add_rule("age", required=True, value_type=int, min_value=0, max_value=150)
v.add_rule("email", required=True, pattern=r".+@.+\..+")
try:
v.validate({"name": "", "age": -5, "email": "bad"})
except ValidationError as e:
print(e.errors)
# ["name: must be at least 2 characters",
# "age: must be at least 0",
# "email: does not match required pattern"]
Summary
In this chapter, you learned the full picture of exception handling in Python:
- Exceptions vs errors: syntax errors are caught at parse time; exceptions occur at runtime and can be caught and handled.
- Built-in exception types: Python provides a rich set of exception classes organized
in a hierarchy, from
BaseExceptiondown throughExceptionand its many subclasses. - try/except: the core mechanism for catching exceptions. Always catch specific types
rather than using bare
except. - Multiple except blocks: handle different exceptions differently, with specific exceptions listed before general ones.
- else clause: runs only when no exception was raised, keeping success code separate from error-prone code.
- finally clause: always runs regardless of what happened, making it ideal for resource cleanup.
- Raising exceptions: use
raiseto signal errors in your own code, and bareraiseto re-raise the current exception. - Custom exceptions: create your own exception classes for domain-specific errors, organized in a meaningful hierarchy.
- Exception chaining: use
raise ... from ...to preserve the original cause, orfrom Noneto suppress it. - Context managers: the
withstatement provides clean, exception-safe resource management. - Assertions: useful for development-time sanity checks, but never for production
validation (they can be disabled with
-O). - Logging: use the
loggingmodule instead ofprintfor production error reporting. - EAFP vs LBYL: Python favors "try it and handle the exception" over "check every condition beforehand."
- Best practices: catch specific exceptions, keep try blocks small, document raised exceptions, and never silently ignore errors.
Robust error handling is what separates fragile scripts from production-ready software. Master these patterns, and your Python programs will be resilient, debuggable, and a pleasure to maintain.