Python Exceptions

An exception is Python’s way of telling you that something went wrong while your program was running — a file wasn’t found, a list index didn’t exist, or a user typed text where a number was expected. Instead of crashing silently or continuing with garbage data, Python raises an exception object that carries information about the failure, and your code gets a structured way to detect it, react to it, and keep running. Mastering exceptions is what separates scripts that fall over on the first bad input from programs that behave predictably under real-world conditions.

Overview: How Exceptions Work

Every error in Python — from division by zero to a missing dictionary key — is represented by an exception object, an instance of a class that ultimately inherits from BaseException. When something goes wrong, Python constructs one of these objects and raises it. This immediately stops normal execution of the current statement and begins unwinding the call stack: Python walks back up through each function call that is currently active, looking for a surrounding try block that declares it can handle this kind of exception. If it finds a matching except clause, execution jumps into that block. If it walks all the way back to the top level of the program without finding a handler, the interpreter prints a traceback — the chain of calls that led to the error — and the program terminates.

Almost every exception you write and catch derives from Exception, itself a subclass of BaseException. Three direct subclasses of BaseException sit outside the Exception branch on purpose: SystemExit, KeyboardInterrupt, and GeneratorExit. Keeping them separate means a well-written except Exception: clause won’t accidentally swallow a user pressing Ctrl+C or the interpreter shutting down. A bare except:, however, catches literally everything — including those three — which is one reason experienced Python developers avoid it.

Every exception instance carries an args tuple (usually just the message string you see printed), plus two attributes Python sets automatically to help you debug: __context__, which records another exception that was already being handled when this one was raised (implicit chaining), and __cause__, which records an exception you explicitly linked with raise ... from ... (explicit chaining). Exceptions also carry a __traceback__ attribute — the frame-by-frame record of where the error occurred, which is exactly what an unhandled crash prints to your terminal.

Because raising and catching exceptions is relatively cheap in Python, and the language itself leans on them heavily (a StopIteration ends every for loop internally, KeyError powers dictionary lookups, IndexError bounds-checks sequences), Python culture favors EAFP — “Easier to Ask Forgiveness than Permission” — over LBYL, “Look Before You Leap.” Instead of checking whether a key exists before reading it, idiomatic Python just attempts the read and catches KeyError if it’s missing. This avoids a redundant check-then-use pattern and is generally considered more Pythonic.

Syntax

The general shape of exception handling in Python looks like this:

try:
    ...
except SomeException as exc:
    ...
except (AnotherException, YetAnotherException):
    ...
else:
    ...
finally:
    ...
Clause Purpose Runs when
try: Wraps code that might fail Always, first
except ExcType: Handles one specific exception type (or its subclasses) Only if that type was raised in try
except ExcType as e: Same, but binds the exception object to the name e Same as above
except (TypeError, ValueError): Handles several types with one block If any listed type was raised
else: Code that should run only when no exception occurred After try completes cleanly
finally: Cleanup code Always — exception or not, even across a return
raise ExcType(msg) Creates and raises a new exception Whenever executed
raise NewErr from cause Raises a new exception while recording another as its explicit cause Whenever executed

except clauses are checked top to bottom, and Python uses the first one that matches (using an isinstance-style check against the exception’s type). This means more specific exception types must be listed before more general ones — an except Exception: placed first will intercept everything below it, and any later, more specific clause becomes unreachable dead code.

Examples

Example 1: try / except / else / finally in Action

def parse_age(value: str) -> int:
    try:
        age = int(value)
    except ValueError:
        print(f"'{value}' is not a valid integer.")
        return -1
    else:
        print(f"Parsed age: {age}")
        return age
    finally:
        print("Finished attempting to parse.")

parse_age("25")
parse_age("twenty-five")

Output:

Parsed age: 25
Finished attempting to parse.
'twenty-five' is not a valid integer.
Finished attempting to parse.

When the input converts cleanly, the try block succeeds, so the else block runs and prints the parsed age. Notice that finally still runs afterward, even though else already executed a return — Python holds the return value, runs finally, and only then actually returns. When the input fails to convert, the except ValueError block runs instead of else, but finally still fires exactly the same way. This demonstrates that finally is unconditional cleanup code, while else is “only if nothing went wrong” code.

