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 except blocks creates silent failures that are harder to debug than letting the program crash with a clear error message.
  • The finally block guarantees cleanup code execution regardless of exceptions, but context managers (with statements) 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.