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.
Related Resources
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.
Related Resources
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.
Related Resources
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.
Related Resources
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.