Exception Handling
try runs its block; if an exception is raised, Python stops executing the try block immediately and looks for a matching except clause. If no exception occurs, every except clause is skipped entirely.
🎯 What you'll learn
try/exceptBasics- The Exception Hierarchy
- Catching Multiple Types, and Binding the Exception
elseandfinally- Raising and Re-raising
1try/except Basics
try:
result = 10 / 0
except ZeroDivisionError:
print("can't divide by zero")try runs its block; if an exception is raised, Python stops executing the try block immediately and looks for a matching except clause. If no exception occurs, every except clause is skipped entirely.
2The Exception Hierarchy
Every exception is a class, and except matches by class, including subclasses:
try:
[1, 2][5]
except LookupError: # IndexError IS-A LookupError, so this catches it
print("bad lookup")BaseException
├── SystemExit, KeyboardInterrupt, GeneratorExit (NOT caught by `except Exception`)
└── Exception
├── ArithmeticError → ZeroDivisionError, OverflowError
├── LookupError → IndexError, KeyError
├── ValueError, TypeError, AttributeError, ...
└── ... (your custom exceptions typically subclass Exception)except Exception: deliberately does not catch SystemExit/KeyboardInterrupt/GeneratorExit — they subclass BaseException directly, specifically so that Ctrl+C and sys.exit() aren't accidentally swallowed by a broad except Exception handler.
3Catching Multiple Types, and Binding the Exception
try:
risky()
except (ValueError, TypeError) as e: # tuple = match any of these
print(f"bad input: {e}")
except Exception as e: # broader catch-all, checked only if the above didn't match
print(f"unexpected: {e}")except clauses are checked top to bottom, and only the first matching one runs — order matters, and a broad exception type listed before a narrower one will shadow it, so put specific exceptions first.
4else and finally
try:
value = int(user_input)
except ValueError:
print("not a number")
else:
print(f"parsed: {value}") # runs ONLY if the try block raised nothing
finally:
print("done") # ALWAYS runs — exception or not, return or notelse separates "code that might raise" from "code that should only run on success" — it's not required, but makes intent clearer than putting success-only code at the end of the try block (where it could accidentally catch its own exceptions). finally runs unconditionally, even if the try/except block returns, breaks, or continues — it is exactly the place for cleanup that must always happen (see also the Context Managers topic, which formalizes this pattern for resources).
5Raising and Re-raising
def validate(age):
if age < 0:
raise ValueError(f"age cannot be negative: {age}")
try:
validate(-5)
except ValueError:
print("logging the error...")
raise # bare `raise` re-raises the SAME exception, with its original traceback intactraise (no argument) inside an except block re-raises the exception currently being handled, preserving its original traceback — different from raise ValueError(...) again, which would start a brand-new traceback from this point.
6Exception Chaining: raise ... from
try:
int("not a number")
except ValueError as e:
raise RuntimeError("config parsing failed") from eTraceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'not a number'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
RuntimeError: config parsing failedraise ... from e explicitly links the new exception's __cause__ to e, producing the "direct cause" message above. Without from, raising a new exception inside an except block still chains it automatically via __context__ ("during handling of the above exception, another exception occurred") — from upgrades an implicit, incidental link into an explicit, intentional one; raise ... from None suppresses the chain display entirely when the original exception is truly irrelevant to report.
7Custom Exceptions
class InsufficientFundsError(Exception):
def __init__(self, requested, available):
super().__init__(f"requested {requested}, only {available} available")
self.requested = requested
self.available = available
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(amount, balance)
return balance - amount
try:
withdraw(50, 100)
except InsufficientFundsError as e:
print(e.requested, e.available) # structured data, not just a message stringSubclassing Exception (not BaseException) and calling super().__init__(message) gives you a normal, catchable exception whose str(e) is the message, while still letting you attach structured attributes (requested, available) that calling code can inspect programmatically instead of parsing an error string.
Under the HoodStack Unwinding, Tracebacks, and Where finally Fits In▾
An exception is an object that travels up the call stack, frame by frame
When raise executes, CPython doesn't search for a handler by scanning source code — it walks up the call stack, one frame at a time, checking each frame for an active try block whose except matches the exception's type. Each frame it passes through without a match is popped (a process called unwinding), and a reference to that frame's location (file, line, function name) is prepended to the exception's traceback — which is exactly why a traceback reads top-to-bottom as "outermost call first, deepest failure last": it's built by accumulating frames during the unwind, most-recently-added frame last.
def c():
raise ValueError("boom") # traceback frame 3 (deepest)
def b():
c() # traceback frame 2
def a():
b() # traceback frame 1
a()If no frame all the way up to the module level has a matching except, the exception reaches the interpreter's top level, which prints the accumulated traceback and terminates the process (or, in a REPL, just prints it and continues).
finally runs during the unwind, not after it
A finally block is guaranteed to execute even while an exception is actively propagating past its try — mechanically, CPython temporarily "parks" the in-flight exception, runs the finally block's bytecode, and then either re-raises the parked exception (if finally didn't itself return/raise) or lets whatever finally did (a return, a new raise) override the original outcome entirely:
def f():
try:
raise ValueError("original")
finally:
return "overridden" # SWALLOWS the ValueError entirely — no warning, no trace
print(f()) # "overridden" — the ValueError never propagates furtherThis is a genuine footgun precisely because it's silent: a return/break/continue inside finally discards any exception currently unwinding through it, with no indication that anything was suppressed.
sys.exc_info() and why except ... as e scopes e to the block
Python maintains a notion of the "exception currently being handled" (accessible via sys.exc_info()), which is what makes bare raise and implicit __context__ chaining possible without passing the exception object around explicitly. except Exception as e: additionally does something specific at the end of the block: CPython automatically dels the name e when the except block exits (successfully or not), specifically to avoid a reference cycle — the exception object's traceback holds a reference to every frame it passed through, and if one of those frames held a local variable pointing back at the exception, that's a reference cycle that only the cyclic garbage collector (not refcounting) would eventually clean up. This is why e is unexpectedly NameError-undefined immediately after an except ... as e: block, even though it looks like an ordinary local variable.
⚠️ Common Pitfalls
- Bare
except:(catching absolutely everything, includingKeyboardInterrupt/SystemExit) — this can make a script impossible to interrupt with Ctrl+C and hides genuine bugs; always catch a specific exception type, or at narrowestexcept Exception:if you truly need a catch-all. return/break/continueinsidefinallysilently swallowing an in-flight exception — as shown in Under the Hood, this discards the original exception with zero indication; avoid control-flow statements insidefinallyblocks.- Using exceptions for expected, common control flow where a simple
ifwould do — exceptions in CPython are relatively cheap to raise/catch compared to some other languages, but atrywrapping a hot loop's every iteration for an expected, frequent condition is usually a sign the logic should be anifcheck instead. - Catching a broad exception type before a narrower, more specific one —
except Exception:listed beforeexcept ValueError:makes theValueErrorclause unreachable dead code; Python does not warn about this. - Referencing
eafter theexcept ... as e:block has exited — as covered in Under the Hood, the name is automatically deleted at block exit; assignmsg = str(e)inside the block if you need the information afterward. - Forgetting that
except (ValueError, TypeError):requires a tuple —except ValueError, TypeError:is Python 2 syntax and is aSyntaxErrorin Python 3; the parentheses are mandatory.
📋 Quick Reference
try:
risky()
except (ValueError, TypeError) as e: # catch multiple types; most specific first
handle(e)
else:
only_if_no_exception()
finally:
always_runs() # cleanup — but avoid return/break/continue here
raise # bare re-raise, inside except, preserves traceback
raise RuntimeError("x") from original # explicit chain -> __cause__
raise RuntimeError("x") from None # suppress chain display
class MyError(Exception): ... # custom exception — subclass Exception, not BaseException
BaseException
├── SystemExit / KeyboardInterrupt / GeneratorExit # NOT caught by `except Exception`
└── Exception # everything you should normally catch/raise🎤 Interview Q&A
Q1How does try/except/else/finally work?core▾
try runs code; except handles matching exceptions; else runs if no exception occurred (keep the try block minimal); finally always runs for cleanup.
- Catch multiple types?
except (ValueError, KeyError) as e:.
Q2Why avoid bare except:?core▾
It catches everything including KeyboardInterrupt and SystemExit, hiding bugs and making programs hard to stop. Catch specific exceptions, or Exception at top-level boundaries with logging.
- Logging with traceback?
logger.exception('msg')inside except.
Q3How do you create and chain custom exceptions?core▾
Subclass Exception (a base app exception plus specific ones). Use raise NewError('context') from err to keep the original cause in tracebacks.
- raise from None? Suppresses the context when it's noise.
Q4What are exception groups?senior▾
Python 3.11 added ExceptionGroup and except* to handle multiple concurrent exceptions, e.g. from asyncio.TaskGroup.
- When do you see them? Concurrent tasks failing together.
Q5EAFP vs LBYL?core▾
EAFP (try the operation, handle exceptions) is idiomatic Python and avoids race conditions; LBYL (check first) can be clearer when failure is common and checks are cheap.
- Race example? Checking a file exists then opening it.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.