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

# 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...")

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))   # 5.0
print(divide(10, 0))   # Error: Cannot divide by zero!
                        # None

# Program doesn't crash!

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}")

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
safe_divide(10, 2)        # 5.0
safe_divide(10, 0)        # Error: Division by zero!
safe_divide("10", 2)      # Error: Invalid types for division!

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}")

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

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.
    # In real code, you'd use: file.close() if 'file' in locals() else None

# Example: Ensuring cleanup happens
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)

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
divide_numbers(10, 2)
# Output:
# Division successful!
# This always runs!
# Returns: 5.0

divide_numbers(10, 0)
# Output:
# Error: Division by zero!
# This always runs!
# Returns: None

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")
# ValueError: invalid literal
TypeError

Raised when an operation is performed on an object of inappropriate type.

"5" + 3
# TypeError: can only concatenate
KeyError

Raised when a dictionary key is not found.

d = {"a": 1}
d["b"]
# KeyError: 'b'
IndexError

Raised when a sequence subscript is out of range.

lst = [1, 2, 3]
lst[10]
# IndexError: list index out of range
ZeroDivisionError

Raised when division or modulo by zero occurs.

10 / 0
# ZeroDivisionError: division by zero
FileNotFoundError

Raised when a file or directory is requested but doesn't exist.

open("missing.txt")
# FileNotFoundError: [Errno 2]

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):
    if birth_year < 0:
        raise ValueError("Birth year cannot be negative")
    if birth_year > 2024:
        raise ValueError("Birth year cannot be in the future")

    age = 2024 - birth_year
    return age

# Usage
try:
    age = calculate_age(1990)
    print(f"Age: {age}")
except ValueError as e:
    print(f"Error: {e}")

# Test with invalid input
try:
    age = calculate_age(-5)
except ValueError as e:
    print(f"Error: {e}")  # Error: Birth year cannot be negative

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}")  # Transaction failed: Insufficient funds

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}")

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

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

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)   # Alice
print(email)  # No email

# Alternative: Use .get() method (built-in)
name = person.get("name", "Unknown")
email = person.get("email", "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)

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