Skip to content
beginner Phase 3 · Python Advanced

Exception Handling

Handle errors with try/except/finally, custom exceptions, and best practices.

1h
0 problems
Topic Progress 0%

Exception Basics

try/except/else/finally

try:
    # Code that might raise exception
    result = 10 / 0
except ZeroDivisionError as e:
    # Handle specific exception
    print(f'Error: {e}')
else:
    # Runs if no exception occurred
    print(f'Result: {result}')
finally:
    # Always runs (cleanup code)
    print('Done')

# Output:
# Error: division by zero
# Done

Common Built-in Exceptions

# ValueError - wrong value
int('hello')  # ValueError: invalid literal

# TypeError - wrong type
'hello' + 1   # TypeError: can only concatenate str to str

# KeyError - missing dictionary key
{'a': 1}['b']  # KeyError: 'b'

# IndexError - list index out of range
[1, 2][5]     # IndexError: list index out of range

# FileNotFoundError - file doesn't exist
open('nonexistent.txt')  # FileNotFoundError

# AttributeError - attribute doesn't exist
'hello'.nonexistent  # AttributeError

# ImportError - module not found
import nonexistent  # ImportError

# RuntimeError - generic runtime error
raise RuntimeError('Something went wrong')

Catching Multiple Exceptions

try:
    # Some code
    pass
except (ValueError, TypeError) as e:
    # Handle either exception
    print(f'Error: {e}')
except ZeroDivisionError:
    # Handle different exception
    print('Division by zero')
except Exception as e:
    # Catch all other exceptions (use sparingly!)
    print(f'Unexpected error: {e}')

Exception Hierarchy

BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- StopIteration
      +-- ArithmeticError
      |    +-- ZeroDivisionError
      |    +-- OverflowError
      |    +-- FloatingPointError
      +-- LookupError
      |    +-- IndexError
      |    +-- KeyError
      +-- ValueError
      +-- TypeError
      +-- AttributeError
      +-- OSError
           +-- FileNotFoundError
           +-- PermissionError
           +-- TimeoutError

Custom Exceptions

Creating Custom Exceptions

class AppError(Exception):
    """Base exception for application."""
    pass

class ValidationError(AppError):
    """Raised when validation fails."""
    def __init__(self, field, message):
        self.field = field
        self.message = message
        super().__init__(f'{field}: {message}')

class NotFoundError(AppError):
    """Raised when resource not found."""
    def __init__(self, resource, identifier):
        self.resource = resource
        self.identifier = identifier
        super().__init__(f'{resource} with id {identifier} not found')

# Usage
def validate_age(age):
    if not isinstance(age, int):
        raise ValidationError('age', 'Must be an integer')
    if age < 0 or age > 150:
        raise ValidationError('age', 'Must be between 0 and 150')
    return True

try:
    validate_age(-5)
except ValidationError as e:
    print(f'Validation failed: {e}')
    print(f'Field: {e.field}')
    print(f'Message: {e.message}')

Exception Chaining

class DatabaseError(Exception):
    pass

class ConnectionError(DatabaseError):
    pass

try:
    try:
        open('config.txt')
    except FileNotFoundError as e:
        # Chain the original exception
        raise DatabaseError('Failed to load config') from e
except DatabaseError as e:
    print(f'Error: {e}')
    print(f'Original: {e.__cause__}')

Best Practices

# ✅ Do: Be specific
try:
    value = my_dict[key]
except KeyError:
    value = default

# ❌ Don't: Catch everything
try:
    value = my_dict[key]
except Exception:
    value = default

# ✅ Do: Log exceptions
import logging
try:
    risky_operation()
except Exception as e:
    logging.exception('Operation failed')
    raise  # Re-raise if needed

# ✅ Do: Use custom exceptions for domain errors
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f'Insufficient funds: balance={balance}, amount={amount}'
        )

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

Exception Patterns

EAFP vs LBYL

# LBYL: Look Before You Leap
if key in my_dict:
    value = my_dict[key]
else:
    value = default

# EAFP: Easier to Ask Forgiveness than Permission
try:
    value = my_dict[key]
except KeyError:
    value = default

# Python prefers EAFP - it's more Pythonic

Exception as Flow Control

# ✅ Good: Exceptions for exceptional cases
def divide(a, b):
    if b == 0:
        raise ValueError('Cannot divide by zero')
    return a / b

# ❌ Bad: Exceptions for normal flow
# Don't use exceptions for regular conditional logic
try:
    result = divide(10, 0)
except ValueError:
    result = None  # This is expected, not exceptional

# ✅ Better: Return None or use Optional
from typing import Optional

def divide(a: float, b: float) -> Optional[float]:
    if b == 0:
        return None
    return a / b

Context Managers and Exceptions

class ManagedResource:
    def __enter__(self):
        print('Acquiring')
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        print('Releasing')
        # Return True to suppress exception
        # Return False to propagate
        if exc_type is TimeoutError:
            print('Timeout - suppressed')
            return True  # Suppress TimeoutError
        return False  # Propagate other exceptions

with ManagedResource():
    raise TimeoutError('Timed out')  # Suppressed

with ManagedResource():
    raise ValueError('Bad value')  # Propagated

Raising Exceptions

# Raise with message
raise ValueError('Invalid value')

# Raise with chaining
try:
    open('missing.txt')
except FileNotFoundError as e:
    raise RuntimeError('Config error') from e

# Re-raise current exception
try:
    risky_operation()
except Exception:
    log_error()
    raise  # Re-raise with original traceback

# Raise from nothing (suppress context)
raise RuntimeError('Bad') from None