Exceptions
What exceptions are
Section titled “What exceptions are”An exception is a signal that something went wrong during execution.
When Python raises an exception, it unwinds the call stack until it finds a matching except clause or terminates the program.
Exceptions are objects — instances of classes that inherit from BaseException. The ones you will work with in normal code almost always inherit from Exception.
The exception hierarchy
Section titled “The exception hierarchy”Every exception is a class, and every exception class inherits — directly or indirectly — from
BaseException. Exception is one branch of that tree; a handful of process-control signals live in
sibling branches that Exception does not cover:
flowchart TD BE["BaseException"] --> KI["KeyboardInterrupt"] BE --> SE["SystemExit"] BE --> GE["GeneratorExit"] BE --> EXC["Exception"] EXC --> VE["ValueError"] EXC --> TE["TypeError"] EXC --> LE["LookupError"] LE --> KE["KeyError"] LE --> IE["IndexError"] EXC --> OSE["OSError"] EXC --> RTE["RuntimeError"] EXC --> EG["ExceptionGroup"]
This is why except Exception, bare except:, and except BaseException are three different things:
except SpecificError:— catches only that class and its subclasses. Always prefer this.except Exception:— catches everything that represents an application-level error, but notKeyboardInterrupt,SystemExit, orGeneratorExit— those are process-control signals, not bugs.- bare
except:— identical toexcept BaseException:. It catches literally everything, including Ctrl+C and a cleansys.exit(). This is almost never what you want: it makes a program impossible to interrupt and silently absorbs shutdown signals meant to propagate.
except: pass — swallowing bugs silently
Section titled “except: pass — swallowing bugs silently”Combining a broad except with an empty body is the single most common way AI-generated code hides a real
bug behind a plausible-looking fallback value:
def risky_lookup(data: dict, key: str) -> int: try: return data[key] except: # catches EVERYTHING, not just KeyError pass return -1A missing key returns -1 — probably what was intended. But so does calling risky_lookup(None, "x"),
which raises TypeError: 'NoneType' object is not subscriptable — a real programming error, not a missing
key — and it is swallowed exactly the same way, returning -1 as if nothing happened. The fix is always to
name the specific exception you expect (except KeyError:) so that a different exception still surfaces.
The full try statement
Section titled “The full try statement”Python’s try statement has four optional clauses:
# illustrative — names below are placeholderstry: result = risky_operation()except SomeError as e: # runs only if SomeError (or a subclass) was raised handle(e)else: # runs only if NO exception was raised in try use(result)finally: # ALWAYS runs — exception or not cleanup()The else clause is a useful Python feature: it lets you distinguish “the operation succeeded” from “we are in cleanup.” Code in else only runs on the happy path.
Never return out of finally
Section titled “Never return out of finally”A return, break, or continue inside finally throws away whatever exception was in flight — the
function returns normally and the error disappears. Python 3.14 (PEP 765) warns about it:
def risky(): try: raise ValueError("lost") finally: return "swallowed" # SyntaxWarning in 3.14 — the ValueError never escapesKeep finally to cleanup only, and let exceptions propagate.
Catching specific exception types
Section titled “Catching specific exception types”Always catch the most specific exception type you can. Catching Exception is a last resort; catching bare except: silently swallows KeyboardInterrupt and SystemExit as well.
try: n = int(user_input)except ValueError: print("Not a valid integer")except TypeError: print("Expected a string, not None")To catch multiple types in one clause, use a tuple:
except (ValueError, TypeError) as e: print(f"Input error: {e}")Since Python 3.14 (PEP 758) the parentheses are optional when the clause has no as:
except ValueError, TypeError: print("Input error")With as they are still required — except (ValueError, TypeError) as e: — because otherwise the parser cannot tell the tuple from the binding.
EAFP vs LBYL, and the TOCTOU race
Section titled “EAFP vs LBYL, and the TOCTOU race”Python culture favors EAFP (“easier to ask forgiveness than permission” — try the operation, catch the failure) over LBYL (“look before you leap” — check a precondition, then act):
def lbyl(d: dict[str, int], key: str) -> int: if key in d: # check return d[key] # use -- something could remove `key` between these two lines return 0
def eafp(d: dict[str, int], key: str) -> int: try: return d[key] # single operation -- no gap for anything else to run in between except KeyError: return 0Both give the same answer for a plain, single-threaded dict. The difference matters once another thread,
signal handler, or await point can run between the check and the use — LBYL has a TOCTOU window
(“time-of-check to time-of-use”) where the condition you checked can become false before you act on it. A
classic real example: if os.path.exists(path): os.remove(path) can still raise FileNotFoundError if
another process deletes the file in between. EAFP’s single operation has no such window.
Raising exceptions
Section titled “Raising exceptions”Use raise to signal an error explicitly:
def divide(a: float, b: float) -> float: if b == 0: raise ValueError("divisor cannot be zero") return a / braise vs raise e — the extra frame
Section titled “raise vs raise e — the extra frame”Re-raising the same exception inside an except block looks interchangeable between a bare raise and
raise e, but they are not:
def a(): b()
def b(): raise ValueError("deep failure")
def reraise_bare(): try: a() except ValueError: raise # preserves the original traceback -- adds no frame
def reraise_e(err: ValueError): raise err # adds a frame pointing at THIS lineCalling reraise_bare() produces a traceback ending at b(), unchanged. Calling a(), catching, then
calling reraise_e(caught) adds one more frame to the traceback — the line inside reraise_e — even though
it is “the same” exception object. Verified by counting traceback.extract_tb frames in the runnable demo
below: bare raise keeps the original frame count; raise e adds one. Prefer bare raise when re-raising
unchanged; it keeps the traceback pointing at the real origin.
Exception chaining with raise ... from
Section titled “Exception chaining with raise ... from”When you catch one exception and raise another, use raise NewError(...) from original to preserve the original as the cause. This keeps the full traceback in the error chain.
try: value = int(raw_string)except ValueError as original: raise RuntimeError("Failed to parse config") from originalThe __cause__ attribute on the new exception holds the original. Python displays both tracebacks automatically.
To explicitly discard the cause (suppress the chained traceback), use raise ... from None. Without either
from original or from None, raising a new exception inside an except block still records the old one
in __context__ (implicit chaining, covered in the Custom Errors lesson) — from None is the only way to
suppress that entirely.
assert is not validation — it is stripped by -O
Section titled “assert is not validation — it is stripped by -O”assert exists for internal self-checks and tests, not for validating data your program does not control.
Running Python with -O (optimized mode) removes every assert statement from the compiled bytecode
entirely — the condition is never even evaluated:
def validate(value: int) -> int: assert value > 0, "value must be positive" return value
print(validate(-1))$ python3.14 validate.pyTraceback (most recent call last): ...AssertionError: value must be positive
$ python3.14 -O validate.py-1The exact same source produces a crash or a silently-accepted negative number, depending on a flag chosen
outside the file entirely. Use if/raise (typically ValueError) for anything that must always be
checked, and reserve assert for conditions that indicate a bug in your own code, not bad external input.
except* and exception groups (Python 3.11+)
Section titled “except* and exception groups (Python 3.11+)”Some operations — concurrent tasks, batch validation — can fail with more than one unrelated exception at
once. ExceptionGroup bundles multiple exceptions into one object; except* unpacks a group by matching
each contained exception against a type, potentially running more than one handler for a single raise:
def run_tasks() -> None: errors: list[Exception] = [ValueError("bad input"), TypeError("bad type")] raise ExceptionGroup("task failures", errors)
try: run_tasks()except* ValueError as eg: print(f"ValueError group: {[str(e) for e in eg.exceptions]}")except* TypeError as eg: print(f"TypeError group: {[str(e) for e in eg.exceptions]}")Both except* ValueError and except* TypeError run here, each receiving its own sub-group. Plain except ValueError cannot catch an ExceptionGroup at all — it does not match unless ExceptionGroup itself (or a
superclass of it) is named. PEP 758 (3.14) is the syntax change that let except and except* clauses drop
the parentheses around multiple types shown earlier in this lesson; it does not change how exception groups
themselves work.
contextlib.suppress — a targeted alternative to try/except/pass
Section titled “contextlib.suppress — a targeted alternative to try/except/pass”For the specific, legitimate case of “ignore this one exception type on purpose,” contextlib.suppress is
clearer than a try/except/pass block because it names exactly what it suppresses, in one line:
import contextlib
with contextlib.suppress(FileNotFoundError): open("does-not-exist.txt")# execution continues here either wayUnlike bare except: pass, suppress takes an explicit list of exception types — it cannot accidentally
swallow KeyboardInterrupt or a typo-triggered TypeError the way an unqualified except: can.
logging.exception vs print(e)
Section titled “logging.exception vs print(e)”print(e) (or print(f"{e}")) shows only the exception’s message — the traceback, the chain of causes, and
the exact line are all gone. logging.exception(...), called from inside an except block, records the
full traceback automatically:
import logging
try: 1 / 0except ZeroDivisionError as e: print(f"print(e) shows only: {e}") # "division by zero" -- no location, no traceback logging.exception("division failed") # full traceback, at ERROR level, plus your messagelogging.exception is shorthand for logging.error(..., exc_info=True) — it must be called while handling
the exception (inside the except block) for exc_info=True to have anything to attach.
sys.exit and SystemExit
Section titled “sys.exit and SystemExit”sys.exit(code) does not forcibly terminate the process — it raises SystemExit, which propagates like any
other exception (running finally blocks along the way) unless something catches it:
import sys
def cli_entry(code: int) -> None: sys.exit(code)
try: cli_entry(2)except SystemExit as e: print(f"SystemExit caught, code={e.code}")Because SystemExit inherits from BaseException, not Exception, except Exception: never intercepts a
clean sys.exit() call — only bare except: or an explicit except SystemExit:/except BaseException:
would. This is deliberate: a library-level except Exception handler should not be able to prevent your
program from exiting.
Reading a traceback bottom-up
Section titled “Reading a traceback bottom-up”A Python traceback lists frames top-to-bottom in the order they were entered, but the useful reading order is bottom-up: the last line names the exception type and message; the frame just above it is where the exception was actually raised; each frame above that is one level further out in the call chain that led there.
def outer(): middle()
def middle(): inner()
def inner(): raise ValueError("innermost failure")
outer()Traceback (most recent call last): File "demo.py", line 9, in <module> outer() File "demo.py", line 2, in outer middle() File "demo.py", line 5, in middle inner() File "demo.py", line 8, in inner raise ValueError("innermost failure")ValueError: innermost failureStart at ValueError: innermost failure (what broke), then look at the frame directly above it (inner,
where it broke), then work upward through middle and outer only if you need to know how execution got
there. Reading top-down tempts you to start debugging in outer, which never actually failed.
Full runnable demo
Section titled “Full runnable demo”def safe_divide(a: float, b: float) -> float: """Divide a by b, raising ValueError if b is zero.""" if b == 0: raise ValueError("divisor cannot be zero") return a / b
# --- try / except / else / finally ---print("=== basic try/except/else/finally ===")for b in [2.0, 0]: try: result = safe_divide(10.0, b) except ValueError as e: print(f" ValueError: {e}") else: print(f" Result: {result}") finally: print(" finally ran")
# --- catching specific types ---print("\n=== specific exception types ===")vals: list[str | None] = ["42", "abc", None]for val in vals: try: if val is None: raise TypeError("expected str, got None") n = int(val) print(f" parsed {val!r} -> {n}") except (TypeError, ValueError) as e: print(f" {type(e).__name__} for {val!r}: {e}")
# --- exception chaining ---print("\n=== exception chaining ===")try: try: raw = "not-a-number" value = int(raw) except ValueError as original: raise RuntimeError("Failed to parse config value") from originalexcept RuntimeError as e: print(f" RuntimeError: {e}") print(f" __cause__: {e.__cause__}")Loading Python runtime (first run only)…
Depth demo: everything above, running
Section titled “Depth demo: everything above, running”import contextlibimport loggingimport sysimport traceback
# --- hierarchy: except Exception vs bare except vs BaseException ---print("=== hierarchy ===")for kind in (ValueError("bad value"), KeyboardInterrupt(), SystemExit(1)): try: raise kind except Exception as e: print(f" except Exception caught: {type(e).__name__}") except BaseException as e: print(f" except Exception missed it; only BaseException caught: {type(e).__name__}")
# --- except: pass swallows real bugs silently ---print("=== except: pass ===")def risky_lookup(data: dict, key: str) -> int: try: return data[key] except: # deliberately bad: swallows EVERYTHING, including a bad argument type below pass return -1
print(f" risky_lookup({{}}, 'x') = {risky_lookup({}, 'x')}") # KeyError silently becomes -1result = risky_lookup(None, "x") # TypeError: 'NoneType' is not subscriptable -- ALSO silently swallowedprint(f" risky_lookup(None, 'x') = {result} <- a real bug (wrong argument type) hidden as -1")
# --- raise vs raise e vs raise ... from ---print("=== raise vs raise e ===")def a(): b()
def b(): raise ValueError("deep failure")
def reraise_bare(): try: a() except ValueError: raise # preserves the original traceback -- no new frame for this line
def reraise_e(err: ValueError): raise err # adds THIS line as a new traceback frame
try: reraise_bare()except ValueError: frames_bare = len(traceback.extract_tb(sys.exc_info()[2])) print(f" bare raise traceback frames: {frames_bare}")
try: try: a() except ValueError as caught: reraise_e(caught)except ValueError: frames_e = len(traceback.extract_tb(sys.exc_info()[2])) print(f" raise e traceback frames: {frames_e}")
# --- from / from None ---try: try: int("not-a-number") except ValueError as original: raise RuntimeError("config parse failed") from originalexcept RuntimeError as e: print(f" __cause__ set by 'from': {e.__cause__!r}")
try: try: int("not-a-number") except ValueError: raise RuntimeError("config parse failed") from Noneexcept RuntimeError as e: print(f" __cause__ suppressed by 'from None': {e.__cause__!r}")
# --- finally overriding return ---print("=== finally overriding return ===")def leaky() -> str: try: raise ValueError("lost forever") finally: return "swallowed" # SyntaxWarning in 3.14 (PEP 765) -- the ValueError never escapes
print(f" leaky() = {leaky()!r} <- no exception visible at all")
# --- EAFP vs LBYL ---print("=== EAFP vs LBYL ===")config: dict[str, int] = {"retries": 3}
def lbyl(d: dict[str, int], key: str) -> int: if key in d: return d[key] return 0
def eafp(d: dict[str, int], key: str) -> int: try: return d[key] except KeyError: return 0
print(f" lbyl(config, 'retries') = {lbyl(config, 'retries')}")print(f" eafp(config, 'retries') = {eafp(config, 'retries')}")
# --- except* and exception groups (3.11+) ---print("=== except* / exception groups ===")def run_tasks() -> None: errors: list[Exception] = [ValueError("bad input"), TypeError("bad type")] raise ExceptionGroup("task failures", errors)
try: run_tasks()except* ValueError as eg: print(f" except* ValueError caught {len(eg.exceptions)}: {[str(e) for e in eg.exceptions]}")except* TypeError as eg: print(f" except* TypeError caught {len(eg.exceptions)}: {[str(e) for e in eg.exceptions]}")
# --- contextlib.suppress ---print("=== contextlib.suppress ===")with contextlib.suppress(FileNotFoundError): open("does-not-exist.txt")print(" suppress(FileNotFoundError) let execution continue past the failed open()")
# --- logging.exception vs print(e) ---print("=== logging.exception vs print(e) ===")logging.basicConfig(stream=sys.stdout, level=logging.INFO, format=" LOG: %(message)s")try: 1 / 0except ZeroDivisionError as e: print(f" print(e) shows only: {e}") logging.exception("division failed") # includes the full traceback, not just the message
# --- sys.exit / SystemExit ---print("=== sys.exit / SystemExit ===")def cli_entry(code: int) -> None: sys.exit(code)
try: cli_entry(2)except SystemExit as e: print(f" SystemExit caught, code={e.code} -- inherits BaseException, not Exception")
# --- assert is stripped by -O ---print("=== assert and -O ===")def validate(value: int) -> int: assert value > 0, "value must be positive" # removed entirely when Python runs with -O return value
print(f" validate(5) = {validate(5)}")try: validate(-1)except AssertionError as e: print(f" validate(-1) raised AssertionError: {e} <- gone under -O, never validated")
# --- reading a traceback bottom-up ---print("=== reading a traceback bottom-up ===")def outer(): middle()
def middle(): inner()
def inner(): raise ValueError("innermost failure")
try: outer()except ValueError: tb_text = traceback.format_exc() print(" " + tb_text.strip().splitlines()[-1]) print(" ^ read bottom-up: this line names the error; frames above show the call chain that led here")Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”An exception’s journey is a single straight line up the call stack: raise creates it at the innermost
frame; Python then unwinds one frame at a time, checking each enclosing try for a matching except, until
one matches or the stack runs out (uncaught → the interpreter prints the traceback and exits). finally
blocks along the way always run during this unwind, whether or not they are the ones that catch anything.
Reading a traceback is reading this unwind log — which is exactly why the bottom line (where it started) is
more useful than the top line (how the program got there) as your first read.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Specific except clauses | Only intended failures are handled; bugs still crash loudly | More clauses to write than one broad except |
except Exception | One handler for “any application error” | Still misses process-control signals on purpose; can mask a bug you meant to fix |
EAFP (try/except) | No TOCTOU window; often faster on the common path | Exception handling has overhead on the failure path |
except* / exception groups | One raise can carry unrelated concurrent failures | New syntax and mental model; plain except cannot catch a group |
assert for validation | Zero extra code to write | Vanishes completely under -O — never validates in production if that flag is used |
Common Misconceptions
Section titled “Common Misconceptions”- “
except Exceptioncatches everything.” It deliberately does not catchKeyboardInterrupt,SystemExit, orGeneratorExit— those inherit directly fromBaseException. Only bareexcept:orexcept BaseException:catches those too. - “
raiseandraise einside anexceptblock are the same thing.”raise eadds a new traceback frame at theraise eline itself; bareraisere-raises the original exception with its original traceback untouched. - “
assertis a lightweight way to validate user input.” It disappears entirely underpython -O— never use it for anything that must always be checked. - “
sys.exit()immediately kills the process.” It raisesSystemExit, an ordinary (if unusual) exception that runsfinallyblocks and can be caught, just like any other. - “A traceback should be read top to bottom, like the code.” The bottom line is the actual error; read upward from there to trace how execution reached it.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated exception handling, check for these:
- Is there a bare
except:(orexcept BaseException:) with no re-raise? It will also swallowKeyboardInterruptandSystemExit, and any typo that raises an unrelated exception. - Is
except: pass(orexcept Exception: pass) used to “handle” an error with no logging at all? That is a silently disappearing bug, not error handling. - Does the code use
assertto validate external input (user input, API responses, file contents) rather than an internal invariant? It vanishes under-O. - Does
raise eappear inside anexcept ... as e:block where a bareraisewas intended? Check whether the extra traceback frame actually matters here — usually it does not, and bareraiseis cleaner. - Does the code
print(e)instead oflogging.exception(...)inside anexceptblock in anything other than a quick script? The traceback is gone the moment it is needed for debugging.
🐛 Spot the bug
def process_order(quantity: int) -> int: assert quantity > 0, "quantity must be positive" return quantity * 10
print(process_order(-5))Show the answer
This looks like input validation, and it even raises AssertionError: quantity must be positive when run normally — so it is easy to trust. But assert is not validation: run the exact same file with python3.14 -O process_order.py and the assertion is stripped from the bytecode entirely, process_order(-5) silently returns -50, and a negative order quantity flows straight into the rest of the program. The fix is if quantity <= 0: raise ValueError("quantity must be positive"), which no interpreter flag can remove.