Skip to content

Exceptions

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.

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"]
A slice of the built-in exception hierarchy

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 not KeyboardInterrupt, SystemExit, or GeneratorExit — those are process-control signals, not bugs.
  • bare except: — identical to except BaseException:. It catches literally everything, including Ctrl+C and a clean sys.exit(). This is almost never what you want: it makes a program impossible to interrupt and silently absorbs shutdown signals meant to propagate.

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 -1

A 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.

Python’s try statement has four optional clauses:

# illustrative — names below are placeholders
try:
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.

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 escapes

Keep finally to cleanup only, and let exceptions propagate.

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.

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 0

Both 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.

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 / b

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 line

Calling 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.

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 original

The __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.py
Traceback (most recent call last):
...
AssertionError: value must be positive
$ python3.14 -O validate.py
-1

The 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 way

Unlike 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.

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 / 0
except 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 message

logging.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(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.

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 failure

Start 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.

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 original
except RuntimeError as e:
print(f" RuntimeError: {e}")
print(f" __cause__: {e.__cause__}")
import contextlib
import logging
import sys
import 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 -1
result = risky_lookup(None, "x") # TypeError: 'NoneType' is not subscriptable -- ALSO silently swallowed
print(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 original
except 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 None
except 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 / 0
except 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")

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.

What you getBenefitCost
Specific except clausesOnly intended failures are handled; bugs still crash loudlyMore clauses to write than one broad except
except ExceptionOne 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 pathException handling has overhead on the failure path
except* / exception groupsOne raise can carry unrelated concurrent failuresNew syntax and mental model; plain except cannot catch a group
assert for validationZero extra code to writeVanishes completely under -O — never validates in production if that flag is used
  • “except Exception catches everything.” It deliberately does not catch KeyboardInterrupt, SystemExit, or GeneratorExit — those inherit directly from BaseException. Only bare except: or except BaseException: catches those too.
  • “raise and raise e inside an except block are the same thing.” raise e adds a new traceback frame at the raise e line itself; bare raise re-raises the original exception with its original traceback untouched.
  • “assert is a lightweight way to validate user input.” It disappears entirely under python -O — never use it for anything that must always be checked.
  • “sys.exit() immediately kills the process.” It raises SystemExit, an ordinary (if unusual) exception that runs finally blocks 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.

Before trusting generated exception handling, check for these:

  • Is there a bare except: (or except BaseException:) with no re-raise? It will also swallow KeyboardInterrupt and SystemExit, and any typo that raises an unrelated exception.
  • Is except: pass (or except 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 assert to validate external input (user input, API responses, file contents) rather than an internal invariant? It vanishes under -O.
  • Does raise e appear inside an except ... as e: block where a bare raise was intended? Check whether the extra traceback frame actually matters here — usually it does not, and bare raise is cleaner.
  • Does the code print(e) instead of logging.exception(...) inside an except block 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.

Which except clause is valid in Python 3.14 without parentheses?
When does the else block in a try statement run?
What does raise RuntimeError('msg') from original do?
Compared to calling reraise_bare(), what happens to the traceback frame count when catching from a() and calling reraise_e(caught) instead?
def a():
    b()
def b():
    raise ValueError("x")

def reraise_bare():
    try:
        a()
    except ValueError:
        raise

def reraise_e(err):
    raise err
What does this print, and why is it dangerous?
def risky_lookup(data, key):
    try:
        return data[key]
    except:
        pass
    return -1

print(risky_lookup(None, "x"))
A function validates its argument with assert value > 0, "...". What happens when the program is run with python -O?
Why does except Exception: fail to catch a sys.exit(1) call?