Build robust applications by gracefully handling errors and unexpected situations
Errors are inevitable in programming. Users enter invalid data, files don't exist, network connections fail, and calculations produce unexpected results. The difference between a fragile program and a robust one is how it handles these situations.
Python's exception handling mechanism allows you to anticipate, catch, and handle errors gracefully, ensuring your programs don't crash unexpectedly and provide meaningful feedback to users.
An exception is an event that disrupts the normal flow of a program. When Python encounters an error, it creates an exception object and stops the program unless you handle it.
# ZeroDivisionError
result = 10 / 0 # Error: division by zero
# TypeError
number = "100"
result = number + 50 # Error: can't add string and int
# ValueError
age = int("twenty") # Error: invalid literal for int()
# KeyError
person = {"name": "Alice"}
age = person["age"] # Error: key doesn't exist
# IndexError
numbers = [1, 2, 3]
item = numbers[10] # Error: index out of range
# FileNotFoundError
with open("missing_file.txt") as f: # Error: file doesn't exist
content = f.read()
# AttributeError
text = "Hello"
text.append("!") # Error: str has no append method
The try-except block allows you to attempt code that might fail and handle the error gracefully if it does.
# Without exception handling (program crashes)
age = int(input("Enter your age: ")) # Crashes if user enters text
# With exception handling (program continues)
try:
age = int(input("Enter your age: "))
print(f"You are {age} years old")
except ValueError:
print("Please enter a valid number!")
age = 0 # Default value
print("Program continues running...")
# Method 1: Separate except blocks
def divide_numbers(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
return None
except TypeError:
print("Error: Please provide numbers only!")
return None
print(divide_numbers(10, 2)) # Output: 5.0
print(divide_numbers(10, 0)) # Output: Error message
print(divide_numbers(10, "2")) # Output: Error message
# Method 2: Single except for multiple types
try:
# Some operation
number = int(input("Enter a number: "))
result = 100 / number
except (ValueError, ZeroDivisionError) as error:
print(f"Invalid input: {error}")
# Method 3: Catch all exceptions (use sparingly)
try:
# Risky operation
result = some_risky_function()
except Exception as error:
print(f"Something went wrong: {error}")
except: can hide bugs and make debugging harder.
The else block runs only if NO exception occurred in the try block. It's useful for code that should only run when everything succeeds.
def read_number():
try:
number = int(input("Enter a number: "))
except ValueError:
print("That's not a valid number!")
else:
# This runs only if no exception occurred
print(f"You entered: {number}")
print(f"Square: {number ** 2}")
# Example usage
read_number()
# Another example: File processing
try:
file = open("data.txt", "r")
except FileNotFoundError:
print("File not found!")
else:
# Process file only if it opened successfully
content = file.read()
print(f"File has {len(content)} characters")
file.close()
The finally block ALWAYS runs, whether an exception occurred or not. It's perfect for cleanup tasks like closing files or releasing resources.
# Example: Always close the file
def process_file(filename):
file = None
try:
file = open(filename, 'r')
content = file.read()
result = len(content)
return result
except FileNotFoundError:
print(f"Error: {filename} not found")
return 0
except PermissionError:
print(f"Error: No permission to read {filename}")
return 0
finally:
# This ALWAYS runs - even if there's a return statement above!
if file:
file.close()
print("File closed successfully")
# Example: Database connection
def query_database(query):
connection = None
try:
connection = connect_to_database()
result = connection.execute(query)
return result
except DatabaseError as e:
print(f"Database error: {e}")
return None
finally:
# Ensure connection is closed no matter what
if connection:
connection.close()
print("Database connection closed")
# All clauses together
def divide_with_logging(a, b):
print("Starting division...")
try:
result = a / b
except ZeroDivisionError:
print("Error: Division by zero!")
return None
except TypeError:
print("Error: Invalid types!")
return None
else:
# Runs only if no exception
print(f"Division successful: {result}")
return result
finally:
# Always runs
print("Division attempt completed\n")
# Test cases
print(divide_with_logging(10, 2)) # Success case
print(divide_with_logging(10, 0)) # ZeroDivisionError
print(divide_with_logging(10, "2")) # TypeError
Sometimes YOU need to raise exceptions to signal that something is wrong. This is common in functions that validate input or enforce business rules.
# Raise an exception manually
def validate_age(age):
if age < 0:
raise ValueError("Age cannot be negative!")
if age > 150:
raise ValueError("Age seems unrealistic!")
return age
# Test
try:
validate_age(-5)
except ValueError as error:
print(f"Validation failed: {error}")
# Another example: Password validation
def set_password(password):
if len(password) < 8:
raise ValueError("Password must be at least 8 characters long")
if not any(char.isdigit() for char in password):
raise ValueError("Password must contain at least one digit")
if not any(char.isupper() for char in password):
raise ValueError("Password must contain at least one uppercase letter")
print("Password set successfully!")
# Usage
try:
set_password("weak")
except ValueError as error:
print(f"Error: {error}")
# Log error but still propagate it
def process_data(data):
try:
# Some processing
result = complex_operation(data)
return result
except Exception as error:
# Log the error
print(f"Error logged: {error}")
# Re-raise the same exception
raise # This passes the exception to the caller
# Caller must handle it
try:
process_data(some_data)
except Exception:
print("Could not process data")
Create your own exception classes to represent specific error conditions in your application. This makes error handling more semantic and organized.
# Basic custom exception
class InsufficientFundsError(Exception):
"""Raised when account has insufficient funds for withdrawal."""
pass
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(
f"Cannot withdraw ${amount}. Balance: ${self.balance}"
)
self.balance -= amount
return self.balance
# Usage
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as error:
print(f"Transaction failed: {error}")
# More sophisticated custom exception
class ValidationError(Exception):
"""Base class for validation errors."""
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
class InvalidEmailError(ValidationError):
"""Raised when email format is invalid."""
def __init__(self, email):
super().__init__("email", f"'{email}' is not a valid email address")
class InvalidAgeError(ValidationError):
"""Raised when age is out of valid range."""
def __init__(self, age):
super().__init__("age", f"{age} is not a valid age")
# Usage in a user registration system
class UserRegistration:
def register(self, email, age):
try:
self.validate_email(email)
self.validate_age(age)
print(f"User registered: {email}")
except ValidationError as error:
print(f"Registration failed - {error}")
def validate_email(self, email):
if '@' not in email or '.' not in email.split('@')[-1]:
raise InvalidEmailError(email)
def validate_age(self, age):
if not 0 <= age <= 120:
raise InvalidAgeError(age)
# Test
registration = UserRegistration()
registration.register("user@example.com", 25) # Success
registration.register("invalid-email", 25) # Fails
registration.register("user@example.com", 150) # Fails
def read_file_safely(filename):
"""
Read a file with comprehensive error handling.
"""
try:
with open(filename, 'r') as file:
content = file.read()
return content
except FileNotFoundError:
print(f"Error: The file '{filename}' does not exist.")
return None
except PermissionError:
print(f"Error: No permission to read '{filename}'.")
return None
except IsADirectoryError:
print(f"Error: '{filename}' is a directory, not a file.")
return None
except Exception as error:
print(f"Unexpected error: {error}")
return None
# Usage
content = read_file_safely("data.txt")
if content:
print(f"File read successfully: {len(content)} characters")
import requests # Hypothetical example
class APIError(Exception):
"""Custom exception for API errors."""
pass
def fetch_user_data(user_id):
"""
Fetch user data from API with error handling.
"""
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
response = requests.get(f"https://api.example.com/users/{user_id}")
# Raise exception for bad status codes
if response.status_code != 200:
raise APIError(f"API returned status code {response.status_code}")
return response.json()
except requests.ConnectionError:
retry_count += 1
print(f"Connection failed. Retry {retry_count}/{max_retries}...")
if retry_count >= max_retries:
raise APIError("Could not connect to API after multiple retries")
except requests.Timeout:
print("Request timed out")
raise APIError("API request timed out")
except APIError as error:
print(f"API Error: {error}")
return None
# Usage
try:
user = fetch_user_data(123)
if user:
print(f"User: {user['name']}")
except APIError as error:
print(f"Failed to fetch user: {error}")
def get_valid_integer(prompt, min_value=None, max_value=None):
"""
Keep asking for input until a valid integer is entered.
"""
while True:
try:
value = int(input(prompt))
# Check range if specified
if min_value is not None and value < min_value:
raise ValueError(f"Value must be at least {min_value}")
if max_value is not None and value > max_value:
raise ValueError(f"Value must be at most {max_value}")
return value
except ValueError as error:
print(f"Invalid input: {error}")
print("Please try again.\n")
# Usage: Get age between 0 and 120
age = get_valid_integer("Enter your age: ", min_value=0, max_value=120)
print(f"You entered: {age}")
# Usage: Get menu choice
def show_menu():
print("\n=== Menu ===")
print("1. Option A")
print("2. Option B")
print("3. Option C")
print("4. Exit")
choice = get_valid_integer("Select option (1-4): ", min_value=1, max_value=4)
return choice
choice = show_menu()
print(f"You selected option {choice}")
except:finally or context managers to ensure resources are releasedException unless necessaryexcept: pass - it hides all errors# ❌ Bad: Silent failure
try:
risky_operation()
except:
pass # Error is completely hidden!
# ✅ Good: Handle appropriately
try:
risky_operation()
except SpecificError as error:
log_error(error)
notify_user("Operation failed")
# ❌ Bad: Too broad
try:
result = process_data()
except Exception:
print("Error!") # What error? Where?
# ✅ Good: Specific handling
try:
result = process_data()
except ValueError as error:
print(f"Invalid data: {error}")
except KeyError as error:
print(f"Missing key: {error}")
except Exception as error:
print(f"Unexpected error: {error}")
raise # Re-raise unexpected errors
Create a calculator that handles all possible errors gracefully.
def safe_calculator():
"""Calculator with comprehensive error handling."""
print("=== Safe Calculator ===")
try:
num1 = float(input("Enter first number: "))
operator = input("Enter operator (+, -, *, /): ").strip()
num2 = float(input("Enter second number: "))
if operator == '+':
result = num1 + num2
elif operator == '-':
result = num1 - num2
elif operator == '*':
result = num1 * num2
elif operator == '/':
if num2 == 0:
raise ZeroDivisionError("Cannot divide by zero!")
result = num1 / num2
else:
raise ValueError(f"Invalid operator: {operator}")
print(f"\nResult: {num1} {operator} {num2} = {result}")
except ValueError as error:
print(f"Invalid input: {error}")
except ZeroDivisionError as error:
print(f"Math error: {error}")
except KeyboardInterrupt:
print("\nCalculation cancelled by user")
except Exception as error:
print(f"Unexpected error: {error}")
# Run
safe_calculator()
Create a function that creates a backup of a file with proper error handling.
import shutil
from datetime import datetime
def backup_file(source_file):
"""Create a timestamped backup of a file."""
try:
# Generate backup filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = f"{source_file}.backup_{timestamp}"
# Copy file
shutil.copy2(source_file, backup_file)
print(f"✅ Backup created: {backup_file}")
return backup_file
except FileNotFoundError:
print(f"❌ Error: Source file '{source_file}' not found")
return None
except PermissionError:
print(f"❌ Error: No permission to read '{source_file}'")
return None
except Exception as error:
print(f"❌ Backup failed: {error}")
return None
# Test
backup_file("important_data.txt")
raiseWith exception handling mastered, you're ready for:
Test your understanding of exception handling!