Exception Handling
Interview answer (say this first). An exception is how Python reports that something went wrong; it interrupts normal control flow and unwinds the call stack until a matching
excepthandles it. Good handling means catching only what you can actually deal with, adding context, and never hiding the failure.
Why this exists
Things fail: files are missing, networks drop, inputs are malformed, and databases reject writes. The question is not whether errors happen, but what the program does when they do.
The naive approach is to check everything in advance:
def read_config(path):
if not os.path.exists(path):
return None
if not os.access(path, os.R_OK):
return None
...
This is fragile. The file can be deleted between the check and the open. And the caller now has to inspect a return value: was None an error, or a legitimately empty config? Nothing distinguishes “failed” from “no data”.
The opposite extreme is worse:
try:
do_everything()
except:
pass
This catches everything, including a KeyboardInterrupt from the user pressing Ctrl-C, and then throws the information away. The program continues in an unknown state, and the real problem is invisible forever.
Exceptions exist to make failures explicit, typed, and impossible to ignore by accident. A raised exception interrupts the flow, carries a type and a message, and refuses to let normal code pretend everything is fine.
Start from zero
| Word | Plain meaning |
|---|---|
| Exception | An object representing a failure. It is raised and travels up the call stack. |
| Raise | To signal an exception with raise, stopping normal execution. |
| Unwind | The process of leaving stack frames while looking for a handler. |
| Handle / catch | To except an exception and decide what to do. |
| Propagate | To let an exception continue travelling upward because you did not handle it. |
| Traceback | The report showing the exception type, message, and the chain of calls that led to it. |
BaseException | The root of all exceptions. It includes system-level ones like KeyboardInterrupt and SystemExit. |
Exception | The base class for ordinary program errors. Almost everything you catch should be an Exception. |
| EAFP | “Easier to Ask Forgiveness than Permission”: try it, handle the failure. The Pythonic style. |
| LBYL | “Look Before You Leap”: check conditions first, then act. |
The BaseException vs Exception split is the one to internalise:
BaseException
├── KeyboardInterrupt (user pressed Ctrl-C)
├── SystemExit (the process is exiting)
└── Exception (everything you normally handle)
├── ValueError
├── KeyError
├── OSError
│ └── FileNotFoundError
└── ... your own exception classes
Catching Exception deliberately excludes KeyboardInterrupt and SystemExit. That is correct: a program should not swallow the user’s request to stop or the runtime’s request to exit.
EAFP vs LBYL is a style choice that Python leans toward EAFP, because it avoids race conditions and duplicated logic:
# LBYL: check first, then act — the state can change in between
if key in cache:
return cache[key]
# EAFP: act, handle the failure — no gap for a race
try:
return cache[key]
except KeyError:
return fetch(key)
The core idea
An exception is a labelled parcel travelling up a ladder. Each function on the stack gets one chance to open it. If it recognises the label, it handles the parcel and the climb stops. If not, it passes the parcel up to its caller. If nobody handles it, the program stops and prints the traceback.
The key insight is that the handler and the failure are often far apart, and that is a feature. A low-level function should not know how to show a user an error; it should just report what went wrong accurately. A high-level function decides what that means for the product.
Raise where you know what happened; catch where you know what to do about it.
flowchart TD
A["low-level: file missing"] -->|"raise FileNotFoundError"| B["service layer"]
B -->|"add context: which config?"| C["raise ConfigError from err"]
C --> D["API layer"]
D -->|"handle: return 500, log traceback"| E["request fails cleanly"]
Exception chaining is what makes this practical. raise NewError(...) from original keeps both the high-level meaning and the low-level cause, so the traceback shows the whole story.
How it works
raisecreates an exception object and immediately stops the current function. Nothing after theraiseruns.- Python unwinds the stack, looking for the nearest enclosing
trywhoseexceptclause matches the exception’s type. - Matching uses subclassing.
except OSErroralso catchesFileNotFoundError, because it is a subclass. Order matters: the first matching clause wins. - If no clause matches, the exception propagates to the caller, and so on. If nothing catches it, the program exits and prints the traceback.
elseruns if no exception occurred in thetryblock. It keeps the “success” code out of thetryso it is not accidentally protected.finallyalways runs, whether there was an exception, no exception, or even areturn.- Chaining preserves the cause. An exception raised inside another
exceptblock automatically gets that exception as its__context__.raise ... from errsets__cause__explicitly and is shown as “direct cause”. - The exception variable is deleted after the block. After
except ValueError as err:, the nameerris unbound, which prevents a reference cycle and forces you to capture what you need.
Tip:
The shape you should remember.
tryholds only the risky statement,excepthandles specific failures,elseholds the success path, andfinallyholds cleanup. Most bugs come from putting too much insidetry, so unrelated errors get misattributed to the handler.
The syntax you will use
The full form. Use else and finally only when they earn their place.
try:
value = int(text)
except ValueError:
value = 0
else:
log.info("parsed %s", value) # runs only on success
finally:
mark_attempted()
Catching specific exceptions, and several at once.
try:
fetch()
except (TimeoutError, ConnectionError) as err:
log.warning("network issue: %s", err)
raise
Re-raising. A bare raise inside except re-raises the same exception with its original traceback intact. This is how you log and still let the error travel.
try:
process()
except ValueError:
log.exception("processing failed")
raise # same exception, traceback preserved
Adding context with chaining.
try:
config = json.loads(raw)
except json.JSONDecodeError as err:
raise ConfigError(f"invalid config from {source}") from err
Suppressing an unhelpful chain with from None.
try:
value = int(raw)
except ValueError:
raise ValidationError("value must be a number") from None
Be precise here: from None sets __cause__ to None and marks the context as suppressed for display. It does not delete __context__; the original exception is still attached, just hidden from the traceback. Use it when the low-level detail is noise, not when you are hiding a real cause.
A custom exception hierarchy. This is how libraries let callers catch a whole family or one specific case.
class AppError(Exception):
"""Base class for all errors this application raises."""
class NotFound(AppError):
def __init__(self, resource: str, key: object):
super().__init__(f"{resource} {key!r} not found")
self.resource = resource
self.key = key
class PermissionDenied(AppError):
pass
Callers can now write except NotFound for the precise case, or except AppError to catch everything the application itself raises, while still letting programming errors like TypeError propagate.
assert is not validation. It is for internal invariants and is removed when Python runs with -O.
assert user.id is not None # an invariant, not user input validation
Never use assert to validate input or enforce security; it can be stripped from production builds.
EAFP for expected failures.
try:
value = cache[key]
except KeyError:
value = compute(key)
cache[key] = value
Examples: simple to real
Example 1 — catching only what you understand.
try:
response = client.get(url, timeout=5)
except TimeoutError:
metrics.increment("upstream_timeout")
raise
except ConnectionError:
metrics.increment("upstream_down")
raise
Each handler does something meaningful and re-raises. Unrelated errors, such as a bug in client.get, are not caught and will surface loudly instead of being disguised as a network problem.
Example 2 — translating low-level errors into domain errors.
def load_user(user_id: int) -> User:
try:
row = db.fetch_one("SELECT * FROM users WHERE id = %s", user_id)
except OperationalError as err:
raise ServiceUnavailable("user store unavailable") from err
if row is None:
raise NotFound("user", user_id)
return User(**row)
The API layer does not need to know about database drivers. It catches NotFound and returns 404, catches ServiceUnavailable and returns 503. The from err keeps the driver’s message in the logs.
Example 3 — else keeps the try block honest.
try:
record = json.loads(raw)
except json.JSONDecodeError:
return None
# This runs only on success and is NOT protected by the except clause.
save(record)
If save() raised a ValueError, putting it inside the try would misreport it as a JSON problem. else prevents that class of confusion.
Example 4 — retries with selective catching.
def fetch_with_retry(url, attempts=3):
for attempt in range(1, attempts + 1):
try:
return client.get(url, timeout=2)
except (TimeoutError, ConnectionError) as err:
if attempt == attempts:
raise
log.warning("attempt %d failed: %s", attempt, err)
Only transient network errors are retried. A ValueError from a bad URL fails immediately, because retrying it would never help and would waste time.
Example 5 — the silent-failure anti-pattern.
# BAD: the failure disappears and the caller sees a wrong answer
try:
balance = account.balance
except Exception:
balance = 0
If the account lookup fails, the user is told their balance is zero. Nothing is logged, no metric moves, and the bug is invisible until a customer complains. This single pattern causes a large share of production outages.
In production
- Never write a bare
except:. It catchesBaseException, includingKeyboardInterruptandSystemExit. CatchExceptionif you must be broad, and log. - Never swallow silently.
except Exception: passis how failures become invisible. At minimum, log withlog.exception()so the traceback is recorded. - Catch the narrowest type that is still correct.
except ValueErrordocuments exactly what you expect and lets everything else surface. - Keep
tryblocks small. Only the statement(s) that can raise should be inside, so you do not misattribute an unrelated error to the handler. Useelsefor the success path. - Re-raise with a bare
raise, notraise err.raise errloses some traceback context in some cases; a bareraisepreserves the original exactly. - Chain, do not replace.
raise DomainError(...) from errkeeps the cause visible. Replacing an error without chaining turns a debuggable failure into a mystery. - Define a domain exception hierarchy. It gives callers a stable contract, separates “expected business failure” from “programming bug”, and makes API error mapping mechanical.
- Log once, at the boundary. Log where you handle or translate, not at every level, or one failure produces dozens of identical log lines. Use
log.exception()inside anexceptto attach the traceback. - Do not use exceptions for routine control flow. They are for exceptional conditions. Using them for normal branching is slow and hides the logic.
dict.get,defaultdict, and guard clauses are clearer. - Exceptions are not free. Raising and unwinding costs far more than a normal return. In a hot loop, prefer checks; at a boundary, exceptions are the right tool.
- Translate at the boundary, not in the middle. Low-level code raises precise errors; the API layer maps them to status codes and user-facing messages. This keeps layers independent.
Interview questions
1. What is the difference between Exception and BaseException?
Answer. BaseException is the root of the hierarchy and includes KeyboardInterrupt, SystemExit, and GeneratorExit. Exception is the base class for ordinary program errors and excludes those system-level signals. Catch Exception; almost never catch BaseException.
Follow-up: “What breaks if you catch BaseException?” Ctrl-C stops working and the process cannot be asked to exit normally. Containers and orchestrators rely on those signals to stop work, so they will time out and kill the process instead.
Trap. Using a bare except: thinking it is the same as except Exception:. It catches system signals too.
2. What do else and finally do in a try statement?
Answer. else runs when the try block completes without an exception, and it is not covered by the except clauses. finally always runs, on success, on failure, and even on return. Use else to keep the success path out of the protected region, and finally for cleanup.
Follow-up: “Does finally run when the function returns?” Yes. The cleanup runs before the value is actually returned.
Trap. Putting the success path inside try. Then an error in that code is caught by your handler and mislabelled.
3. How does exception chaining work, and why does it matter?
Answer. When you raise inside an except block, Python automatically records the original exception as __context__. Using raise NewError(...) from err sets it explicitly as __cause__, shown as the direct cause. Chaining preserves both the high-level meaning and the low-level technical cause in one traceback.
Follow-up: “What does from None actually do?” It sets __cause__ to None and suppresses the display of the implicit context, but __context__ still holds the original exception. It hides noise, it does not erase the cause.
Trap. Claiming from None removes the original exception. It only hides it from the traceback.
4. Why is a bare except: pass dangerous?
Answer. It catches everything, including system signals, and discards all information about the failure. The program continues in an unknown state, no log or metric records the problem, and the symptom appears far from the cause. It converts a loud, debuggable failure into a silent, wrong result.
Follow-up: “What should you do instead?” Catch the specific exception you can handle, do something meaningful, and either recover or re-raise. If you truly must be broad, catch Exception, log with the traceback, and re-raise.
Trap. Defending it as “defensive programming”. It is the opposite; it removes the information you need to be defensive.
5. When should you define a custom exception hierarchy?
Answer. As soon as callers need to react differently to different failures. A base AppError lets a boundary handler catch everything the application raises while letting programming errors propagate, and specific subclasses such as NotFound or PermissionDenied map cleanly to API responses. It also gives each error a stable name.
Follow-up: “What should the base class inherit from?” Exception. Keep the hierarchy small and meaningful; one exception per genuine decision point, not per function.
Trap. Creating dozens of near-identical exception classes that callers cannot distinguish, or the opposite — raising bare Exception with a string message, which forces string matching.
6. What is EAFP, and when should you use it?
Answer. EAFP is “Easier to Ask Forgiveness than Permission”: attempt the operation and handle the failure. It is the Pythonic default because it avoids race conditions and duplicated checks — for example, try: return cache[key] except KeyError: instead of checking key in cache first, which can go stale between the check and the access.
Follow-up: “When is LBYL better?” When failure is expensive or the check is cheap and reliable, such as validating a request body before doing work. For local, predictable conditions, an explicit check reads more clearly.
Trap. Using EAFP to catch exceptions that are actually bugs. Catching KeyError for a key you control hides a programming mistake.
7. How do you handle errors across layers of an application?
Answer. Raise precise low-level exceptions where they occur, translate them into domain exceptions at the service boundary using raise ... from, and let the outermost layer (the API or worker) map domain exceptions to responses or retries. Each layer should know only its own vocabulary.
Follow-up: “Where do you log?” Once, at the layer that handles or translates the error, using log.exception() to capture the traceback. Logging at every layer produces duplicate noise.
Trap. Letting driver or framework exceptions reach the API layer, which couples your public contract to an implementation detail and can leak internal messages to users.
8. Are exceptions expensive, and should you avoid them?
Answer. Raising an exception is much more expensive than a normal return because Python must build the exception and unwind frames. It is negligible at a boundary doing I/O, and measurable inside a tight loop. Do not use exceptions for routine branching; use them for genuinely exceptional conditions.
Follow-up: “How would you avoid one in a hot path?” Use dict.get with a default, collections.defaultdict, hasattr/getattr with a default, or a guard clause — whichever states the intent most clearly.
Trap. Optimising exceptions away in ordinary code. Readability first; only avoid them where profiling shows the cost matters.
Remember this
- Raise where you know what happened; catch where you know what to do. Re-raise otherwise.
- Catch
Exception, never bareexcept:, and never swallow silently. - Keep
trysmall; useelsefor success andfinallyfor cleanup. - Chain errors with
raise ... from err;from Noneonly hides noise. - Domain exception hierarchies let the boundary map errors to responses and separate bugs from expected failures.