Error Handling & Exceptions

Difficulty

The hierarchy, top-down

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── TypeError
      ├── KeyError, IndexError (-> LookupError)
      ├── OSError (FileNotFoundError, PermissionError, ...)
      ├── ArithmeticError (ZeroDivisionError, ...)
      └── ... (all "normal" errors your code raises/catches)

Exception is the practical root for everything you raise and catch in application code. BaseException additionally covers SystemExit (raised by sys.exit()) and KeyboardInterrupt (Ctrl+C) — these deliberately sit outside Exception so that a broad except Exception: doesn't accidentally swallow a user's Ctrl+C or a deliberate sys.exit() call.

Defining a custom exception

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

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    withdraw(100, 150)
except InsufficientFundsError as e:
    print(e)              # the formatted message
    print(e.balance, e.amount)   # structured fields for programmatic handling

Subclassing Exception and calling super().__init__(message) gives you the standard string representation (str(e)) for free, while attaching extra attributes (self.balance, self.amount) lets calling code inspect structured error details instead of parsing a message string.

Building an exception hierarchy for a library/app

class AppError(Exception):
    '''Base class for all errors raised by this application.'''

class ValidationError(AppError):
    pass

class NotFoundError(AppError):
    pass

class PermissionDeniedError(AppError):
    pass

try:
    do_something()
except AppError as e:      # catches ANY of the app's custom errors
    log.error(e)
except Exception:            # anything unexpected/unrelated to the app's domain
    raise

Defining one base exception per library/module (AppError) and deriving all specific errors from it lets calling code choose the granularity it needs: catch a specific subtype, or catch the whole family via the shared base, without needing a tuple of every specific exception class.

When to subclass a built-in instead

class InvalidEmailError(ValueError):   # "is a" ValueError -- caller code that
    pass                                 # already catches ValueError still works

If your custom error is genuinely a more specific case of a built-in (a malformed email is a kind of ValueError), subclassing the built-in lets existing code that catches the built-in continue to work correctly without changes.

