Error Handling and Exceptions
Learn how to handle errors and exceptions in your Python code
No matter how carefully you write your code, errors can and will occur. Python provides a robust way to handle these errors through exceptions. In this section, you'll learn how to anticipate, catch, and handle exceptions to make your programs more resilient.
Understanding Errors and Exceptions
In Python, there are two main types of errors:
Syntax Errors: These occur when the Python parser can't understand your code due to incorrect syntax. They prevent your program from running.
Exceptions: These occur during the execution of your program when something unexpected happens.
Syntax Errors
Syntax errors, also known as parsing errors, are detected when Python tries to interpret your code:
# Syntax error: missing closing parenthesis
print("Hello, world!"
This will produce an error like:
File "example.py", line 1
print("Hello, world!"
^
SyntaxError: unexpected EOF while parsing
The arrow points to the earliest point where the error was detected, which isn't always where the error actually is.
Exceptions
Exceptions occur during program execution and can happen for various reasons, like trying to divide by zero or accessing a file that doesn't exist:
# Exception: division by zero
result = 10 / 0
This will produce an error like:
Traceback (most recent call last):
File "example.py", line 1, in <module>
result = 10 / 0
ZeroDivisionError: division by zero
The traceback shows the sequence of function calls that led to the error, with the most recent call at the bottom.
Common Exception Types
Python has many built-in exceptions. Here are some common ones:
ZeroDivisionError
: Raised when dividing by zeroTypeError
: Raised when an operation is applied to an object of an inappropriate typeValueError
: Raised when a function receives an argument of the correct type but an inappropriate valueNameError
: Raised when a local or global name is not foundIndexError
: Raised when a sequence subscript is out of rangeKeyError
: Raised when a dictionary key is not foundFileNotFoundError
: Raised when a file or directory is requested but doesn't existIOError
: Raised when an I/O operation failsImportError
: Raised when an import statement fails to find the module or name
Let's see some examples:
# TypeError
result = "42" + 42 # Trying to concatenate a string and an integer
# ValueError
number = int("hello") # Trying to convert a non-numeric string to an integer
# NameError
print(undefined_variable) # Using a variable that hasn't been defined
# IndexError
my_list = [1, 2, 3]
item = my_list[10] # Accessing an index that doesn't exist
# KeyError
my_dict = {"name": "Alice"}
value = my_dict["age"] # Accessing a key that doesn't exist
# FileNotFoundError
with open("nonexistent_file.txt", "r") as file:
content = file.read()
Handling Exceptions with try/except
To handle exceptions, you use a try/except
block. The basic syntax is:
try:
# Code that might raise an exception
result = 10 / 0
except ZeroDivisionError:
# Code to handle the specific exception
print("Error: Cannot divide by zero")
You can handle multiple exceptions either by using multiple except
blocks or by grouping exceptions:
# Multiple except blocks
try:
num = int(input("Enter a number: "))
result = 10 / num
print(f"Result: {result}")
except ValueError:
print("Error: Please enter a valid number")
except ZeroDivisionError:
print("Error: Cannot divide by zero")
# Grouping exceptions
try:
num = int(input("Enter a number: "))
result = 10 / num
print(f"Result: {result}")
except (ValueError, ZeroDivisionError):
print("Error: Please enter a valid, non-zero number")
Capturing Exception Information
You can capture the exception object to get more information about what went wrong:
try:
num = int(input("Enter a number: "))
result = 10 / num
print(f"Result: {result}")
except Exception as e:
print(f"An error occurred: {type(e).__name__}: {e}")
The as
keyword assigns the exception object to a variable (in this case, e
), giving you access to its attributes.
The else and finally Clauses
You can add an else
clause that runs if no exceptions are raised, and a finally
clause that runs whether or not an exception occurs:
try:
num = int(input("Enter a number: "))
result = 10 / num
except ValueError:
print("Error: Please enter a valid number")
except ZeroDivisionError:
print("Error: Cannot divide by zero")
else:
# This runs if no exceptions occur
print(f"Result: {result}")
finally:
# This always runs, whether an exception occurred or not
print("Calculation attempt completed")
The finally
clause is useful for cleanup actions like closing files or releasing resources, regardless of whether an exception occurred.
Using exceptions for flow control
While exceptions are primarily for handling errors, they can also be used for flow control in certain situations. The "Easier to ask for forgiveness than permission" (EAFP) approach is common in Python:
# EAFP approach
try:
value = my_dict["key"]
# Use the value...
except KeyError:
# Handle the case where the key doesn't exist
value = default_value
# Alternative: "Look before you leap" (LBYL) approach
if "key" in my_dict:
value = my_dict["key"]
# Use the value...
else:
# Handle the case where the key doesn't exist
value = default_value
The EAFP approach can be cleaner and more efficient in many cases, especially when checking for a condition is more expensive than handling the exception.
Raising Exceptions
You can raise exceptions in your own code using the raise
statement:
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
try:
result = divide(10, 0)
except ZeroDivisionError as e:
print(f"Error: {e}")
Creating Custom Exceptions
You can define your own exception types by creating a class that inherits from Exception
or one of its subclasses:
class InvalidAgeError(Exception):
"""Raised when the input age is negative or unreasonably high."""
pass
def validate_age(age):
if age < 0:
raise InvalidAgeError("Age cannot be negative")
if age > 150:
raise InvalidAgeError("Age is unreasonably high")
return age
try:
user_age = int(input("Enter your age: "))
validated_age = validate_age(user_age)
print(f"Your age is {validated_age}")
except ValueError:
print("Error: Please enter a valid number")
except InvalidAgeError as e:
print(f"Error: {e}")
Exception Hierarchy
Python's exceptions form a hierarchy, with BaseException
at the top. Here's a simplified view:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── StopIteration
├── ArithmeticError
│ ├── FloatingPointError
│ ├── OverflowError
│ └── ZeroDivisionError
├── AssertionError
├── AttributeError
├── ImportError
│ └── ModuleNotFoundError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── NameError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── ...
└── ...
When handling exceptions, remember that catching a parent exception will also catch all its child exceptions:
try:
# Some code that might raise various exceptions
pass
except LookupError:
# This will catch both IndexError and KeyError
pass
except OSError:
# This will catch FileNotFoundError, PermissionError, etc.
pass
except Exception:
# This will catch most exceptions
pass
It's generally best to catch specific exceptions rather than using a broad except Exception
clause, as the latter can mask bugs and make debugging harder.
The with Statement and Context Managers
We've already seen the with
statement for file operations:
with open("example.txt", "r") as file:
content = file.read()
This is an example of a context manager, which handles setup (opening the file) and teardown (closing the file) automatically, even if an exception occurs. Context managers are a clean way to handle resources and exceptions.
You can create your own context managers by either using the contextlib
module or defining a class with __enter__
and __exit__
methods:
from contextlib import contextmanager
@contextmanager
def file_manager(filename, mode):
try:
file = open(filename, mode)
yield file
finally:
file.close()
# Using the custom context manager
with file_manager("example.txt", "r") as file:
content = file.read()
print(content)
Assertions
Assertions are a way to check that conditions are as expected during development:
def calculate_average(numbers):
assert len(numbers) > 0, "Cannot calculate average of empty list"
return sum(numbers) / len(numbers)
# This will raise an AssertionError
calculate_average([])
Assertions are primarily a debugging aid and can be disabled in optimized mode (python -O
), so don't use them for input validation or enforcing conditions in production code.
Practical Example: A Robust Configuration Parser
Let's build a practical example that demonstrates error handling with a configuration file parser:
"""
Config Parser
A module for parsing configuration files with robust error handling.
"""
class ConfigError(Exception):
"""Base class for configuration-related errors."""
pass
class ConfigParseError(ConfigError):
"""Raised when there's a problem parsing the configuration file."""
def __init__(self, message, line_number=None, line_content=None):
self.line_number = line_number
self.line_content = line_content
super().__init__(f"{message}" +
(f" on line {line_number}" if line_number else "") +
(f": '{line_content}'" if line_content else ""))
class ConfigKeyError(ConfigError):
"""Raised when a required key is missing."""
pass
class ConfigTypeError(ConfigError):
"""Raised when a value has the wrong type."""
pass
def parse_config_file(file_path):
"""
Parse a configuration file and return a dictionary of settings.
The file should have lines in the format "key = value".
Lines starting with # are treated as comments.
Args:
file_path (str): Path to the configuration file
Returns:
dict: The parsed configuration
Raises:
FileNotFoundError: If the file doesn't exist
ConfigParseError: If there's a problem parsing the file
"""
config = {}
try:
with open(file_path, 'r') as file:
for line_number, line in enumerate(file, 1):
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Parse key-value pairs
try:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
# Check for empty key
if not key:
raise ConfigParseError("Empty key", line_number, line)
# Store the key-value pair
config[key] = value
except ValueError:
# Couldn't unpack the line into key and value
raise ConfigParseError("Invalid format", line_number, line)
return config
except FileNotFoundError:
raise FileNotFoundError(f"Configuration file not found: {file_path}")
except Exception as e:
# Catch any other exceptions and re-raise as ConfigParseError
if not isinstance(e, ConfigError):
raise ConfigParseError(f"Unexpected error: {e}")
raise
def get_string(config, key, default=None):
"""Get a string value from the configuration."""
try:
return config[key]
except KeyError:
if default is not None:
return default
raise ConfigKeyError(f"Missing required key: {key}")
def get_int(config, key, default=None):
"""Get an integer value from the configuration."""
try:
value = config[key]
return int(value)
except KeyError:
if default is not None:
return default
raise ConfigKeyError(f"Missing required key: {key}")
except ValueError:
raise ConfigTypeError(f"Value for {key} is not a valid integer: {value}")
def get_float(config, key, default=None):
"""Get a float value from the configuration."""
try:
value = config[key]
return float(value)
except KeyError:
if default is not None:
return default
raise ConfigKeyError(f"Missing required key: {key}")
except ValueError:
raise ConfigTypeError(f"Value for {key} is not a valid float: {value}")
def get_boolean(config, key, default=None):
"""Get a boolean value from the configuration."""
try:
value = config[key].lower()
if value in ('true', 'yes', '1', 'on'):
return True
if value in ('false', 'no', '0', 'off'):
return False
raise ConfigTypeError(f"Value for {key} is not a valid boolean: {value}")
except KeyError:
if default is not None:
return default
raise ConfigKeyError(f"Missing required key: {key}")
def main():
"""Main function demonstrating the config parser."""
config_file = input("Enter configuration file path: ")
try:
config = parse_config_file(config_file)
print("Configuration loaded successfully.")
# Access configuration values
try:
host = get_string(config, "host", default="localhost")
port = get_int(config, "port", default=8080)
max_connections = get_int(config, "max_connections")
timeout = get_float(config, "timeout", default=30.0)
debug = get_boolean(config, "debug", default=False)
print("\nConfiguration values:")
print(f"Host: {host}")
print(f"Port: {port}")
print(f"Max Connections: {max_connections}")
print(f"Timeout: {timeout} seconds")
print(f"Debug Mode: {'Enabled' if debug else 'Disabled'}")
except ConfigKeyError as e:
print(f"Configuration error: {e}")
except ConfigTypeError as e:
print(f"Configuration error: {e}")
except FileNotFoundError as e:
print(f"Error: {e}")
except ConfigParseError as e:
if e.line_number:
print(f"Error parsing configuration on line {e.line_number}: {e}")
else:
print(f"Error parsing configuration: {e}")
except Exception as e:
print(f"Unexpected error: {type(e).__name__}: {e}")
if __name__ == "__main__":
main()
To use this example, create a sample configuration file called config.ini
:
# Sample configuration file
host = example.com
port = 443
max_connections = 100
timeout = 5.5
debug = true
This example demonstrates:
- Creating a custom exception hierarchy for specific error types
- Using try/except blocks to handle different kinds of errors
- Converting user input to appropriate data types with error handling
- Providing helpful error messages with context information
- Using default values when appropriate
- Properly propagating and re-raising exceptions
Best Practices for Exception Handling
- Be specific: Catch specific exceptions rather than using a broad
except
clause. - Keep try blocks small: Only put code that might raise an exception in the try block.
- Handle exceptions gracefully: Provide helpful error messages and recovery options.
- Don't silence exceptions: Avoid empty except blocks that hide errors.
- Use finally for cleanup: Use finally to ensure resources are properly released.
- Use context managers: They handle resource management and exceptions cleanly.
- Reraise when appropriate: Re-raise exceptions to propagate them to the caller.
- Log exceptions: In larger applications, log exceptions for debugging and monitoring.
Next Steps
Error handling is a critical skill for writing robust Python programs. By anticipating and handling exceptions properly, you can make your programs more resilient to unexpected situations and provide better user experiences.
In the next section, we'll explore object-oriented programming in Python, which will allow you to create reusable, modular code by defining your own types with classes and objects.
Happy coding!
Found an issue?