Ad – 728Γ—90
⚑ Intermediate

Python Exception Handling – try, except, finally & raise

Every program encounters errors β€” a missing file, bad user input, a network timeout, a division by zero. Python's exception handling system lets you anticipate these failures and respond gracefully instead of crashing. This lesson teaches you the full toolkit: try/except/else/finally, the exception hierarchy, raising your own exceptions, creating custom exception classes, and the patterns professional developers use every day.

⏱️ 25 min read 🎯 Intermediate πŸ“… Updated 2026

Introduction – Why Errors Happen

In Python, when something goes wrong at runtime, it raises an exception β€” a signal that an error has occurred. If that exception is not caught and handled, Python prints a traceback and the program stops immediately.

There are two fundamental categories of errors:

  • Syntax errors – caught before the program runs (e.g., missing colon, mismatched brackets). You fix these in your editor.
  • Exceptions (runtime errors) – occur while the program is running. These can be caught and handled with try/except.
Python
# Without exception handling – program crashes
number = int("abc")   # ValueError: invalid literal for int()
print("This line never runs")
β–Ά Output
ValueError: invalid literal for int() with base 10: 'abc'

Exception handling transforms this hard crash into a controlled response your program can recover from.

The try / except Block

The most basic form wraps risky code in a try block and places your recovery code in the except block:

Python
try:
    number = int(input("Enter a number: "))
    result = 100 / number
    print(f"100 divided by {number} is {result}")
except ValueError:
    print("That's not a valid integer.")
except ZeroDivisionError:
    print("You can't divide by zero!")

print("Program continues running...")
β–Ά Output (user enters "abc")
That's not a valid integer. Program continues running...
πŸ’‘
Always Catch Specific Exceptions

Catch the most specific exception type you expect. Using a bare except: or except Exception: as your only clause silences bugs you didn't intend to handle. Be precise.

Catching Multiple Exceptions

You can have as many except clauses as you need, and you can also group multiple exception types into one clause with a tuple:

Python
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        print("Error: Cannot divide by zero")
        return None
    except TypeError:
        print("Error: Both arguments must be numbers")
        return None

# Group multiple exceptions in one clause
def parse_input(value):
    try:
        return int(value)
    except (ValueError, TypeError) as e:
        print(f"Conversion failed: {e}")
        return 0

print(safe_divide(10, 2))    # 5.0
print(safe_divide(10, 0))    # Error: Cannot divide by zero β†’ None
print(parse_input("42"))     # 42
print(parse_input("hello"))  # Conversion failed: ... β†’ 0
β–Ά Output
5.0 Error: Cannot divide by zero None 42 Conversion failed: invalid literal for int() with base 10: 'hello' 0

The else and finally Clauses

Python's try block supports two additional optional clauses that give you finer control:

ClauseWhen it runsUse case
elseOnly when no exception was raised in tryCode that should run only on success
finallyAlways – whether or not an exception occurredCleanup: closing files, releasing connections
Python
def read_and_process(filename):
    file = None
    try:
        file = open(filename, "r")
        data = file.read()
    except FileNotFoundError:
        print(f"File '{filename}' not found.")
    except PermissionError:
        print(f"No permission to read '{filename}'.")
    else:
        # Runs only if open() and read() succeeded
        print(f"File read successfully: {len(data)} characters")
        return data
    finally:
        # ALWAYS runs – perfect for cleanup
        if file:
            file.close()
            print("File handle closed.")
    return None

read_and_process("notes.txt")
β–Ά Output (file exists)
File read successfully: 412 characters File handle closed.
⚠️
finally Always Runs β€” Even After return

Even if your try or except block has a return statement, the finally block still executes before the function returns. This makes it the safest place for cleanup code.

Accessing Exception Information

Use as e in the except clause to capture the exception object. This gives you access to the error message and other attributes:

Python
import traceback

try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(f"Exception type : {type(e).__name__}")
    print(f"Exception message: {e}")
    print(f"Args: {e.args}")

# Getting a full traceback as a string
try:
    x = int("bad")
except ValueError as e:
    tb = traceback.format_exc()
    print("Full traceback:")
    print(tb)
β–Ά Output
Exception type : ZeroDivisionError Exception message: division by zero Args: ('division by zero',) Full traceback: Traceback (most recent call last): File "...", line 10, in <module> x = int("bad") ValueError: invalid literal for int() with base 10: 'bad'
Ad – 336Γ—280

The Exception Hierarchy

Python's built-in exceptions are organized in an inheritance tree. Catching a parent catches all children. Knowing the hierarchy helps you catch at the right level of specificity:

Python
# Simplified exception hierarchy
# BaseException
#   β”œβ”€β”€ SystemExit          (sys.exit())
#   β”œβ”€β”€ KeyboardInterrupt   (Ctrl+C)
#   └── Exception           ← catch here for general errors
#         β”œβ”€β”€ ArithmeticError
#         β”‚     β”œβ”€β”€ ZeroDivisionError
#         β”‚     └── OverflowError
#         β”œβ”€β”€ LookupError
#         β”‚     β”œβ”€β”€ IndexError
#         β”‚     └── KeyError
#         β”œβ”€β”€ ValueError
#         β”œβ”€β”€ TypeError
#         β”œβ”€β”€ AttributeError
#         β”œβ”€β”€ NameError
#         β”œβ”€β”€ IOError / OSError
#         β”‚     └── FileNotFoundError
#         └── RuntimeError

