Python Exception Handling: try, except, finally
Exceptions are Python's way of signaling that something went wrong during program execution. They occur when code encounters runtime errors: dividing by zero, accessing missing dictionary keys,...
Key Insights
- Exception handling prevents crashes by catching runtime errors, but overusing generic
exceptblocks creates silent failures that are harder to debug than letting the program crash with a clear error message. - The
finallyblock guarantees cleanup code execution regardless of exceptions, but context managers (withstatements) are almost always a better choice for resource management. - Catching specific exceptions near where they can be meaningfully handled produces more maintainable code than catching everything at the top level—let exceptions bubble up until something can actually fix the problem.
Introduction to Exception Handling
Exceptions are Python’s way of signaling that something went wrong during program execution. They occur when code encounters runtime errors: dividing by zero, accessing missing dictionary keys, opening nonexistent files, or converting incompatible data types. Without proper handling, exceptions terminate your program immediately with a traceback.
The difference between amateur and production-ready code often comes down to exception handling. Unhandled exceptions crash your application and frustrate users. Properly handled exceptions allow graceful degradation, meaningful error messages, and recovery paths.
Here’s the stark contrast:
# Unhandled - program crashes
def calculate_average(numbers):
return sum(numbers) / len(numbers)
result = calculate_average([]) # ZeroDivisionError: division by zero
print(f"Average: {result}") # Never executes
# Handled - graceful recovery
def calculate_average_safe(numbers):
try:
return sum(numbers) / len(numbers)
except ZeroDivisionError:
return 0.0
result = calculate_average_safe([])
print(f"Average: {result}") # Prints: Average: 0.0
The handled version continues executing. Whether returning 0.0 is the right behavior depends on your requirements, but at minimum, your application doesn’t crash.
The try-except Block Basics
The try-except structure is Python’s fundamental exception handling mechanism. Code that might raise exceptions goes in the try block. If an exception occurs, Python immediately jumps to the matching except block.
# Generic exception catching (use sparingly)
try:
user_input = input("Enter a number: ")
number = int(user_input)
print(f"You entered: {number}")
except Exception as e:
print(f"Something went wrong: {e}")
This works, but it’s too broad. Catching Exception masks all errors, including ones you didn’t anticipate. Always catch specific exceptions when possible:
# Specific exception handling (better)
try:
user_input = input("Enter a number: ")
number = int(user_input)
result = 100 / number
print(f"Result: {result}")
except ValueError:
print("That's not a valid number")
except ZeroDivisionError:
print("Cannot divide by zero")
You can handle multiple exception types in a single except block using a tuple:
try:
data = {"name": "Alice"}
age = int(data["age"]) # KeyError or ValueError possible
except (KeyError, ValueError) as e:
print(f"Data validation failed: {e}")
The order of except blocks matters. Python checks them sequentially and executes the first match. Always put more specific exceptions before generic ones:
try:
with open("config.json") as f:
data = json.load(f)
except FileNotFoundError:
print("Config file missing")
except json.JSONDecodeError:
print("Invalid JSON format")
except Exception as e:
print(f"Unexpected error: {e}")
The else Clause
The else clause executes only when the try block completes without raising exceptions. It separates exception-prone code from code that should only run on success.
def read_user_data(filename):
try:
file = open(filename, 'r')
except FileNotFoundError:
print(f"Error: {filename} not found")
return None
else:
# Only runs if file opened successfully
data = file.read()
file.close()
return data.strip()
content = read_user_data("users.txt")
if content:
print(f"Loaded: {content}")
The else block clarifies intent: “Do this only if nothing went wrong.” It’s particularly useful when the success path involves multiple operations that don’t need exception handling:
def process_number(value):
try:
number = float(value)
except ValueError:
print(f"'{value}' is not a number")
return None
else:
# Complex processing that doesn't need try-except
squared = number ** 2
rooted = number ** 0.5
print(f"Number: {number}, Squared: {squared}, Root: {rooted}")
return number
Without else, you’d either nest the processing inside try (catching exceptions you don’t expect) or use flags and conditionals (messier code).
The finally Block
The finally block always executes, whether an exception occurred or not. It’s designed for cleanup operations: closing files, releasing locks, committing or rolling back transactions.
def save_to_database(data):
connection = None
try:
connection = database.connect()
connection.execute("INSERT INTO logs VALUES (?)", data)
connection.commit()
except database.ConnectionError:
print("Failed to connect to database")
except database.IntegrityError:
print("Data validation failed")
if connection:
connection.rollback()
finally:
# Guaranteed to run
if connection:
connection.close()
print("Database connection closed")
The finally block executes even if you return from inside try or except:
def read_config():
file = None
try:
file = open("config.txt")
return file.read()
except FileNotFoundError:
return "default_config"
finally:
if file:
file.close() # Still executes before return
However, for resource management, context managers are superior:
# Better approach using context manager
def read_config():
try:
with open("config.txt") as file:
return file.read()
except FileNotFoundError:
return "default_config"
# File automatically closed, no finally needed
Use finally when you can’t use a context manager, or when you need cleanup beyond resource closure (logging, notifications, metric recording).
Exception Information and Custom Messages
The as keyword captures the exception object, giving you access to error details:
import logging
def parse_json_file(filename):
try:
with open(filename) as f:
return json.load(f)
except FileNotFoundError as e:
logging.error(f"File not found: {e.filename}")
raise
except json.JSONDecodeError as e:
logging.error(f"JSON error at line {e.lineno}, column {e.colno}: {e.msg}")
raise
except Exception as e:
logging.error(f"Unexpected error: {type(e).__name__}: {e}")
raise
The raise statement without arguments re-raises the caught exception, preserving the original traceback. This is crucial for debugging—don’t swallow exceptions after logging them.
You can also raise exceptions with additional context:
def withdraw(account, amount):
try:
if account.balance < amount:
raise ValueError("Insufficient funds")
account.balance -= amount
except ValueError as e:
# Add context and re-raise
raise ValueError(f"Withdrawal failed for account {account.id}: {e}") from e
The from e syntax chains exceptions, preserving the original cause while adding context.
Best Practices and Common Pitfalls
Never use bare except clauses:
# ANTI-PATTERN
try:
process_data()
except: # Catches EVERYTHING, including KeyboardInterrupt
pass # Silent failure
This catches system exits, keyboard interrupts, and memory errors. Your program becomes impossible to stop and debug.
Don’t swallow exceptions without logging:
# BAD
try:
critical_operation()
except Exception:
pass # Error disappears
# BETTER
import logging
try:
critical_operation()
except Exception as e:
logging.exception("Critical operation failed")
# Decide: re-raise, return error value, or use default
Catch exceptions at the level where you can handle them meaningfully:
# Don't do this
def read_file(filename):
try:
with open(filename) as f:
return f.read()
except Exception:
return "" # What if it's a PermissionError? MemoryError?
# Do this
def read_file(filename):
# Let exceptions propagate; caller decides how to handle
with open(filename) as f:
return f.read()
def main():
try:
content = read_file("data.txt")
except FileNotFoundError:
content = get_default_content()
except PermissionError:
logging.error("Permission denied")
sys.exit(1)
Use custom exceptions for application-specific errors:
class ValidationError(Exception):
"""Raised when data validation fails"""
pass
class ConfigurationError(Exception):
"""Raised when configuration is invalid"""
pass
def validate_user(data):
if "email" not in data:
raise ValidationError("Email is required")
if "@" not in data["email"]:
raise ValidationError("Invalid email format")
Real-World Application
Here’s a complete example combining these concepts in a data processing pipeline:
import json
import logging
from typing import Dict, List, Optional
logging.basicConfig(level=logging.INFO)
class DataProcessingError(Exception):
"""Custom exception for data processing failures"""
pass
def process_user_records(input_file: str, output_file: str) -> Optional[int]:
"""
Process user records from JSON file, validate, and save results.
Returns number of processed records or None on failure.
"""
input_handle = None
output_handle = None
processed_count = 0
try:
# Open input file
input_handle = open(input_file, 'r')
try:
records = json.load(input_handle)
except json.JSONDecodeError as e:
raise DataProcessingError(
f"Invalid JSON in {input_file} at line {e.lineno}"
) from e
# Validate data structure
if not isinstance(records, list):
raise DataProcessingError("Expected a list of records")
except FileNotFoundError:
logging.error(f"Input file not found: {input_file}")
return None
except DataProcessingError as e:
logging.error(f"Data processing failed: {e}")
return None
else:
# Only process if input was successfully loaded
try:
output_handle = open(output_file, 'w')
validated_records = []
for idx, record in enumerate(records):
try:
# Validate each record
if not isinstance(record, dict):
raise ValueError("Record must be a dictionary")
if "id" not in record or "name" not in record:
raise ValueError("Record missing required fields")
validated_records.append(record)
processed_count += 1
except ValueError as e:
logging.warning(f"Skipping invalid record {idx}: {e}")
continue
# Write validated records
json.dump(validated_records, output_handle, indent=2)
logging.info(f"Successfully processed {processed_count} records")
except IOError as e:
logging.error(f"Failed to write output file: {e}")
return None
finally:
# Guaranteed cleanup
if input_handle:
input_handle.close()
if output_handle:
output_handle.close()
logging.info("File handles closed")
return processed_count
# Usage
result = process_user_records("users.json", "validated_users.json")
if result is not None:
print(f"Processing complete: {result} records")
else:
print("Processing failed")
This example demonstrates specific exception handling, the else clause for success-path logic, finally for guaranteed cleanup, custom exceptions for domain errors, and proper logging. It handles errors at appropriate levels: file errors at the outer level, validation errors per-record, allowing partial success.
Exception handling isn’t about preventing all errors—it’s about failing gracefully, providing useful information, and maintaining program stability when things go wrong.