Exceptions

Exceptions are Python's mechanism for signaling and reacting to errors — objects raised when something goes wrong, propagated up the call stack until something handles them.

Things go wrong: a file doesn't exist, a division by zero is attempted, a network call times out. Python's answer is exceptions — ordinary objects, raised at the point of failure, that interrupt normal control flow and propagate upward until some code chooses to handle them or the program terminates with an error report.

What Is an Exception

An exception is an object representing an error or unusual condition. Like everything else in Python, exceptions are first-class objects — instances of exception classes, which themselves derive (directly or indirectly) from the built-in BaseException. Raising an exception immediately halts normal execution at that point and begins searching for a handler.

Dividing by zero raises a ZeroDivisionError object; with no handler present, the program prints a traceback and exits.

result = 10 / 0
# Traceback (most recent call last):
#   ...
# ZeroDivisionError: division by zero

Raising Exceptions

The raise statement explicitly triggers an exception — either an exception class (instantiated automatically) or an already-created instance. This is how your own code signals that something has gone wrong, not just how built-in operations fail.

raise with no arguments, used inside an except block, re-raises the exception currently being handled — useful for logging or partial cleanup before letting the error continue propagating.

def withdraw(balance: float, amount: float) -> float:
    if amount > balance:
        raise ValueError(f'insufficient funds: have {balance}, need {amount}')
    return balance - amount

withdraw(100, 150)  # raises ValueError
try:
    risky_operation()
except ValueError:
    log_error()
    raise   # re-raise the same exception, preserving its original traceback

Propagation: How an Unhandled Exception Travels

When an exception is raised and not immediately handled, it propagates up the call stack: the current function stops executing, and the exception is checked against handlers in the calling function, then that function's caller, and so on, until a matching handler is found or the stack is exhausted (in which case the interpreter prints a traceback and the program exits with a nonzero status).

Propagation stops at the first except clause (anywhere up the stack) whose exception type matches — not necessarily in the function where the error occurred.

def level_three():
    raise RuntimeError('deep failure')

def level_two():
    level_three()

def level_one():
    level_two()

level_one()  # RuntimeError propagates all the way up, unhandled

Tracebacks: Reading the Error Report

A traceback is the report Python prints for an unhandled exception, listing the chain of function calls active at the moment it was raised, most recent call last, ending with the exception type and message. Reading it bottom-up tells you what went wrong and where; reading the call chain tells you how execution got there.

Traceback (most recent call last):
  File "app.py", line 9, in <module>
    level_one()
  File "app.py", line 7, in level_one
    level_two()
  File "app.py", line 4, in level_two
    level_three()
  File "app.py", line 2, in level_three
    raise RuntimeError('deep failure')
RuntimeError: deep failure

Handling Exceptions

A try/except block catches an exception raised within the try body and runs handler code instead of letting the program crash. Full syntax and multi-branch handling get their own dedicated topic; the basic shape is shown here.

try:
    value = int(input('Enter a number: '))
except ValueError:
    print('That was not a valid number.')
else:
    print(f'You entered {value}')

The Exception Hierarchy

All exceptions derive from BaseException. Most everyday exceptions derive from Exception, a subclass of BaseException that excludes a small set of special cases (SystemExit, KeyboardInterrupt, GeneratorExit) meant to propagate past ordinary error handling rather than be casually caught. Built-in exceptions form a tree: catching a parent class also catches all its subclasses.

Catching a broad class like LookupError or Exception catches everything beneath it in the hierarchy — useful for generic handling, but it can also hide bugs by catching more than intended.

BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- ArithmeticError
      |    +-- ZeroDivisionError
      +-- LookupError
      |    +-- IndexError
      |    +-- KeyError
      +-- ValueError
      +-- TypeError
      +-- OSError
      +-- ... (many more)
try:
    [1, 2, 3][10]
except LookupError:      # catches IndexError, since it's a subclass
    print('lookup failed')

Custom Exceptions

You can define your own exception types by subclassing Exception (or a more specific built-in exception), giving your program's error conditions meaningful, catchable names distinct from generic built-ins.

class InsufficientFundsError(Exception):
    """Raised when a withdrawal exceeds the available balance."""

def withdraw(balance: float, amount: float) -> float:
    if amount > balance:
        raise InsufficientFundsError(f'need {amount}, have {balance}')
    return balance - amount

try:
    withdraw(50, 100)
except InsufficientFundsError as e:
    print('Blocked:', e)

Exception Chaining