# Catching a parent catches all children:
try:
    my_list = [1, 2, 3]
    print(my_list[10])       # IndexError
except LookupError as e:
    # Catches both IndexError and KeyError
    print(f"Lookup failed: {e}")
β–Ά Output
Lookup failed: list index out of range
ℹ️
Never Catch BaseException Directly

BaseException includes SystemExit and KeyboardInterrupt. Catching it would prevent Ctrl+C from stopping your program. Always use Exception as the broadest catch, and only when genuinely needed.

Raising Exceptions with raise

You can deliberately trigger exceptions using the raise statement. This is useful for enforcing constraints, signaling invalid states, or re-raising caught exceptions:

Python
def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be an integer, got {type(age).__name__}")
    if age < 0 or age > 150:
        raise ValueError(f"Age {age} is not realistic (expected 0–150)")
    return age

# Re-raising: catch, log, then let it propagate
def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        print(f"[ERROR] Config file missing: {path}")
        raise   # Re-raises the same exception

# Chaining exceptions with 'raise ... from ...'
def fetch_user(user_id):
    try:
        # simulate a database error
        raise ConnectionError("DB connection refused")
    except ConnectionError as e:
        raise RuntimeError("Failed to fetch user") from e

try:
    set_age("thirty")
except TypeError as e:
    print(e)

try:
    set_age(200)
except ValueError as e:
    print(e)
β–Ά Output
Age must be an integer, got str Age 200 is not realistic (expected 0–150)

Custom Exception Classes

For larger applications you should define your own exception types. This makes error handling code self-documenting and lets callers distinguish between different failure modes precisely.

Python
# Base custom exception for the entire application
class AppError(Exception):
    """Base class for all application errors."""
    pass

# Domain-specific exceptions
class ValidationError(AppError):
    """Raised when input data fails validation."""
    def __init__(self, field, message):
        self.field = field
        self.message = message
        super().__init__(f"Validation error on '{field}': {message}")

class DatabaseError(AppError):
    """Raised when a database operation fails."""
    def __init__(self, operation, detail=""):
        self.operation = operation
        super().__init__(f"Database {operation} failed. {detail}".strip())

class AuthenticationError(AppError):
    """Raised when authentication fails."""
    pass

# Using custom exceptions
def register_user(username, email, password):
    if len(username) < 3:
        raise ValidationError("username", "Must be at least 3 characters")
    if "@" not in email:
        raise ValidationError("email", "Must be a valid email address")
    if len(password) < 8:
        raise ValidationError("password", "Must be at least 8 characters")
    print(f"User '{username}' registered successfully!")

try:
    register_user("Al", "not-an-email", "pass")
except ValidationError as e:
    print(f"[{e.field.upper()}] {e.message}")
except AppError as e:
    print(f"Application error: {e}")
β–Ά Output
[USERNAME] Must be at least 3 characters

Exception Handling with Context Managers

The with statement is Python's preferred way to handle resources that need cleanup. Context managers guarantee the cleanup code runs β€” even if an exception occurs β€” without writing finally manually.

Python
from contextlib import contextmanager

# The 'with' statement handles file closing automatically
def count_words(filename):
    try:
        with open(filename, "r") as f:
            content = f.read()
            return len(content.split())
    except FileNotFoundError:
        print(f"File not found: {filename}")
        return 0

# Custom context manager for database-like transactions
@contextmanager
def managed_transaction(name):
    print(f"[TX] Starting transaction: {name}")
    try:
        yield
        print(f"[TX] Committed: {name}")
    except Exception as e:
        print(f"[TX] Rolled back: {name} β€” {e}")
        raise

# Using the custom context manager
try:
    with managed_transaction("create_user"):
        print("Inserting user record...")
        raise DatabaseError("INSERT", "Duplicate key")
except DatabaseError as e:
    print(f"Caught outside: {e}")
β–Ά Output
[TX] Starting transaction: create_user Inserting user record... [TX] Rolled back: create_user β€” Database INSERT failed. Duplicate key Caught outside: Database INSERT failed. Duplicate key

Best Practices for Exception Handling

πŸ’‘
Best Practice Checklist
  • Be specific: catch the exact exception type, not a broad parent.
  • Don't swallow exceptions silently: always log or re-raise if you can't truly handle the error.
  • Use finally for cleanup: close files, connections, and locks.
  • Prefer context managers: with open(...) is safer than manual file.close().
  • Create custom exceptions: makes your API self-documenting and catches precise errors.
  • Don't use exceptions for flow control: they are for exceptional conditions, not regular logic.
Python
import logging

logging.basicConfig(level=logging.ERROR)