Example 2: Multiple Except Clauses and a Custom Exception

class InsufficientFundsError(Exception):
    def __init__(self, balance: float, amount: float):
        self.balance = balance
        self.amount = amount
        super().__init__(
            f"Cannot withdraw {amount:.2f}: balance is only {balance:.2f}"
        )

def withdraw(balance: float, amount: float) -> float:
    if amount <= 0:
        raise ValueError("Withdrawal amount must be positive")
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

accounts = [100.0, 100.0, 100.0]
amounts = [30.0, -5.0, 250.0]

for amount in amounts:
    try:
        accounts[0] = withdraw(accounts[0], amount)
    except ValueError as exc:
        print(f"Invalid input: {exc}")
    except InsufficientFundsError as exc:
        print(f"Transaction failed: {exc}")
    else:
        print(f"Success! New balance: {accounts[0]:.2f}")

print(f"Final balance: {accounts[0]:.2f}")

Output:

Success! New balance: 70.00
Invalid input: Withdrawal amount must be positive
Transaction failed: Cannot withdraw 250.00: balance is only 70.00
Final balance: 70.00

This example defines a custom exception, InsufficientFundsError, by subclassing Exception and building a descriptive message in __init__. The loop shows three outcomes for three different withdrawal amounts: a normal success (handled by else), an invalid negative amount (a built-in ValueError), and an amount that exceeds the balance (the custom exception). Because each except clause names a distinct type, Python routes each failure to the correct handler without any manual type-checking.

Example 3: Raising and Chaining Exceptions

def convert_temperatures(readings: list[str]) -> list[float]:
    results = []
    for raw in readings:
        try:
            celsius = float(raw)
        except ValueError as exc:
            raise ValueError(f"Bad reading {raw!r} in sensor data") from exc
        results.append(celsius * 9 / 5 + 32)
    return results

try:
    fahrenheit = convert_temperatures(["21.5", "19.0", "N/A"])
except ValueError as exc:
    print(f"Conversion failed: {exc}")
    print(f"Original cause: {exc.__cause__!r}")
else:
    print(fahrenheit)

Output:

Conversion failed: Bad reading 'N/A' in sensor data
Original cause: ValueError("could not convert string to float: 'N/A'")

Here, a low-level ValueError raised by float("N/A") is caught, then re-raised as a new, more descriptive ValueError using raise ... from exc. This is exception chaining: the new exception’s __cause__ attribute stores the original one, so nothing is lost. If this had propagated all the way to an unhandled crash, Python’s traceback would show both exceptions, connected by the line “The above exception was the direct cause of the following exception.” Chaining like this is far more useful for debugging than losing the original error entirely.

Under the Hood: Step by Step

When Python executes a try statement, here is what actually happens internally:

  • Python begins executing the statements inside try one at a time, as normal.
  • If one of them raises an exception, Python immediately stops executing that statement (and the rest of the try block) and creates the exception object, attaching a fresh __traceback__ frame.
  • Python compares the exception’s type against each except clause, top to bottom, using an isinstance check (so a clause for a parent class also matches subclass instances).
  • The first matching clause runs. Inside it, the exception is considered “handled” — unless you call raise again (bare, to re-raise the same exception, or with a new exception) to keep it propagating.
  • If no clause matches, the exception keeps propagating up to the caller’s stack frame, repeating this whole search there — and so on, until it’s handled or reaches the top of the program.
  • else only executes if the try block finished with no exception at all, and it runs after the try block but before finally.
  • finally always executes last, no matter what: whether the try succeeded, whether an except handled something, or even if the exception was never caught and is still propagating. This makes it the right place for cleanup like closing files or releasing locks.

Common Mistakes

Mistake 1: Using a Bare except:

Wrong:

try:
    value = int(user_input)
except:
    print("Something went wrong")

A bare except: catches every exception, including KeyboardInterrupt and SystemExit, and it also hides unrelated bugs — a typo in a variable name three lines later would raise NameError, and this code would silently print the same generic message instead of revealing the real problem. Always name the exception type you actually expect.

