Python Exception Handling — try, except, finally, and Custom Exceptions

Sanjeev SharmaSanjeev Sharma
4 min read

Advertisement

Introduction

Why This Matters

Runtime errors are inevitable. Files go missing, networks fail, APIs return unexpected data, and users provide invalid input. Unhandled exceptions crash programs and expose confusing stack traces to users. Python's exception handling system gives you the tools to anticipate failures, recover gracefully, and provide meaningful error messages.

In production systems, proper exception handling is the difference between a self-healing application that logs errors and continues serving users, versus one that crashes and requires manual intervention. Libraries like FastAPI, SQLAlchemy, and Django all rely on exception handling for request validation, transaction rollback, and HTTP error responses.

Understanding Python's exception hierarchy and how to create custom exception classes is also a key differentiator in engineering interviews and senior-level code reviews.

Basic try/except

try:
    number = int(input("Enter a number: "))
    result = 100 / number
    print(f"Result: {result}")
except ValueError:
    print("Please enter a valid integer.")
except ZeroDivisionError:
    print("Division by zero is not allowed.")

Catching Multiple Exceptions

def parse_config(path: str) -> dict:
    try:
        with open(path) as f:
            import json
            return json.load(f)
    except FileNotFoundError:
        print(f"Config file not found: {path}")
        return {}
    except json.JSONDecodeError as e:
        print(f"Invalid JSON in config: {e}")
        return {}
    except (PermissionError, OSError) as e:
        print(f"Cannot read config file: {e}")
        return {}

else and finally

def read_file(path: str) -> str:
    f = None
    try:
        f = open(path)
        content = f.read()
    except FileNotFoundError:
        print("File not found")
        return ""
    else:
        # Runs only if no exception was raised
        print(f"Successfully read {len(content)} characters")
        return content
    finally:
        # Always runs — cleanup code
        if f:
            f.close()
            print("File closed")

Context Managers and finally

The with statement handles cleanup automatically and is preferred over manual finally:

def read_file_safe(path: str) -> str:
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        return ""

Accessing Exception Information

import traceback
 
try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(f"Error type: {type(e).__name__}")  # ZeroDivisionError
    print(f"Error message: {e}")              # division by zero
    traceback.print_exc()                     # full stack trace to stderr

Custom Exception Classes

Custom exceptions make errors self-documenting and allow callers to catch specific failures.

class AppError(Exception):
    """Base exception for this application."""
    pass
 
class ValidationError(AppError):
    def __init__(self, field: str, message: str):
        self.field = field
        self.message = message
        super().__init__(f"Validation failed for '{field}': {message}")
 
class NotFoundError(AppError):
    def __init__(self, resource: str, resource_id):
        self.resource = resource
        self.resource_id = resource_id
        super().__init__(f"{resource} with id={resource_id} not found")
 
 
def get_user(user_id: int) -> dict:
    users = {1: {"name": "Alice"}}
    if user_id not in users:
        raise NotFoundError("User", user_id)
    return users[user_id]
 
 
try:
    user = get_user(99)
except NotFoundError as e:
    print(e)  # User with id=99 not found
except AppError as e:
    print(f"Application error: {e}")

Exception Chaining with raise from

class DatabaseError(Exception):
    pass
 
def fetch_user(user_id: int):
    try:
        # Simulate DB failure
        raise ConnectionError("DB connection refused")
    except ConnectionError as e:
        raise DatabaseError("Failed to fetch user") from e
 
try:
    fetch_user(1)
except DatabaseError as e:
    print(e)
    print(f"Caused by: {e.__cause__}")

Raising Exceptions

def set_age(age: int) -> None:
    if not isinstance(age, int):
        raise TypeError(f"Age must be int, got {type(age).__name__}")
    if age < 0 or age > 150:
        raise ValueError(f"Age must be between 0 and 150, got {age}")
 
# Re-raise an exception after logging
try:
    set_age(-5)
except ValueError as e:
    print(f"Logging error: {e}")
    raise  # re-raise original exception

Common Mistakes

  • Using a bare except: clause — catches SystemExit and KeyboardInterrupt too
  • Catching Exception as a catch-all instead of specific exception types
  • Swallowing exceptions silently with pass in except blocks
  • Raising a new exception inside except without from — hides the original cause
  • Not cleaning up resources in finally — always use with statements when available

Best Practices

  • Catch the most specific exception type possible
  • Always use from when chaining exceptions to preserve context
  • Define a custom exception hierarchy rooted at a base AppError class
  • Log exceptions with full tracebacks using the logging module, not print()
  • Use context managers (with) to guarantee resource cleanup

Key Takeaways

  • try/except catches exceptions; else runs if no exception; finally always runs
  • Catch specific exceptions (e.g., ValueError) rather than broad Exception
  • Custom exceptions inherit from Exception and make error handling self-documenting
  • Use raise ExceptionType from original to chain exceptions and preserve context
  • traceback.print_exc() or logging.exception() logs the full stack trace
  • Avoid bare except: — it catches everything including keyboard interrupts
  • The with statement is the cleanest way to ensure resources are released

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading