Error Handling
Gracefully handling errors and exceptions with try/except blocks.
Introduction
Errors are inevitable in programming. Whether it's invalid user input, missing files, network issues, or unexpected data, your programs need to handle errors gracefully. Python uses exceptions to signal errors, and try/except blocks allow you to catch and handle these exceptions. Proper error handling makes your programs more robust, user-friendly, and easier to debug.
What are Exceptions?
An exception is an event that occurs during program execution that disrupts the normal flow. When Python encounters an error, it raises an exception. If not handled, the program crashes.
Common Errors Without Handling
# Each line below is commented out because it would stop the program.
# Uncomment ONE at a time to see the error it produces.
# Division by zero
# result = 10 / 0
# ZeroDivisionError: division by zero
# Invalid type conversion
# number = int("abc")
# ValueError: invalid literal for int() with base 10: 'abc'
# Accessing non-existent key
person = {"name": "Alice"}
# age = person["age"]
# KeyError: 'age'
# Accessing list index out of range
numbers = [1, 2, 3]
# value = numbers[10]
# IndexError: list index out of range
# Calling method on None
text = None
# length = text.upper()
# AttributeError: 'NoneType' object has no attribute 'upper'Without error handling: These errors cause your program to crash immediately, showing a traceback to the user, which is not a good user experience!
Basic Try/Except Blocks
The try/except block allows you to catch and handle exceptions gracefully.
Basic Syntax
try:
# Code that might raise an exception
result = 10 / 0
except:
# Code to handle the exception
print("An error occurred!")
# The program continues after the except block
print("Program continues...")Expected Output:
An error occurred! Program continues...
Practical Example: Division
def divide(a, b):
try:
result = a / b
return result
except:
print("Error: Cannot divide by zero!")
return None
print(divide(10, 2))
print(divide(10, 0))
# Program doesn't crash!Expected Output:
5.0 Error: Cannot divide by zero! None
Catching Specific Exceptions
It's better to catch specific exceptions rather than all exceptions:
try:
number = int(input("Enter a number: "))
result = 10 / number
print(f"Result: {result}")
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
except ValueError:
print("Error: Invalid number entered!")
except Exception as e:
print(f"An unexpected error occurred: {e}")Expected Output:
Enter a number: 0 Error: Cannot divide by zero!
Best Practice: Always catch specific exceptions when possible. Catching all exceptions with bare except: can hide bugs and make debugging harder. Important: There are exceptions to this rule. For example, in ETL (Extract-Transform-Load) pipelines or data processing jobs, you may want your code to continue running no matter what happens, deferring error handling, postmortem analysis, or retries to other specialized tools or workflows outside your code. In such cases, broadly catching exceptions may be appropriate.
Multiple Except Clauses
You can handle multiple different exceptions with separate except clauses.
Handling Different Exceptions
def safe_divide(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
print("Error: Division by zero!")
return None
except TypeError:
print("Error: Invalid types for division!")
return None
except Exception as e:
print(f"Unexpected error: {e}")
return None
# Test different scenarios (printing shows the return value too)
print(safe_divide(10, 2))
print(safe_divide(10, 0))
print(safe_divide("10", 2))Expected Output:
5.0 Error: Division by zero! None Error: Invalid types for division! None
Catching Multiple Exceptions
You can catch multiple exception types in a single except clause:
try:
# Some code that might raise exceptions
value = int(input("Enter a number: "))
result = 100 / value
print(f"Result: {result}")
except (ValueError, TypeError) as e:
print(f"Invalid input: {e}")
except ZeroDivisionError:
print("Cannot divide by zero!")
except Exception as e:
print(f"Unexpected error: {e}")Expected Output:
Enter a number: abc Invalid input: invalid literal for int() with base 10: 'abc'
Else and Finally Clauses
The else clause runs when no exception occurs, and finally always runs, regardless of exceptions.
The Else Clause
try:
number = int(input("Enter a number: "))
result = 10 / number
except ZeroDivisionError:
print("Cannot divide by zero!")
except ValueError:
print("Invalid number!")
else:
# Runs only if no exception occurred
print(f"Success! Result: {result}")
# The else block is useful for code that should only
# run when the try block succeeds completelyExpected Output:
Enter a number: 5 Success! Result: 2.0
The Finally Clause
The finally block always executes, whether an exception occurs or not. It's perfect for cleanup code:
try:
file = open("data.txt", "r")
content = file.read()
print(content)
except FileNotFoundError:
print("File not found!")
except Exception as e:
print(f"Error: {e}")
finally:
# Always runs, even if exception occurs
print("Cleaning up...")
# Close file, release resources, etc.
# Example: cleanup still happens when the work itself fails
def acquire_resource():
print("Resource acquired")
return {"name": "db-connection"}
def use_resource(resource):
raise RuntimeError(f"{resource['name']} failed mid-operation")
def release_resource(resource):
print(f"Resource released: {resource['name']}")
def process_data():
resource = acquire_resource()
try:
# Use resource
result = use_resource(resource)
return result
finally:
# Always release resource, even if error occurs
release_resource(resource)
try:
process_data()
except RuntimeError as e:
print(f"Caught in caller: {e}")Expected Output:
File not found! Cleaning up... Resource acquired Resource released: db-connection Caught in caller: db-connection failed mid-operation
Complete Try/Except/Else/Finally
def divide_numbers(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Error: Division by zero!")
return None
except TypeError:
print("Error: Invalid types!")
return None
else:
print("Division successful!")
return result
finally:
print("This always runs!")
# Test (printing the call shows the return value too)
print(divide_numbers(10, 2))
print(divide_numbers(10, 0))Expected Output:
Division successful! This always runs! 5.0 Error: Division by zero! This always runs! None
Which blocks run, and in what order
Figure 1: If the try block succeeds, else runs; if it raises, a matching except runs. Either way, finally always runs before the code continues.
Common Exception Types
Python has many built-in exception types. Here are the most common ones you'll encounter:
ValueError
Raised when a function receives an argument of correct type but inappropriate value.
int("abc")Expected Output:
ValueError: invalid literal for int() with base 10: 'abc'
TypeError
Raised when an operation is performed on an object of inappropriate type.
"5" + 3
Expected Output:
TypeError: can only concatenate str (not "int") to str
KeyError
Raised when a dictionary key is not found.
d = {"a": 1}
d["b"]Expected Output:
KeyError: 'b'
IndexError
Raised when a sequence subscript is out of range.
lst = [1, 2, 3] lst[10]
Expected Output:
IndexError: list index out of range
ZeroDivisionError
Raised when division or modulo by zero occurs.
10 / 0
Expected Output:
ZeroDivisionError: division by zero
FileNotFoundError
Raised when a file or directory is requested but doesn't exist.
open("missing.txt")Expected Output:
FileNotFoundError: [Errno 2] No such file or directory: 'missing.txt'
Raising Exceptions
You can raise exceptions yourself using the raise statement. This is useful for validating input or signaling errors in your functions.
Raising Built-in Exceptions
def calculate_age(birth_year, current_year):
if birth_year < 0:
raise ValueError("Birth year cannot be negative")
if birth_year > current_year:
raise ValueError("Birth year cannot be in the future")
return current_year - birth_year
# Real code would read date.today().year; it is pinned here so the
# example prints the same numbers whenever you run it.
THIS_YEAR = 2026
try:
age = calculate_age(1990, THIS_YEAR)
print(f"Age: {age}")
except ValueError as e:
print(f"Error: {e}")
# Each guard raises with its own message
try:
calculate_age(-5, THIS_YEAR)
except ValueError as e:
print(f"Error: {e}")
try:
calculate_age(2050, THIS_YEAR)
except ValueError as e:
print(f"Error: {e}")Expected Output:
Age: 36 Error: Birth year cannot be negative Error: Birth year cannot be in the future
Raising with Custom Messages
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
if amount > balance:
raise ValueError(f"Insufficient funds. Balance: {balance}")
return balance - amount
# Usage
account_balance = 100
try:
new_balance = withdraw(account_balance, 50)
print(f"New balance: {new_balance}")
except ValueError as e:
print(f"Transaction failed: {e}")
try:
new_balance = withdraw(account_balance, 150)
except ValueError as e:
print(f"Transaction failed: {e}")Expected Output:
New balance: 50 Transaction failed: Insufficient funds. Balance: 100
Re-raising Exceptions
You can catch an exception, do something, then re-raise it:
def process_data(data):
try:
result = int(data) * 2
return result
except ValueError:
print("Logging error...")
raise # Re-raise the same exception
# The exception propagates up
try:
process_data("abc")
except ValueError as e:
print(f"Caught in outer handler: {e}")Expected Output:
Logging error... Caught in outer handler: invalid literal for int() with base 10: 'abc'
Chaining Exceptions with raise ... from ...
Sometimes you want to raise a new exception, but keep information about the original exception. Python allows you to chain exceptions using raise ... from .... This makes debugging easier by preserving the entire error chain.
def parse_age(age_str):
try:
age = int(age_str)
if age < 0:
raise ValueError("Age cannot be negative")
return age
except ValueError as e:
raise RuntimeError("Failed to parse age") from e # Chain the original error
try:
user_age = parse_age("not_a_number")
except RuntimeError as err:
print(f"Error: {err}")
print(f"Original cause: {err.__cause__}") # Access the original exception
# This prints both errors and preserves traceback context
Expected Output:
Error: Failed to parse age Original cause: invalid literal for int() with base 10: 'not_a_number'
Practical Examples
Example: Safe Input Validation
def get_positive_number(prompt):
"""Get a positive number from user with error handling."""
while True:
try:
number = float(input(prompt))
if number <= 0:
raise ValueError("Number must be positive")
return number
except ValueError as e:
print(f"Invalid input: {e}")
print("Please try again.")
# Usage
age = get_positive_number("Enter your age: ")
print(f"You entered: {age}")
# Keeps asking until valid input is providedExpected Output:
Enter your age: -3 Invalid input: Number must be positive Please try again. Enter your age: abc Invalid input: could not convert string to float: 'abc' Please try again. Enter your age: 25 You entered: 25.0
Example: Safe Dictionary Access
def safe_get_value(data, key, default=None):
"""Safely get value from dictionary."""
try:
return data[key]
except KeyError:
return default
except TypeError:
print("Error: First argument must be a dictionary")
return default
# Usage
person = {"name": "Alice", "age": 30}
name = safe_get_value(person, "name", "Unknown")
email = safe_get_value(person, "email", "No email")
print(name)
print(email)
# Alternative: Use .get() method (built-in)
name = person.get("name", "Unknown")
email = person.get("email", "No email")
print(name)
print(email)Expected Output:
Alice No email Alice No email
Example: File Operations
def read_file_safely(filename):
"""Read file content with proper error handling."""
try:
with open(filename, 'r') as file:
content = file.read()
return content
except FileNotFoundError:
print(f"Error: File '{filename}' not found.")
return None
except PermissionError:
print(f"Error: Permission denied to read '{filename}'.")
return None
except Exception as e:
print(f"Unexpected error: {e}")
return None
# Usage
content = read_file_safely("data.txt")
if content:
print("File read successfully!")
print(content)
# Note: Using 'with' statement automatically closes file
# even if an exception occurs (better than try/finally)Expected Output:
Error: File 'data.txt' not found.
Best Practices
✓ Do
- Catch specific exceptions
- Provide meaningful error messages
- Use
finallyfor cleanup - Log errors for debugging
- Handle errors at appropriate level
- Use
withfor file operations
✗ Don't
- Catch all exceptions with bare
except: - Silently ignore errors
- Use exceptions for control flow
- Catch exceptions you can't handle
- Leave resources open
- Hide error details from users
Key Takeaways
Try/Except Basics
- Use
try/exceptto handle errors - Catch specific exceptions when possible
- Provide clear error messages
- Let program continue after handling
Advanced Features
elseruns when no exceptionfinallyalways runs- Use
raiseto signal errors - Re-raise exceptions when needed
Common Exceptions
ValueError,TypeErrorKeyError,IndexErrorZeroDivisionErrorFileNotFoundError
Best Practices
- Be specific with exception types
- Use
withfor file operations - Don't use exceptions for control flow
- Always clean up resources
What's Next?
Excellent! You now know how to handle errors gracefully. Your programs will be much more robust and user-friendly. In future lessons, we'll explore:
- Working with files - Read from and write to files safely using context managers
- Modules and imports - Organize your code across multiple files
- String manipulation - Advanced string operations and formatting
- Standard library tools - Leverage Python's powerful built-in modules