# ❌ Anti-pattern: silent exception swallowing
def bad_parse(text):
    try:
        return int(text)
    except Exception:
        pass  # Bug disappears here β€” never do this!

# βœ… Good pattern: log and return a meaningful default
def good_parse(text, default=0):
    try:
        return int(text)
    except ValueError:
        logging.error("Could not parse '%s' as int, using default %d", text, default)
        return default

# ❌ Anti-pattern: catching too broadly
def bad_process(data):
    try:
        result = complex_operation(data)
    except Exception:  # Catches everything including bugs in your code!
        return None

# βœ… Good pattern: only catch what you expect
def good_process(data):
    try:
        result = complex_operation(data)
    except (ValueError, KeyError) as e:
        logging.warning("Expected error during processing: %s", e)
        return None
    # Other unexpected exceptions will propagate correctly

Real-World Error Handling Patterns

Here is a realistic example combining everything: a function that fetches JSON from a file with robust error handling at each stage.

Python
import json
import logging

class ConfigLoadError(Exception):
    pass

def load_config(filepath):
    """
    Load and parse a JSON configuration file.
    Returns a dict on success, raises ConfigLoadError on failure.
    """
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            raw = f.read()
    except FileNotFoundError:
        raise ConfigLoadError(f"Config file not found: {filepath}")
    except PermissionError:
        raise ConfigLoadError(f"No read permission for: {filepath}")
    except OSError as e:
        raise ConfigLoadError(f"OS error reading {filepath}: {e}") from e

    try:
        config = json.loads(raw)
    except json.JSONDecodeError as e:
        raise ConfigLoadError(
            f"Invalid JSON in {filepath} at line {e.lineno}: {e.msg}"
        ) from e

    if not isinstance(config, dict):
        raise ConfigLoadError("Config file must contain a JSON object at top level")

    logging.info("Config loaded successfully from %s", filepath)
    return config

# Caller handles the single, descriptive exception type
try:
    cfg = load_config("app_config.json")
    print("Database host:", cfg.get("db_host"))
except ConfigLoadError as e:
    print(f"Startup failed: {e}")
    # In a real app you'd exit(1) here

πŸ‹οΈ Practical Exercise

Write a function safe_calculator() that:

  1. Accepts two inputs from the user and an operator (+, -, *, /).
  2. Converts both inputs to floats (handle ValueError).
  3. Performs the operation (handle ZeroDivisionError for /).
  4. Raises a custom InvalidOperatorError if the operator is not one of the four supported.
  5. Uses finally to print "Calculation attempt complete." regardless of outcome.

πŸ”₯ Challenge Exercise

Build a mini retry decorator. Write a decorator @retry(times=3, delay=1) that calls the decorated function up to times attempts if it raises an exception, waits delay seconds between attempts, and raises the last exception if all attempts fail. Test it with a function that randomly raises ConnectionError.

Interview Questions

  • What is the difference between a syntax error and an exception in Python?
  • Explain the full try / except / else / finally flow. When does each block execute?
  • Why should you always catch specific exceptions rather than using a bare except?
  • What is exception chaining? How do you use raise ... from ...?
  • How do you create a custom exception class in Python?
  • What is the difference between BaseException and Exception?
  • When would you re-raise an exception with a bare raise?
  • How does a context manager (with statement) relate to exception handling?
  • What does the else clause of a try block do?
  • What is the finally block guaranteed to do even when an exception is raised?

πŸ“‹ Summary

  • try/except catches and handles exceptions, preventing program crashes.
  • Always catch the most specific exception type possible.
  • The else clause runs only when no exception occurred in try.
  • The finally clause always runs β€” use it for cleanup (closing files, connections).
  • Use as e to capture the exception object and inspect its message and type.
  • raise triggers an exception; raise ... from ... chains exceptions.
  • Custom exception classes (inheriting from Exception) make your code self-documenting.
  • Context managers (with) are the cleanest way to guarantee resource cleanup.
  • Never silently swallow exceptions β€” always log or re-raise.

Frequently Asked Questions

Can I have multiple except clauses for the same try block? +

Yes. You can add as many except clauses as you need. Python checks them top to bottom and executes only the first matching one. Put more specific exceptions before more general ones β€” otherwise the general one will match first.

What happens if an exception is raised inside a finally block? +

If an exception is raised inside finally, it replaces any exception that was already being propagated. This can hide the original error. Avoid raising exceptions in finally blocks β€” or use a try/except inside finally to handle them locally.

Should I always use exception handling instead of checking conditions? +

Python encourages the EAFP style ("Easier to Ask Forgiveness than Permission") β€” try it and catch failures β€” rather than LBYL ("Look Before You Leap") β€” check conditions first. EAFP is often cleaner and avoids race conditions (e.g., checking if a file exists before opening it has a race; just open it and catch FileNotFoundError).

How do I log exceptions properly in production? +

Use the logging module with logging.exception("message") inside an except block. Unlike logging.error(), logging.exception() automatically appends the full traceback to the log entry, which is invaluable for debugging in production.