Error handling & exceptions
SeniorThe exception hierarchy, cause vs context chaining, custom hierarchies, EAFP vs LBYL, retries and service boundaries, logging, and tricky propagation.
Created 2 September 2026 · Updated 23 October 2026
17 concepts · detail hidden
01The built-in exception hierarchy — why `SystemExit`/`KeyboardInterrupt`/`GeneratorExit` sit outside `Exception`
#c-the-built-in-exception-hierarchy-why-systemexitkeyboardinterBaseExceptionsits at the very top of Python’s exception hierarchy; nearly every exception you’d normally deal with (including all the built-in errors likeValueError,TypeError,KeyError) actually descends fromException, which is itself just one subclass ofBaseException— but a small, deliberate set of exceptions sit outsideException, as direct siblings of it underBaseException:SystemExit(raised whensys.exit()is called, representing an intentional program shutdown),KeyboardInterrupt(raised when the user presses Ctrl-C), andGeneratorExit(delivered into a generator when it’s being closed). This placement is deliberate:except Exception:— an extremely common, broad-catch pattern people reach for — will not accidentally intercept any of these, meaning a Ctrl-C still actually stops the program, andsys.exit()still actually exits, even inside code with a broad exception handler somewhere in the call stack. If you instead use a truly bareexcept:(catching literally everything, includingBaseExceptionitself), you lose this protection entirely — the program can become nearly impossible to interrupt or shut down cleanly, since even the shutdown/interrupt signals themselves get silently swallowed.02Bare `raise` vs `raise SomeException()` vs `raise ... from ...` vs `from None`
#c-bare-raise-vs-raise-someexception-vs-raise-from-vs-from-noneA bare
raise(with no exception specified), used inside anexceptblock, re-raises the exception currently being handled, preserving its original traceback exactly as it was. Constructing and raising a genuinely new exception (raise SomeException()) instead starts a fresh traceback from that point — unless you explicitly chain it to the original usingraise NewError("...") from original_err, which preserves the link between them (surfacing as__cause__) even though it’s technically a different exception object.from Noneis a specific, deliberate variant that suppresses the chain display entirely, useful when translating a low-level, internal exception into a domain-specific one at a public API boundary — the original internal traceback would just be confusing noise to an external caller who doesn’t need or want to see your implementation details, sofrom Nonepresents a clean, focused error instead.03Why `except (TypeError, ValueError) as e:` needs the parens — the Python 2 holdover trap
#c-why-except-typeerror-valueerror-as-e-needs-the-parens-the-pyThe parentheses are genuinely required in Python 3 to group multiple exception types together into a single tuple that
exceptchecks against; without them,except TypeError, ValueError:was actually valid, meaningful Python 2 syntax with an entirely different, and now-removed, meaning — binding the caught exception instance itself to the nameValueError(an old, now-removed alternate way of naming the caught exception, distinct from Python 3’saskeyword) — a real trap specifically for anyone porting code, or muscle-memory habits, over from Python 2.04`__cause__` vs `__context__` — explicit vs implicit exception chaining
#c-__cause__-vs-__context__-explicit-vs-implicit-exception-chai__context__is set automatically, with no special syntax needed, any time a new exception happens to be raised while Python is already in the middle of handling a previous one (e.g., yourexceptblock’s own cleanup code itself raises) — this is implicit chaining, and produces the “During handling of the above exception, another exception occurred” message.__cause__is set only via the explicitraise ... from ...syntax — explicit chaining, deliberately declared by you — and produces the “The above exception was the direct cause of the following exception” message instead. Both attributes can genuinely coexist on the same exception object simultaneously even though only one message typically prints in the default traceback formatting;from Nonespecifically suppresses both from being displayed, even though the underlying__context__may technically still be set internally.try: try: {}["missing"] except KeyError as e: raise ValueError("bad config") from e except ValueError as err: print(repr(err.__cause__)) # -> KeyError('missing') explicit print(repr(err.__context__)) # -> KeyError('missing') also set implicitly print(err.__suppress_context__) # -> True so only the "direct cause" message printsWhich message the default traceback prints is decided by these attributes: if
__cause__is set, the “direct cause” message is shown (andraise ... fromalso sets__suppress_context__ = True, hiding the implicit context); otherwise, if__context__is set and not suppressed, the “During handling” message is shown.raise ... from Nonesets__cause__ = Noneand__suppress_context__ = True, so neither prints.05Designing a custom exception hierarchy — driven by how callers will react, not taxonomy
#c-designing-a-custom-exception-hierarchy-driven-by-how-callersGood design here balances two competing needs: giving callers a single, common base exception for the whole library (so
except PaymentError:reliably catches anything the library might raise, without needing to enumerate every specific subtype), while also giving meaningful subclassing so callers who do care about a specific failure mode can catch it narrowly. The specific hierarchy shape should be driven by how callers will actually want to react — e.g., splitting into something likeTransientPaymentError(network blips, temporary service unavailability — safe and sensible to retry) versusPermanentPaymentError(insufficient funds, invalid card — retrying is pointless or actively wrong) is a genuinely useful split because it directly drives real retry-logic decisions, rather than subclassing purely for the sake of taxonomic completeness. A common mistake worth flagging: over-engineering a deep, elaborate 15-level exception hierarchy that no caller actually branches on in practice — restraint matters as much as thoughtful structure.06An exception's `.args` vs `str(exception)` — and why custom exceptions need `__str__` for logging
#c-an-exceptions-args-vs-strexception-and-why-custom-exceptions.argsholds the raw positional arguments the exception was actually constructed with, as a genuine tuple;str(exception)calls the exception’s__str__(inherited by default fromException, which typically just joins.argsinto a readable string) — but if a custom exception subclass overrides__init__to store genuinely richer structured state beyond a single simple message, the default__str__may no longer produce a genuinely useful, informative message, meaning you’d want to override__str__explicitly yourself, specifically so that logging tools that callstr(exc)actually produce something genuinely useful rather than a cryptic, unhelpful default.07EAFP vs LBYL — and when LBYL is the better choice despite Python's convention
#c-eafp-vs-lbyl-and-when-lbyl-is-the-better-choice-despite-pythEAFP (“easier to ask forgiveness than permission”) means just attempting an operation and handling the exception if it fails (
try: value = d[key] \n except KeyError: ...); LBYL (“look before you leap”) means checking preconditions first before attempting the operation (if key in d: value = d[key]). Python culture leans EAFP for good reasons (it avoids a separate, redundant check that can itself race against concurrent modification, and often reads more naturally). LBYL is genuinely better specifically when the “ask forgiveness” operation would have a real, unwanted side effect if attempted speculatively and failed (e.g., you don’t want to speculatively kick off an expensive or irreversible operation just to catch its failure), or in a genuinely hot, performance-sensitive loop where raising and catching exceptions has real, measurable cost in CPython — worth noting that Python 3.11 made the non-raising path of exception handling essentially free (zero-cost when no exception actually occurs), which shifted this specific performance tradeoff somewhat, though the “unwanted side effect” reason for preferring LBYL remains independent of that.08The `else` clause on a `try` block — why it's distinct from code after the `try`/`except`
#c-the-else-clause-on-a-try-block-why-its-distinct-from-code-afThe
elseclause on atrystatement runs its code only if thetryblock completed with no exception at all — the key, easy-to-miss distinction from simply placing that same code after the wholetry/except: any exception raised inside theelseblock itself is not caught by theexceptclauses that precede it (since those clauses are only listening for exceptions from thetrybody, not fromelse), whereas code accidentally placed inside thetrybody instead ofelsewould be vulnerable to being unintentionally caught by an overly broadexceptclause meant only for the original risky operation. A lot of experienced Python developers have simply never usedelseon atrystatement and don’t know precisely why it exists as a distinct clause from “just put it after.”09`contextlib.suppress(SomeException)` vs `try/except: pass` — the safety difference
#c-contextlibsuppresssomeexception-vs-tryexcept-pass-the-safetyIn the most common, narrow use case, they’re largely equivalent in effect — both discard the specified exception silently. But there’s a real, meaningful safety difference:
contextlib.suppress(SomeException)only swallows exactly the exception type(s) you named, letting anything else propagate normally, whereas a genuinely bareexcept:(with no type specified at all) swallows everything, includingBaseExceptionsubclasses likeKeyboardInterrupt/SystemExit— a real, if perhaps unintended, safety gap.suppressis the safer default even when your actual intent was already narrow, precisely because it makes that narrowness an explicit, enforced constraint rather than an accidental byproduct of writingexcept SpecificError: passcorrectly (which achieves the same narrow safety, butsuppresscommunicates the intent to suppress more clearly and concisely as a one-liner).10An exception raised in a `finally` block displaces the one already propagating
#c-an-exception-raised-in-a-finally-block-displaces-the-one-alrIf a
finallyblock itself raises a new exception while an earlier exception was already actively propagating out of thetry/except, the new exception fromfinallytakes over and becomes the one that actually propagates further up the call stack — the original exception becomes that new exception’s__context__(the same implicit-chaining mechanism described above) but is otherwise effectively displaced from the primary propagation path, easy to lose track of unless someone specifically inspects the exception chain. This is a genuine, real production footgun: cleanup code insidefinally(closing a connection, releasing a lock) can itself fail — and if it does, the original error (often the actual root cause someone actually needed to see and fix) gets silently overshadowed by a secondary cleanup failure, which is rarely tested for precisely because writing a test that deliberately makes cleanup logic also fail, on top of the original failure, isn’t something most people think to exercise.11Designing a retry mechanism — transient vs non-transient failures, backoff, and idempotency
#c-designing-a-retry-mechanism-transient-vs-non-transient-failuA well-designed retry mechanism distinguishes transient failures (network timeouts, temporary 503-style unavailability — genuinely worth retrying, since the same call might well succeed moments later) from non-transient ones (validation errors, 4xx-style client errors — retrying identically won’t help, since the request itself is fundamentally invalid, not just unlucky). It uses exponential backoff with jitter — waiting progressively longer between each retry attempt (backoff), with some randomization added (jitter) specifically to avoid many clients all retrying in perfect lockstep and slamming the recovering service simultaneously (a “thundering herd”) — and enforces a sensible maximum attempt count rather than retrying forever. The other essential piece of this is idempotency: an operation is idempotent if performing it multiple times has the same effect as performing it once (e.g., “set this field to X” is idempotent; “charge this card $10” performed twice is very much not). Blindly retrying a non-idempotent operation (like a payment charge) risks duplicating a real-world side effect — the actual production remedy involves idempotency keys or similar mechanisms, not just “add a retry loop.”
12Translating internal exceptions to HTTP responses at a service boundary
#c-translating-internal-exceptions-to-http-responses-at-a-serviThe core principle: internal exception details (raw messages, stack traces, internal class names) should generally never leak directly to an external caller — beyond being poor UX, it’s a real security concern, since internal error text can inadvertently reveal implementation details, file paths, or other information useful to an attacker probing the system. The right design has a deliberate translation layer at the API boundary — mapping specific, known domain exceptions to specific, well-defined API error codes/messages (e.g., a domain
InsufficientFundsErrormaps to a clean402 Payment Requiredwith a client-safe message) — while anything unexpected (a bug, an unhandled edge case) falls through to a generic500 Internal Server Errorresponse with a minimal, non-revealing message to the client, while the full internal exception details (message, traceback, context) are still captured and logged internally for on-call engineers to actually diagnose. Letting arbitrary internal exceptions bubble directly to a serializer without this deliberate translation step is a common, real production anti-pattern.13`logging.exception(...)` vs `logging.error(str(e))` — why the traceback matters in production
#c-loggingexception-vs-loggingerrorstre-why-the-traceback-mattelogging.exception(...)(or equivalently,logging.error(..., exc_info=True)) captures and logs the full traceback — the entire call stack showing exactly where the exception originated and propagated through — alongside the message, whereaslogging.error(str(e))only logs whatever textstr(e)happens to produce for that specific exception, which can be extremely thin: for something like a bareKeyError,str(e)might just print the missing key itself, with no indication whatsoever of which line of code, in which function, actually raised it. This is an extremely common, genuinely costly real-world mistake — a production incident where the only logged information is a cryptic"'user_id'"(aKeyError’s minimal string representation) with no traceback at all can turn a two-minute diagnosis into a much longer one, purely because of how the exception was logged, not because of anything about the bug itself.14Swallowing exceptions to return `None`/a sentinel — the tradeoffs and `Result`/`Either` patterns
#c-swallowing-exceptions-to-return-nonea-sentinel-the-tradeoffsExceptions-as-control-flow for signaling failure is deeply idiomatic in Python (the same EAFP culture described above) — swallowing a failure and returning
None(or some other sentinel value) instead discards the actual reason something failed, making the caller’s job harder (they know something didn’t work, but not why), and creates a genuine ambiguity problem whenNoneis already a perfectly valid, meaningful return value in the successful case (now the caller can’t distinguish “legitimately gotNone” from “something silently failed”).Result/Either-style patterns (a return type that’s explicitly either “success, here’s the value” or “failure, here’s the error,” commonly seen in Rust, Haskell, and similar languages) are a real, legitimate alternative some Python codebases deliberately adopt — but it’s worth recognizing this as importing a different language culture’s convention into Python rather than something the language itself is built around — both approaches carry genuine tradeoffs, and neither is straightforwardly better in every case.15Concrete failure modes of `except Exception: pass`
#c-concrete-failure-modes-of-except-exception-passBeyond the general vagueness of “swallowing errors is bad,” there are concrete, specific failure modes: silent data corruption — if an exception occurs partway through a multi-step operation (e.g., updating several related records) and it’s simply swallowed, the operation can be left in an inconsistent, half-completed state with no error surfaced anywhere to alert anyone; masked resource leaks — if an exception prevented a cleanup step from running, and the exception is just discarded, you lose the only signal that would have told you a file handle, connection, or lock never got released; and a genuinely subtle scoping detail worth knowing precisely:
except Exceptiondoes not actually catch everything —KeyboardInterruptandSystemExitspecifically inherit fromBaseException, notException, specifically so that broad-but-common patterns like this one don’t accidentally swallow “the user pressed Ctrl-C” or “the program is trying to shut down cleanly” signals. A genuinely broadexcept:(bare, catching literally everything includingBaseExceptionsubclasses) is even more dangerous for exactly that reason. The fix in all cases isn’t “never catch broadly,” it’s catching the narrowest exception type you actually expect and know how to handle, and letting everything else propagate.16Where an exception surfaces in a consumed generator, and exceptions thrown in via `.throw()`
#c-where-an-exception-surfaces-in-a-consumed-generator-and-exceAn exception raised naturally inside a generator’s own body (during the code executing between two
yieldstatements) propagates out to whatever’s driving the generator — theforloop, or whoever callednext()— at exactly the point where thatnext()/iteration step was requested; from the consumer’s perspective, it looks just like the generator itself raised at that call. Separately, you can also actively throw an exception into an already-paused generator using.throw(), which delivers it at the exact point the generator is currently suspended (at its most recentyield) — this is precisely the same underlying mechanism used to deliverCancelledErrorinto a suspended coroutine, and to deliver exceptions into acontextlib.contextmanager-based context manager’s generator body — understanding this one mechanism explains several seemingly-separate behaviors across the language.17Why `assert` isn't production error handling — and how `-O` strips every assert
#c-why-assert-isnt-production-error-handling-and-how--o-strips-No —
assertstatements are specifically meant for debugging assumptions and test invariants, genuinely not for handling real, expected error conditions in production code. Running Python with the-O(optimize) flag actually strips out every singleassertstatement entirely from the compiled bytecode — meaning any code that has come to silently rely on anassertfor genuine, real runtime validation or safety-checking will simply have that entire check vanish completely under-O, with no warning whatsoever, a real and genuinely dangerous production footgun for any team that isn’t fully aware of this specific flag’s actual behavior.