Python Error Handling — Exceptions Done Right

Python BasicsError HandlingFree Lesson

Advertisement

Python Error Handling — Exceptions Done Right

Errors are inevitable in programming. What separates beginner code from production-quality code is how you handle them. Python's exception system gives you powerful tools to catch, recover from, and communicate errors gracefully.


Learning Objectives

By the end of this tutorial, you will be able to:

  1. Distinguish between syntax errors and runtime exceptions
  2. Use try/except/else/finally blocks correctly
  3. Catch specific exceptions rather than using bare except
  4. Raise exceptions with meaningful messages and re-raise with tracebacks
  5. Create custom exception classes for domain-specific errors
  6. Apply the EAFP principle for cleaner, more Pythonic code
  7. Implement retry logic, validation, and fallback patterns
  8. Avoid common exception-handling mistakes that hide bugs

Syntax Errors vs Exceptions

SyntaxError: Code That Can't Parse

A SyntaxError means Python can't even read your code. It's caught during parsing, before anything runs.

def calculate_total(items
    return sum(items)

Output:

  File "example.py", line 1
    def calculate_total(items
                          ^
SyntaxError: '(' was never closed

You cannot catch SyntaxError with try/except because the code never executes. You must fix the syntax first.

Exceptions: Runtime Errors

Exceptions occur when syntactically correct code runs into a problem. These are the errors you can catch and handle.

result = 10 / 0

Output:

Traceback (most recent call last):
  File "example.py", line 1, in <module>
    result = 10 / 0
ZeroDivisionError: division by zero

Common Built-in Exceptions

ExceptionWhen It OccursExample
ValueErrorWrong value typeint("hello")
TypeErrorWrong operand type"2" + 2
KeyErrorDict key not found{"a": 1}["b"]
IndexErrorList index out of range[1, 2][5]
FileNotFoundErrorFile doesn't existopen("missing.txt")
AttributeErrorObject lacks attribute"hi".push("!")
ZeroDivisionErrorDivision by zero10 / 0
StopIterationIterator exhaustednext(iter([]))
ImportErrorModule not foundimport nonexistent
RuntimeErrorGeneric runtime errorVarious
OverflowErrorNumber too largemath.exp(1000)
MemoryErrorOut of memoryHuge data structures

Try/Except

Basic Syntax

Wrap code that might fail in a try block and handle errors in except:

try:
    result = int(input("Enter a number: "))
    print(f"You entered: {result}")
except ValueError:
    print("That's not a valid number!")

If the user types abc, output is:

Enter a number: abc
That's not a valid number!

Catching Specific Exceptions

Always catch the most specific exception you can handle meaningfully:

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("Cannot divide by zero")
        return None

print(divide(10, 0))   # Cannot divide by zero → None
print(divide(10, 2))   # 5.0

Catching Multiple Exceptions

Handle different exceptions separately or together:

def process_data(data):
    try:
        value = int(data)
        result = 100 / value
        return result
    except ValueError:
        print(f"Cannot convert '{data}' to integer")
    except ZeroDivisionError:
        print("Cannot divide by zero")
    return None

# Or group related exceptions:
def process_data_v2(data):
    try:
        value = int(data)
        result = 100 / value
        return result
    except (ValueError, ZeroDivisionError) as e:
        print(f"Error: {e}")
    return None

print(process_data("abc"))    # Cannot convert 'abc' to integer
print(process_data("0"))      # Cannot divide by zero
print(process_data("5"))      # 20.0

Bare Except: Why to Avoid It

# BAD: Catches everything including SystemExit, KeyboardInterrupt
try:
    risky_operation()
except:
    print("Something went wrong")

# GOOD: Catch specific exceptions
try:
    risky_operation()
except Exception as e:
    print(f"Error: {e}")

Bare except catches KeyboardInterrupt, SystemExit, and GeneratorExit, which means users can't Ctrl+C to stop your program. Always use except Exception at minimum.

Exception Hierarchy

Exceptions form a class hierarchy. Catching a parent catches all children:

# This catches ALL exceptions (use sparingly):
except Exception:
    pass

# This catches arithmetic-related errors:
except ArithmeticError:
    pass

# ArithmeticError catches: ZeroDivisionError, OverflowError, FloatingPointError

Try/Except/Else/Finally

Complete Block Structure

try:
    # Code that might raise an exception
    file = open("data.txt", "r")
except FileNotFoundError:
    # Runs only if an exception occurs
    print("File not found, creating it")
    file = open("data.txt", "w")
except PermissionError:
    print("No permission to access file")
else:
    # Runs ONLY if no exception occurred
    content = file.read()
    print(f"File content: {content}")
finally:
    # ALWAYS runs, regardless of what happened
    file.close()
    print("File handle closed")

Execution Flow

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│                   try                        │
│  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”                            │
│  │  Code runs   │                            │
│  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜                            │
│         │                                    │
│    ā”Œā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”                               │
│    │ Success? │                               │
│    ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”˜                               │
│    Yes  │  No                                │
│    │    └──→ except                          │
│    │         │                               │
│    ↓         ↓                               │
│  else    handle error                        │
│    │         │                               │
│    ā””ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”˜                               │
│         │                                    │
│       finally                                │
│         │                                    │
│       return                                 │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Practical Example

def read_config(path):
    config = {}
    try:
        with open(path, "r") as f:
            for line in f:
                key, value = line.strip().split("=")
                config[key] = value
    except FileNotFoundError:
        print(f"Config file '{path}' not found, using defaults")
        config = {"host": "localhost", "port": "8080"}
    except ValueError:
        print("Config file has malformed lines, using defaults")
        config = {"host": "localhost", "port": "8080"}
    else:
        print(f"Loaded {len(config)} settings from '{path}'")
    finally:
        print("Config initialization complete")
    return config

settings = read_config("app.ini")
print(settings)

Exception Hierarchy

Understanding the hierarchy helps you catch the right level:

BaseException
ā”œā”€ā”€ SystemExit
ā”œā”€ā”€ KeyboardInterrupt
ā”œā”€ā”€ GeneratorExit
└── Exception
    ā”œā”€ā”€ StopIteration
    ā”œā”€ā”€ ArithmeticError
    │   ā”œā”€ā”€ ZeroDivisionError
    │   ā”œā”€ā”€ OverflowError
    │   └── FloatingPointError
    ā”œā”€ā”€ AssertionError
    ā”œā”€ā”€ AttributeError
    ā”œā”€ā”€ EOFError
    ā”œā”€ā”€ ImportError
    │   └── ModuleNotFoundError
    ā”œā”€ā”€ LookupError
    │   ā”œā”€ā”€ IndexError
    │   └── KeyError
    ā”œā”€ā”€ NameError
    │   └── UnboundLocalError
    ā”œā”€ā”€ OSError
    │   ā”œā”€ā”€ FileNotFoundError
    │   ā”œā”€ā”€ PermissionError
    │   ā”œā”€ā”€ FileExistsError
    │   └── IsADirectoryError
    ā”œā”€ā”€ RuntimeError
    │   └── NotImplementedError
    ā”œā”€ā”€ SyntaxError
    │   └── IndentationError
    ā”œā”€ā”€ TypeError
    └── ValueError

When to Catch Specific vs General

# TOO SPECIFIC: Redundant, both are subclasses of LookupError
try:
    result = data[key]
except (KeyError, IndexError):
    pass

# BETTER: Use parent class
try:
    result = data[key]
except LookupError:
    pass

# TOO BROAD: Hides unrelated errors
try:
    result = data[key]
except Exception:
    pass

# GOOD: Catch what you can handle
try:
    result = data[key]
except KeyError:
    result = default_value

Raising Exceptions

The raise Statement

def set_age(age):
    if not isinstance(age, int):
        raise TypeError("Age must be an integer")
    if age < 0 or age > 150:
        raise ValueError(f"Age must be between 0 and 150, got {age}")
    return age

try:
    set_age(-5)
except ValueError as e:
    print(f"Invalid age: {e}")
# Invalid age: Age must be between 0 and 150, got -5

Re-raising Exceptions

Preserve the original traceback when re-raising:

def process_file(filename):
    try:
        with open(filename) as f:
            data = f.read()
            return int(data.strip())
    except (FileNotFoundError, ValueError):
        print(f"Failed to process {filename}")
        raise  # Re-raises with original traceback

Without raise, you'd lose the traceback context.

Exception Chaining with raise from

When one exception causes another, chain them:

def load_user(user_id):
    try:
        import json
        with open(f"users/{user_id}.json") as f:
            return json.load(f)
    except FileNotFoundError as original:
        raise RuntimeError(f"User {user_id} not found") from original

# Output shows the chain:
# Traceback (most recent call last):
#   ...
# FileNotFoundError: [Errno 2] No such file or directory: 'users/123.json'
#
# During handling of the above exception, another exception occurred:
#
# Traceback (most recent call last):
#   ...
# RuntimeError: User 123 not found

Suppressing the Chain

Use from None when the original exception is not useful:

def read_int(path):
    try:
        with open(path) as f:
            return int(f.read().strip())
    except (FileNotFoundError, ValueError) as e:
        raise ValueError(f"Could not read integer from {path}") from None

Custom Exceptions

Creating Exception Classes

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

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

class DatabaseError(AppError):
    """Raised when a database operation fails."""
    def __init__(self, operation, query=None):
        self.operation = operation
        self.query = query
        super().__init__(f"Database error during '{operation}'")

class NotFoundError(AppError):
    """Raised when a requested resource doesn't exist."""
    def __init__(self, resource_type, resource_id):
        self.resource_type = resource_type
        self.resource_id = resource_id
        super().__init__(f"{resource_type} with id '{resource_id}' not found")

Using Custom Exceptions

def create_user(name, email, age):
    if not name:
        raise ValidationError("name", "cannot be empty")
    if "@" not in email:
        raise ValidationError("email", "must contain @")
    if not isinstance(age, int) or age < 0:
        raise ValidationError("age", "must be a non-negative integer")
    return {"name": name, "email": email, "age": age}

# Catching at different levels
try:
    user = create_user("", "alice@example.com", 25)
except ValidationError as e:
    print(f"Validation error: {e.field} — {e.message}")
# Validation error: name — cannot be empty

# Catching the base class
try:
    user = create_user("Alice", "not-an-email", 25)
except AppError as e:
    print(f"Application error: {e}")
# Application error: Validation failed for 'email': must contain @

When to Create Custom Exceptions

Create custom exceptions when:

  • You need to distinguish between error types programmatically
  • You want to attach domain-specific data to errors
  • You're building a library and want users to catch specific errors
  • Business logic requires special error handling paths

Context Managers and Exceptions

The with Statement

Context managers handle resource cleanup even when exceptions occur:

# Without context manager (fragile):
f = open("data.txt")
try:
    data = f.read()
    process(data)
finally:
    f.close()

# With context manager (robust):
with open("data.txt") as f:
    data = f.read()
    process(data)
# File is automatically closed, even if an exception occurs

Custom Context Managers

class DatabaseConnection:
    def __init__(self, host, port):
        self.host = host
        self.port = port
        self.connection = None

    def __enter__(self):
        print(f"Connecting to {self.host}:{self.port}")
        self.connection = {"host": self.host, "port": self.port}
        return self.connection

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            print(f"Exception occurred: {exc_val}")
        self.connection = None
        print("Connection closed")
        return False  # Don't suppress exceptions

# Usage:
with DatabaseConnection("localhost", 5432) as conn:
    print(f"Using connection: {conn}")
    # Even if an exception occurs here, __exit__ runs

The __exit__ Parameters

  • exc_type: Exception class (None if no exception)
  • exc_val: Exception instance (None if no exception)
  • exc_tb: Traceback object (None if no exception)
  • Return True to suppress the exception, False to re-raise it

Exception Best Practices

EAFP vs LBYL

LBYL (Look Before You Leap): Check before acting.

# LBYL approach
if key in my_dict:
    value = my_dict[key]
else:
    value = default

if os.path.exists(filename):
    with open(filename) as f:
        data = f.read()

EAFP (Easier to Ask Forgiveness than Permission): Try and handle failure.

# EAFP approach
try:
    value = my_dict[key]
except KeyError:
    value = default

try:
    with open(filename) as f:
        data = f.read()
except FileNotFoundError:
    data = ""

EAFP is generally preferred in Python because:

  • It avoids race conditions (the state could change between check and use)
  • It's more readable when the "happy path" is the focus
  • It handles edge cases automatically

Don't Catch System Exceptions

# NEVER catch these:
except KeyboardInterrupt:  # Let users Ctrl+C
    pass
except SystemExit:         # Let sys.exit() work
    pass
except GeneratorExit:      # Let generators close properly
    pass

Log Before Handling

import logging

def process_order(order):
    try:
        validate_order(order)
        charge_payment(order)
        send_confirmation(order)
    except PaymentError as e:
        logging.error(f"Payment failed for order {order.id}: {e}")
        notify_admin(order, e)
        order.status = "payment_failed"
    except Exception as e:
        logging.exception(f"Unexpected error processing order {order.id}")
        raise

Don't Swallow Exceptions Silently

# BAD: Hides bugs
try:
    risky_operation()
except Exception:
    pass

# BETTER: At minimum log it
try:
    risky_operation()
except Exception as e:
    logging.warning(f"Operation failed: {e}")

# BEST: Handle it meaningfully
try:
    risky_operation()
except SpecificError as e:
    recover_from_error(e)

Common Patterns

Validation Functions

class ValidationError(Exception):
    def __init__(self, errors):
        self.errors = errors
        super().__init__(f"Validation failed: {', '.join(errors)}")

def validate_email(email):
    if not isinstance(email, str):
        raise ValidationError(["Email must be a string"])
    if "@" not in email:
        raise ValidationError(["Email must contain @"])
    if "." not in email.split("@")[-1]:
        raise ValidationError(["Email must have a valid domain"])
    return True

try:
    validate_email("not-an-email")
except ValidationError as e:
    print(e.errors)  # ['Email must contain @']

Retry Logic

import time

def retry(func, max_attempts=3, delay=1):
    """Retry a function on failure."""
    last_exception = None
    for attempt in range(max_attempts):
        try:
            return func()
        except Exception as e:
            last_exception = e
            if attempt < max_attempts - 1:
                print(f"Attempt {attempt + 1} failed: {e}")
                print(f"Retrying in {delay} seconds...")
                time.sleep(delay)
    raise last_exception

def unreliable_api_call():
    import random
    if random.random() < 0.7:
        raise ConnectionError("API timeout")
    return {"status": "success"}

try:
    result = retry(unreliable_api_call, max_attempts=3, delay=0.5)
    print(result)
except ConnectionError as e:
    print(f"All attempts failed: {e}")

Fallback Values

def get_user_preference(user_id, key, default=None):
    """Try cache → database → default."""
    try:
        return cache.get(f"user:{user_id}:{key}")
    except CacheError:
        pass

    try:
        return database.query(f"SELECT {key} FROM users WHERE id = ?", user_id)
    except DatabaseError:
        pass

    return default

# Usage
theme = get_user_preference(123, "theme", default="light")

Resource Cleanup

from contextlib import contextmanager

@contextmanager
def temporary_directory():
    import tempfile, shutil
    path = tempfile.mkdtemp()
    try:
        yield path
    finally:
        shutil.rmtree(path)

# Usage:
with temporary_directory() as tmp:
    # Work with files in tmp
    with open(f"{tmp}/data.txt", "w") as f:
        f.write("hello")
# tmp directory is automatically cleaned up

Exception Groups (Python 3.11+)

ExceptionGroup

When multiple independent errors occur simultaneously:

# Python 3.11+
def validate_registration(data):
    errors = []
    if not data.get("username"):
        errors.append(ValidationError("username", "required"))
    if not data.get("email"):
        errors.append(ValidationError("email", "required"))
    if not data.get("password"):
        errors.append(ValidationError("password", "required"))
    if errors:
        raise ExceptionGroup("Registration validation failed", errors)

try:
    validate_registration({})
except* ValidationError as eg:
    for error in eg.exceptions:
        print(f"  {error.field}: {error.message}")

Output:

  username: required
  email: required
  password: required

When to Use ExceptionGroups

Use them when:

  • You're validating multiple independent fields
  • You're running parallel operations that can each fail
  • You want to report all errors at once, not just the first one

Common Mistakes

1. Bare Except Catching Everything

# BAD
try:
    operation()
except:
    pass

# GOOD
try:
    operation()
except Exception as e:
    logger.error(f"Operation failed: {e}")

2. Catching and Ignoring Silently

# BAD: Hides bugs forever
try:
    result = int(user_input)
except ValueError:
    pass  # User never knows something went wrong

# GOOD: At least inform the user
try:
    result = int(user_input)
except ValueError:
    print("Please enter a valid number")
    result = None

3. Overly Broad Exception Catching

# BAD: Catches unrelated errors
try:
    data = json.loads(response.text)
    user = find_user(data["user_id"])
    user.update_profile(data)
except Exception as e:
    print(f"Error: {e}")

# GOOD: Handle each failure point specifically
try:
    data = json.loads(response.text)
except json.JSONDecodeError as e:
    print(f"Invalid JSON: {e}")
    return

try:
    user = find_user(data["user_id"])
except UserNotFoundError:
    print(f"User not found: {data['user_id']}")
    return

user.update_profile(data)

4. Not Preserving Traceback

# BAD: Loses original traceback
try:
    complex_operation()
except Exception as e:
    raise RuntimeError("Operation failed")

# GOOD: Preserve traceback with chaining
try:
    complex_operation()
except Exception as e:
    raise RuntimeError("Operation failed") from e

# GOOD: Or just re-raise
try:
    complex_operation()
except Exception:
    print("Logging the error...")
    raise

5. Mutable Default Arguments in Exception Classes

# BAD: Shared mutable default
class MyAppError(Exception):
    def __init__(self, message, details=[]):
        self.details = details
        super().__init__(message)

# GOOD: Create new list each time
class MyAppError(Exception):
    def __init__(self, message, details=None):
        self.details = details if details is not None else []
        super().__init__(message)

Practice Exercises

Exercise 1: Safe Division Function

Write a function that safely divides two numbers and handles all possible errors.

Solution:

def safe_divide(a, b, default=None):
    """Safely divide a by b, returning default on error."""
    try:
        result = a / b
    except ZeroDivisionError:
        print(f"Warning: Cannot divide {a} by zero")
        return default
    except TypeError as e:
        print(f"Warning: Invalid types for division: {e}")
        return default
    else:
        return result

print(safe_divide(10, 3))       # 3.3333333333333335
print(safe_divide(10, 0))       # Warning: Cannot divide 10 by zero → None
print(safe_divide(10, "a"))     # Warning: Invalid types... → None
print(safe_divide(10, 2, 0))    # 5.0

Exercise 2: Robust File Reader

Write a function that reads a JSON file and returns the data, with appropriate error handling for all common failure modes.

Solution:

import json

def read_json_file(filepath, default=None):
    """Read and parse a JSON file with comprehensive error handling."""
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            try:
                return json.load(f)
            except json.JSONDecodeError as e:
                print(f"JSON parse error in '{filepath}': {e}")
                return default
    except FileNotFoundError:
        print(f"File not found: '{filepath}'")
        return default
    except PermissionError:
        print(f"Permission denied: '{filepath}'")
        return default
    except UnicodeDecodeError:
        print(f"Encoding error reading '{filepath}'")
        return default
    except OSError as e:
        print(f"OS error reading '{filepath}': {e}")
        return default

# Test cases
print(read_json_file("config.json"))           # File not found → None
print(read_json_file("malformed.json"))         # JSON parse error → None
print(read_json_file("valid.json", default={})) # Parsed data or {}

Exercise 3: Custom Exception Hierarchy

Build an exception hierarchy for a banking application with classes for insufficient funds, invalid account, and transaction limit errors.

Solution:

class BankingError(Exception):
    """Base exception for banking operations."""
    def __init__(self, message, account_id=None):
        self.account_id = account_id
        super().__init__(message)

class AccountNotFoundError(BankingError):
    """Raised when an account doesn't exist."""
    def __init__(self, account_id):
        super().__init__(f"Account '{account_id}' not found", account_id)

class InsufficientFundsError(BankingError):
    """Raised when balance is too low."""
    def __init__(self, account_id, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Insufficient funds: have ${balance:.2f}, need ${amount:.2f}",
            account_id
        )

class TransactionLimitError(BankingError):
    """Raised when daily transaction limit exceeded."""
    def __init__(self, account_id, limit, daily_total):
        self.limit = limit
        self.daily_total = daily_total
        super().__init__(
            f"Daily limit ${limit:.2f} exceeded (current: ${daily_total:.2f})",
            account_id
        )

# Usage
def transfer(from_account, to_account, amount, accounts, daily_totals):
    if from_account not in accounts:
        raise AccountNotFoundError(from_account)
    if to_account not in accounts:
        raise AccountNotFoundError(to_account)

    balance = accounts[from_account]
    if balance < amount:
        raise InsufficientFundsError(from_account, balance, amount)

    daily_total = daily_totals.get(from_account, 0)
    if daily_total + amount > 10000:
        raise TransactionLimitError(from_account, 10000, daily_total + amount)

    accounts[from_account] -= amount
    accounts[to_account] += amount
    daily_totals[from_account] = daily_total + amount
    return True

# Test
accounts = {"A1": 500, "B2": 200}
daily_totals = {}

try:
    transfer("A1", "B2", 300, accounts, daily_totals)
    print("Transfer successful")
except BankingError as e:
    print(f"Transfer failed: {e}")

try:
    transfer("A1", "B2", 400, accounts, daily_totals)
except InsufficientFundsError as e:
    print(f"Transfer failed: {e}")
    print(f"  Current balance: ${e.balance:.2f}")
    print(f"  Attempted: ${e.amount:.2f}")

Key Takeaways

  1. Catch specific exceptions — never use bare except: or catch Exception unless you have a good reason
  2. Use try/except/else/finally — put only risky code in try, put success logic in else
  3. Always re-raise or handle meaningfully — swallowing exceptions hides bugs
  4. Create custom exceptions for domain-specific errors with useful context
  5. Prefer EAFP — try the operation and handle failure rather than checking first
  6. Chain exceptions with raise from to preserve the full error context
  7. Context managers guarantee cleanup even when exceptions occur
  8. Log exceptions before handling so you can debug production issues
  9. Never catch KeyboardInterrupt, SystemExit, or GeneratorExit
  10. Structure exception hierarchies to allow both specific and general catching

Error handling isn't about preventing all errors — it's about handling the ones you expect gracefully and letting the ones you don't expect crash loudly so you can fix them.

Advertisement

Need Expert Python Help?

Get personalized tutoring, project support, or professional consulting.

Advertisement