When one exception is raised while handling another, Python automatically records the original as __context__, shown in the traceback as 'During handling of the above exception, another exception occurred'. raise NewError(...) from original_error sets this explicitly (__cause__) to document an intentional translation from one error type to another.

The traceback shows both exceptions, with 'from e' making clear the ValueError is a deliberate translation of the underlying SyntaxError, not an unrelated failure.

def parse_config(raw: str) -> dict:
    try:
        return eval(raw)  # illustrative only
    except SyntaxError as e:
        raise ValueError('invalid config format') from e

Best Practices

Catch the most specific exception type that's actually expected, rather than a bare except: or overly broad except Exception:, so genuine bugs aren't silently swallowed. Raise exceptions with clear, actionable messages. Reserve custom exception types for conditions your code's callers genuinely need to distinguish and handle differently.

Key Takeaways

Exceptions are objects representing errors; raising one interrupts normal control flow.

raise triggers an exception explicitly; bare raise inside except re-raises the current one.

An unhandled exception propagates up the call stack until a matching handler is found or the program exits with a traceback.

All exceptions derive from BaseException; most user-facing ones derive from Exception, which excludes SystemExit, KeyboardInterrupt, and GeneratorExit.

Custom exception classes give your program's error conditions meaningful, specifically catchable names.

raise ... from ... documents intentional exception chaining, distinct from an exception's incidental __context__.

try: do_something() except: pass try: do_something() except ValueError as e: log.warning('bad value: %s', e)

A bare except: catches everything, including KeyboardInterrupt and SystemExit, and silently discarding errors with pass hides real bugs instead of handling them.

raise Exception('something went wrong') raise ValueError('amount must be positive, got -5')

Raising the generic Exception class (or a vague message) makes it impossible for callers to catch this specific failure mode without accidentally catching every other error too; a specific type with a clear message is far more useful.

What is an exception, fundamentally, in Python's data model?

An object — an instance of an exception class that ultimately derives from BaseException — representing an error or unusual condition.

What happens to a raised exception if no except clause in the current function handles it?

It propagates up to the caller, then that caller's caller, and so on, until a matching handler is found or the call stack is exhausted, at which point the interpreter prints a traceback and the program exits.

What is the difference between the Exception class and BaseException?

BaseException is the root of the entire exception hierarchy; Exception is a subclass of it that excludes SystemExit, KeyboardInterrupt, and GeneratorExit, which are meant to propagate past ordinary error handling.

What does a bare raise (no arguments) do when used inside an except block?

It re-raises the exception currently being handled, preserving its original traceback, rather than raising a new one.

Why is except Exception: generally safer than a bare except:?

Because a bare except: also catches SystemExit and KeyboardInterrupt, which are meant to terminate the program, not be silently absorbed by ordinary error handling; except Exception: excludes those.

What does raise NewError(...) from original_error communicate that letting __context__ be set automatically does not?

It explicitly documents that NewError is an intentional translation of original_error (setting __cause__), rather than an incidental exception that happened to occur while handling another.

Why might catching a broad exception class like LookupError instead of the specific IndexError be a design decision worth thinking about?

It catches everything beneath it in the hierarchy (IndexError, KeyError, etc.), which is convenient for generic handling but risks silently catching error types the code wasn't actually prepared to handle.

Write a custom exception NegativeValueError and a function square_root(x) that raises it if x is negative, otherwise returns x ** 0.5.

class NegativeValueError(Exception): """Raised when a negative value is passed where non-negative is required.""" def square_root(x: float) -> float: if x < 0: raise NegativeValueError(f'cannot take square root of {x}') return x ** 0.5 square_root(-4) raises NegativeValueError('cannot take square root of -4'); square_root(4) returns 2.0. Callers can catch NegativeValueError specifically without also catching unrelated ValueErrors.

What does this print, and where was the exception actually raised versus where it was caught?

It prints "caught: 'missing'". The exception is raised inside inner(), propagates up through outer() (which does nothing to handle it), and is finally caught by the except KeyError clause in the outer try block — handling doesn't have to happen where the error occurred.

Explain what a traceback tells you, reading it from top to bottom versus focusing on the last line.

The last line gives the exception type and message — what went wrong. The lines above it, read top to bottom, show the chain of function calls active when the exception was raised (most recent call last), letting you trace exactly how execution reached the failure point.

def inner():
    raise KeyError('missing')

def outer():
    inner()

try:
    outer()
except KeyError as e:
    print(f'caught: {e}')