Engineering Notes
Home/Languages/Python/Exception Handling
Languages · Python

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.

💻 Languages● Intermediate⏱ 7 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • try/except Basics
  • The Exception Hierarchy
  • Catching Multiple Types, and Binding the Exception
  • else and finally
  • Raising and Re-raising

1try/except Basics

python
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:

python
try:
    [1, 2][5]
except LookupError:       # IndexError IS-A LookupError, so this catches it
    print("bad lookup")
text
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

python
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

python
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 not

else 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

python
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 intact

raise (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

python
try:
    int("not a number")
except ValueError as e:
    raise RuntimeError("config parsing failed") from e
text
Traceback (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 failed

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

python
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 string

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

python
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).

raise ValueError in c() unwinds through b() and a(), prepending each frame's location, until module level's except ValueError matches
raise ValueError in c() unwinds through b() and a(), prepending each frame's location, until module level's except ValueError matches

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:

python
def f():
    try:
        raise ValueError("original")
    finally:
        return "overridden"   # SWALLOWS the ValueError entirely — no warning, no trace

print(f())  # "overridden" — the ValueError never propagates further

This 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

  1. Bare except: (catching absolutely everything, including KeyboardInterrupt/SystemExit) — this can make a script impossible to interrupt with Ctrl+C and hides genuine bugs; always catch a specific exception type, or at narrowest except Exception: if you truly need a catch-all.
  2. return/break/continue inside finally silently swallowing an in-flight exception — as shown in Under the Hood, this discards the original exception with zero indication; avoid control-flow statements inside finally blocks.
  3. Using exceptions for expected, common control flow where a simple if would do — exceptions in CPython are relatively cheap to raise/catch compared to some other languages, but a try wrapping a hot loop's every iteration for an expected, frequent condition is usually a sign the logic should be an if check instead.
  4. Catching a broad exception type before a narrower, more specific one — except Exception: listed before except ValueError: makes the ValueError clause unreachable dead code; Python does not warn about this.
  5. Referencing e after the except ... as e: block has exited — as covered in Under the Hood, the name is automatically deleted at block exit; assign msg = str(e) inside the block if you need the information afterward.
  6. Forgetting that except (ValueError, TypeError): requires a tuple — except ValueError, TypeError: is Python 2 syntax and is a SyntaxError in Python 3; the parentheses are mandatory.

📋 Quick Reference

python
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.