Python

Exception handling — the real control flow

try/except isn't just "catch errors so the program doesn't crash" — where you catch, what you catch, and whether you re-raise are all real design decisions with different failure modes.

Intermediate

4 min read

What actually happens when an exception is raised

def parse_age(raw):
    return int(raw)
 
try:
    age = parse_age("thirty")
except ValueError:
    age = 0

Raising an exception immediately stops normal execution and unwinds the call stack — frame by frame — looking for a try block whose except matches the exception's type, until either a match is found or the exception reaches the top of the program and crashes it. int("thirty") raises ValueError from inside parse_age; that function has no try, so the exception propagates up and out of it to the caller's except ValueError, skipping every remaining line in parse_age entirely — int(raw) never returns normally, execution just leaves that function.

Why bare except: is a real bug, not just bad style

try:
    result = risky_operation()
except:                 # catches EVERYTHING, including KeyboardInterrupt and SystemExit
    result = None

A bare except: catches KeyboardInterrupt (Ctrl+C) and SystemExit, which are exceptions in Python's hierarchy specifically so cooperative code can choose to clean up on shutdown — but a blanket catch silently swallows the user's Ctrl+C or a deliberate sys.exit(), making the program impossible to interrupt normally. It also hides genuine bugs: a TypeError from a typo gets silently converted into result = None instead of surfacing as a crash you'd actually notice and fix. except Exception: is the usual "catch broadly but not that broadly" choice — it excludes KeyboardInterrupt and SystemExit, which both inherit from BaseException but not Exception.

Catching specific types, and why order matters

try:
    value = data[key]
except KeyError:
    value = default
except (TypeError, AttributeError):
    value = None

Python checks except clauses top to bottom and uses the first one whose type matches (via isinstance, so a subclass matches its parent's except too) — which is why a more specific exception type must be listed before a more general one that's also its parent class; an except Exception listed first would swallow every subclass beneath it, and any except clauses after it would be unreachable dead code.

raise vs. raise ... from: preserving the real cause

def load_config(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError as e:
        raise ConfigError(f"missing config: {path}") from e

Re-raising a different, more meaningful exception is common — callers of load_config shouldn't need to know it's implemented with json.load under the hood. from e explicitly chains the original exception as the new one's __cause__, so the traceback shows both: "this ConfigError happened, caused by this underlying FileNotFoundError" — genuinely useful when debugging, versus a bare raise ConfigError(...) which discards the original traceback context entirely (Python still shows it as "during handling of the above exception," implicitly, via __context__, but from e makes the causal relationship explicit and intentional rather than incidental).

finally: the one block that always runs

def process(conn):
    try:
        return conn.execute(query)
    finally:
        conn.close()

finally runs whether the try block succeeds, raises, or even hits a return inside it — this is exactly the guarantee a context manager's __exit__ relies on internally, and why finally is the right place for cleanup that absolutely must happen (closing a connection, releasing a lock) regardless of how the block exits. It runs before the return value is actually handed back to the caller, and before an unhandled exception continues propagating outward.

Why exceptions are Python's normal control flow, not just an error mechanism

StopIteration (ending a for loop), KeyError/IndexError (distinguishing "missing" from a real value in patterns like dict.get), and even generator cleanup all use exceptions as the mechanism, not just for genuine failures — this is a deliberate language design choice ("easier to ask forgiveness than permission," EAFP), distinct from languages that treat exceptions as exclusively for exceptional, unrecoverable failures.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why is a bare `except:` considered a real bug, not just bad style?

2. In a try block with multiple except clauses, why must a more specific exception type be listed before its more general parent class?

3. What does `raise ConfigError(...) from e` do that a bare `raise ConfigError(...)` doesn't?

4. When does a `finally` block run relative to a `return` inside the `try`?