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 completely
Expected 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

enter trytry blockexception?else blockno errorexcept blockhandles itfinally blockalways runsdonenoyes

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 provided
Expected 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 finally for cleanup
  • Log errors for debugging
  • Handle errors at appropriate level
  • Use with for 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/except to handle errors
  • Catch specific exceptions when possible
  • Provide clear error messages
  • Let program continue after handling

Advanced Features

  • else runs when no exception
  • finally always runs
  • Use raise to signal errors
  • Re-raise exceptions when needed

Common Exceptions

  • ValueError, TypeError
  • KeyError, IndexError
  • ZeroDivisionError
  • FileNotFoundError

Best Practices

  • Be specific with exception types
  • Use with for 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