Interview-ready summary: Exception is the practical base for application errors (BaseException also covers SystemExit/ KeyboardInterrupt, deliberately excluded from Exception so broad handlers don't eat them). Custom exceptions are ordinary subclasses, usually of Exception or a relevant built-in, adding structured fields via __init__ — and a shared base exception per module/library lets callers choose how broadly to catch.

Specific: the default, correct choice

try:
    value = int(user_input)
except ValueError:
    print("please enter a valid number")

Only ValueError (or its subclasses) is handled here — a KeyboardInterrupt during the try block, or a typo causing a NameError, propagates normally instead of being silently absorbed. This is almost always what you want: handle the error you anticipated, let anything else surface.

except Exception: — a broad but bounded net

try:
    risky_operation()
except Exception as e:
    log.error(f"operation failed: {e}")

Reasonable at a system boundary (a web request handler, a task queue worker) where you genuinely want to catch "anything that went wrong during business logic" and convert it to a controlled response/log entry, without swallowing process-control signals. Still risky if overused deep inside application logic, since it can mask bugs (a typo raising NameError gets logged and ignored instead of crashing loudly during development).

Bare except: — almost always a mistake

try:
    risky_operation()
except:            # DON'T -- catches EVERYTHING, including:
    pass             # SystemExit, KeyboardInterrupt, and typos like NameError

A bare except: is equivalent to except BaseException: — it will swallow sys.exit() (so your program refuses to actually exit), KeyboardInterrupt (so Ctrl+C appears to do nothing), and any programming error (AttributeError from a typo) that you'd want to see and fix immediately. Linters (flake8, ruff) flag bare except: for exactly this reason.

Multiple specific exceptions

try:
    process(data)
except (ValueError, TypeError) as e:
    print(f"bad input: {e}")
except KeyError as e:
    print(f"missing field: {e}")

A tuple of exception types in one except clause handles them identically; separate except clauses let you respond differently per error type. Order matters — Python checks clauses top to bottom and uses the first match, so a more specific exception type must come before a more general one that would otherwise shadow it (e.g., except ValueError before except Exception).

The practical rule

Catch the most specific exception type that you know how to handle, as close as possible to where you can meaningfully react to it. Reserve except Exception for true boundary layers (top-level request handlers, background job runners), and never use a bare except: — if you truly need to catch everything including SystemExit/KeyboardInterrupt (rare — e.g., a supervisor process), spell out except BaseException: explicitly so the intent is unambiguous.

Interview-ready summary: Prefer catching the specific exception type you can actually handle. except Exception is acceptable at application boundaries as a last line of defense, but never use a bare except: — it silently swallows SystemExit/KeyboardInterrupt and masks genuine programming errors that should crash loudly instead.

Implicit chaining: happens automatically

def parse_config(raw):
    try:
        return int(raw)
    except ValueError:
        raise RuntimeError("config value must be numeric")

parse_config("abc")
ValueError: invalid literal for int() with base 10: 'abc'

During handling of the above exception, another exception occurred:

RuntimeError: config value must be numeric

Because RuntimeError was raised inside the except ValueError: block, Python automatically links the original ValueError as the new exception's __context__ — the full traceback shows both, which is invaluable for debugging (you see not just "config is bad" but why, down to the actual parse failure).

Explicit chaining with from: marking intent

def parse_config(raw):
    try:
        return int(raw)
    except ValueError as e:
        raise RuntimeError("config value must be numeric") from e
ValueError: invalid literal for int() with base 10: 'abc'

The above exception was the direct cause of the following exception:

RuntimeError: config value must be numeric

from e sets __cause__ explicitly, and Python's traceback formatting changes to "was the direct cause of" — signaling this chaining was deliberate (a real causal relationship), not just "this happened to occur while handling something else." Tools and some linters distinguish __cause__ (explicit) from __context__ (implicit) accordingly.

Suppressing chaining entirely: from None

def parse_config(raw):
    try:
        return int(raw)
    except ValueError:
        raise RuntimeError("config value must be numeric") from None
RuntimeError: config value must be numeric

from None suppresses the chained traceback entirely — useful when the original low-level exception is genuinely irrelevant noise for the caller (e.g., a public API deliberately hiding internal implementation exceptions behind a clean, documented error type).

Why this matters beyond cosmetics

Exception chaining preserves the full causal chain for debugging — without it, catching a low-level error and re-raising a higher-level one (a very common pattern for wrapping errors in domain-specific exception types) would silently discard the original error's message and traceback, making production debugging much harder ("config value must be numeric" tells you what failed, but not why the int conversion failed).

Interview-ready summary: Raising an exception inside an except block automatically chains it via __context__; raise ... from cause sets __cause__ explicitly to mark the relationship as intentional ("direct cause"), and raise ... from None suppresses chaining when the original exception is genuinely irrelevant to expose. Either way, chaining keeps the full failure history visible instead of discarding it when wrapping low-level errors in higher-level ones.

finally: guaranteed cleanup

def read_config(path):
    f = open(path)
    try:
        return f.read()
    finally:
        f.close()    # ALWAYS runs -- success, exception, or even an early return

finally runs no matter how the try block exits: normal completion, a caught exception, an uncaught exception (it still runs before the exception propagates further), or control-flow statements like return inside the try. This unconditional guarantee is why it's the standard place for releasing resources (though in practice, a context manager — with open(path) as f: — is usually preferred over manual try/finally for exactly this pattern).

else: only on success, keeping the try block narrow

try:
    value = int(user_input)
except ValueError:
    print("not a valid number")
else:
    print(f"you entered {value}")   # only runs if int() did NOT raise
    process(value)                    # if process() raises, it's NOT caught by except ValueError above!

Without else, a common mistake is putting process(value) inside the try block itself — which means if process() also happens to raise a ValueError, it gets misattributed to "not a valid number" even though the real problem is unrelated to parsing. Moving success-dependent code into else keeps the try block scoped to exactly the operation you intend to guard, so unrelated exceptions from subsequent code aren't accidentally caught by the same handler.

Full combination

def process_file(path):
    try:
        f = open(path)
    except FileNotFoundError:
        print("file not found")
        return
    else:
        data = f.read()      # only runs if open() succeeded
    finally:
        try:
            f.close()          # always attempt cleanup...
        except NameError:
            pass                 # ...but f might not exist if open() failed
    return data

This shows the intended division of labor: try holds only the risky operation, except handles its specific failure, else holds what should happen only on success, and finally holds unconditional cleanup — each clause has one clear job instead of being crammed together.

Execution order guarantee

For a successful try: try body → else body → finally body. For a caught exception: try body (until the exception) → matching except body → finally body (note: else is skipped whenever an exception occurred, matched or not).

Interview-ready summary: finally is unconditional cleanup that runs regardless of outcome; else runs only when the try block succeeded, letting you separate "the operation that might fail" from "what happens next only if it didn't" — which avoids accidentally catching unrelated exceptions raised by follow-up code that has nothing to do with the original try.

LBYL: check first, then act

if "key" in d:
    value = d["key"]
else:
    value = default

if hasattr(obj, "process"):
    obj.process()

if os.path.exists(path):
    f = open(path)

Each of these checks a condition, then performs the action — two separate operations, with a gap in between where the condition could theoretically become stale.

EAFP: try it, handle the failure

try:
    value = d["key"]
except KeyError:
    value = default

try:
    obj.process()
except AttributeError:
    pass

try:
    f = open(path)
except FileNotFoundError:
    ...

Each of these makes one attempt and reacts to failure — no separate "check" step at all.

Why EAFP is preferred: avoiding TOCTOU races

# LBYL -- racy: the file could be deleted between the check and the open()
if os.path.exists(path):
    f = open(path)     # could still raise FileNotFoundError anyway!

# EAFP -- no race: a single atomic attempt, with a clear failure path
try:
    f = open(path)
except FileNotFoundError:
    ...

Between os.path.exists(path) returning True and the subsequent open(path) call, another process (or thread) could delete the file — the LBYL version can still fail even after its check passed, so you end up needing the try/except anyway. EAFP skips the redundant, racy check entirely and handles the one real failure point directly.

Why it's not a performance tradeoff in the common case

A try block costs essentially nothing when no exception is raised — the overhead only shows up when an exception actually occurs. So for operations that usually succeed (the common case for dict lookups, attribute access, file opens), EAFP is both cleaner and has no meaningful performance downside; it only becomes more expensive than LBYL in the (usually rare) case where failures are frequent enough that exception-raising overhead adds up.

When LBYL still makes sense

if amount > 0:          # a business-logic precondition, not error handling
    withdraw(amount)

LBYL remains appropriate for validating business rules/preconditions that aren't really "errors" in the exceptional sense (e.g., checking a withdrawal amount is positive) — EAFP is specifically about operations whose failure mode is naturally expressed as an exception (missing key, missing attribute, missing file), not a stand-in for all conditional logic.

Interview-ready summary: EAFP attempts an operation and handles failure via except; LBYL checks preconditions first. Python favors EAFP for operations with a natural exception-based failure mode because it avoids check-then-act race conditions and costs nothing extra when the operation succeeds, which is the common case.

Related Resources