Corrected:

def parse_input(user_input: str) -> int | None:
    try:
        return int(user_input)
    except ValueError:
        print(f"'{user_input}' is not a valid integer")
        return None

print(parse_input("42"))
print(parse_input("abc"))

Output:

42
'abc' is not a valid integer
None

Mistake 2: Catching Exception Too Broadly Around Too Much Code

Wrong:

def process_orders(orders):
    try:
        for order in orders:
            total = order.qty * order.pric
            print(total)
    except Exception:
        print("Failed to process orders")

Here, order.pric is a typo (it should be order.price), which would raise a real AttributeError — a bug worth knowing about. But because the entire loop is wrapped in one broad try with except Exception:, the typo is silently converted into a generic “Failed to process orders” message every time, and the actual cause never surfaces, even during testing.

Corrected:

class Order:
    def __init__(self, qty: int, price: float):
        self.qty = qty
        self.price = price

def process_orders(orders: list[Order]) -> list[float]:
    totals = []
    for order in orders:
        try:
            totals.append(order.qty * order.price)
        except AttributeError as exc:
            print(f"Skipping malformed order: {exc}")
    return totals

orders = [Order(3, 9.5), Order(2, 4.0)]
print(process_orders(orders))

Output:

[28.5, 8.0]

The fix narrows the try block to just the risky expression and catches only the specific exception type expected from malformed data, so any other, unrelated bug still surfaces normally.

Best Practices

  • Catch specific exception types (ValueError, KeyError, your own custom classes) instead of bare except: or blanket except Exception:, unless you immediately log and re-raise.
  • Keep the try block as small as possible — wrap only the line or two that can actually fail, not an entire function body.
  • Use else for code that should run only after success; keeping it outside the try avoids accidentally catching exceptions that code itself might raise.
  • Prefer context managers (with open(...) as f:) over manual try/finally cleanup whenever a resource supports one — the cleanup happens automatically even on error.
  • Define custom exception classes for domain-specific errors (e.g. class ConfigError(Exception): ...) so callers can catch precisely what they expect instead of guessing at built-in types.
  • Preserve context with raise NewError(...) from original_exc so both tracebacks remain visible; use raise ... from None to deliberately suppress noisy, expected chaining.
  • Never leave an except block empty (except Exception: pass) — at minimum log the exception’s message so failures aren’t invisible.
  • Reserve exceptions for genuinely exceptional situations; for routine checks a plain if is often clearer, but embrace EAFP (try it, catch the failure) for things like optional dictionary keys or duck-typed objects.

Practice Exercises

  • Write a function safe_divide(a, b) that returns a / b, but catches ZeroDivisionError, prints a warning message, and returns None instead of crashing. Test it with safe_divide(10, 2) and safe_divide(10, 0).
  • Define a custom exception NegativeAmountError. Write a function deposit(balance, amount) that raises it whenever amount is negative, otherwise returns balance + amount. Loop over a list of deposit amounts including at least one negative value, catching NegativeAmountError and printing which deposits failed.
  • Write a function that takes a list of strings expected to represent integers (for example ["10", "xx", "20"]) and returns two lists: one of successfully converted integers, and one of the original strings that failed, using try/except inside a loop rather than stopping at the first failure. For the example input, the successful list should be [10, 20] and the failed list should be ['xx'].

Summary

  • An exception is an object — an instance of a class derived from BaseException — that Python raises when something goes wrong, unwinding the call stack until a matching except handles it.
  • try wraps risky code, except handles specific failures, else runs only on success, and finally always runs, making it ideal for cleanup.
  • except clauses are matched top to bottom by type, so specific exceptions must be listed before general ones like Exception.
  • Custom exceptions, created by subclassing Exception, let you model domain-specific errors that callers can catch precisely.
  • raise NewError(...) from original creates explicit exception chaining, preserving the original cause via __cause__ for easier debugging.
  • Avoid bare except: and empty except: pass blocks — they hide real bugs and can even swallow KeyboardInterrupt.
  • Python’s EAFP style — try the operation and catch the failure — is usually preferred over pre-checking conditions with if.