Filter concepts by levelShowing all levels.

Python · Exception Handling

Fundamentals

Concepts
9

The hierarchy every exception inherits from, the try/except/else/finally control flow that catches and recovers from one, raising your own, and linking a new exception to the one that caused it.

This section

The exception hierarchy

What every exception actually is — a class — and where the built-in ones sit relative to each other.

Exception hierarchy

corebeginner

Every exception is a class inheriting from BaseException. Exception is one direct subclass of it, and almost everything you catch — ValueError, KeyError, OSError — descends from Exception, not BaseException directly.

Think of it as

The hierarchy is a filing cabinet, not a flat pile of error names — BaseException is the cabinet itself, Exception is the drawer for "things your code should normally handle," and SystemExit/KeyboardInterrupt/GeneratorExit sit outside that drawer on purpose, so a catch-all for Exception never accidentally intercepts Ctrl+C or sys.exit().

python
BaseException
 ├── SystemExit, KeyboardInterrupt, GeneratorExit   # NOT under Exception
 └── Exception                                       # catch this, not BaseException
      ├── ArithmeticError → ZeroDivisionError, OverflowError
      ├── LookupError     → KeyError, IndexError
      └── OSError         → FileNotFoundError, PermissionError, ConnectionError

What we're doing: Confirm where three common exceptions sit in the hierarchy, and show that except Exception does not catch KeyboardInterrupt.

hierarchy.pypython
print(Exception.__bases__)
print(issubclass(ValueError, Exception))
print(issubclass(KeyboardInterrupt, Exception))

try:
    raise KeyboardInterrupt
except Exception:
    print("caught by except Exception")
except BaseException:
    print("caught by except BaseException")
1
Exception.__bases__ shows Exception inherits directly from BaseException — one level up.
3
KeyboardInterrupt is NOT a subclass of Exception, so except Exception below cannot match it.
7
The first except that matches wins — KeyboardInterrupt skips except Exception entirely and is caught by except BaseException instead.
Output
(<class 'BaseException'>,)
True
False
caught by except BaseException

Why this works: issubclass(KeyboardInterrupt, Exception) is False because KeyboardInterrupt inherits BaseException directly, bypassing the Exception drawer entirely — this is why except Exception: is safe to use as a broad catch-all without also swallowing Ctrl+C or sys.exit().

The exception tree, top to bottom

BaseException

the true root — every exception inherits from here

Exception

the drawer for ordinary, handleable errors

ArithmeticError / LookupError / OSError

broad groups within Exception

ZeroDivisionError / KeyError / FileNotFoundError

the specific errors you actually catch

  1. BaseException — the true root — every exception inherits from here
  2. Exception — the drawer for ordinary, handleable errors
  3. ArithmeticError / LookupError / OSError — broad groups within Exception
  4. ZeroDivisionError / KeyError / FileNotFoundError — the specific errors you actually catch

Catching BaseException instead of Exception for a general handler

Wrong

python
try:
    run_batch_job()
except BaseException as e:   # WRONG — also catches Ctrl+C and sys.exit()
    log_and_continue(e)

Better

python
try:
    run_batch_job()
except Exception as e:   # everything ordinary, but not SystemExit/KeyboardInterrupt
    log_and_continue(e)

What you see: Pressing Ctrl+C to stop the program does nothing — the handler silently catches KeyboardInterrupt and the job keeps running, because BaseException includes it.

Why: except BaseException matches every exception, including the three that exist specifically so operators can interrupt a program (KeyboardInterrupt) or a program can exit cleanly (SystemExit). A broad handler almost always means except Exception, which leaves those two paths untouched.

The core of the built-in hierarchy

The core of the built-in hierarchy
ClassParentCatches
BaseException(root)everything — never catch this directly
ExceptionBaseExceptionthe drawer for ordinary, handleable errors
SystemExitBaseExceptionraised by sys.exit() — not caught by except Exception
KeyboardInterruptBaseExceptionraised by Ctrl+C — not caught by except Exception
ArithmeticErrorExceptionparent of ZeroDivisionError, OverflowError
LookupErrorExceptionparent of KeyError, IndexError
OSErrorExceptionparent of FileNotFoundError, PermissionError, ConnectionError

Together

python
print(issubclass(ValueError, Exception))
print(issubclass(KeyboardInterrupt, Exception))
print(issubclass(KeyboardInterrupt, BaseException))
print(issubclass(ZeroDivisionError, ArithmeticError))
print(issubclass(FileNotFoundError, OSError))

Remember: except Exception excludes SystemExit/KeyboardInterrupt on purpose. Reach for BaseException only when you truly mean everything.

See also: try except · custom exceptions · multiple exception types

Advertisement

Catching and recovering

The full try statement — the block that might fail, the type that catches it, the success-only branch, and guaranteed cleanup.

try / except

corebeginner

try wraps code that might raise an exception. except names the exception type to catch and recover from, running only when that type — or a subclass of it — is actually raised.

Think of it as

try/except is a safety net under a specific piece of the program, not the whole program — code inside try runs normally until something raises, then control jumps straight to the first except that matches, skipping the rest of the try block entirely.

python
try:
    risky_call()
except ValueError as e:
    handle(e)   # runs only if risky_call() raises ValueError (or a subclass)

What we're doing: Catch a specific exception type from a division helper and confirm the rest of the try block is skipped after the failure.

divide.pypython
def divide(a, b):
    try:
        result = a / b
        print("this only prints on success")
    except ZeroDivisionError:
        return "cannot divide by zero"
    return result

print(divide(10, 2))
print(divide(10, 0))
3
a / b raises ZeroDivisionError when b is 0, before print() on the next line ever runs.
5
except ZeroDivisionError only matches that one exception type — any other error would propagate unhandled.
Output
5.0
cannot divide by zero

Why this works: divide(10, 0) never reaches "this only prints on success" because a / b raises immediately — Python jumps straight from the point of the raise to the matching except, skipping everything else left in the try block.

Catching Exception broadly instead of the one error actually expected

Wrong

python
try:
    price = float(user_input)
except Exception:   # hides typos AND the real ValueError equally
    price = 0.0

Better

python
try:
    price = float(user_input)
except ValueError:   # only the failure float() actually raises
    price = 0.0

What you see: A typo like flaot(user_input) raises NameError, which the broad except Exception also swallows — the bug looks identical to bad user input and is far harder to find.

Why: except Exception matches every ordinary error, not just the one the code is prepared to handle, so it hides bugs the same way it hides expected failures. Naming the exact type keeps unrelated bugs visible.

try/except control flow

try: block runs

normal code, may raise

exception raised

remaining try lines are skipped

matching except runs

first except whose type matches

  1. try: block runs — normal code, may raise
  2. exception raised — remaining try lines are skipped
  3. matching except runs — first except whose type matches

Remember: try wraps code that might fail; except names what it recovers from. Name a specific type — a bare except: hides bugs it was never meant to catch.

See also: exception hierarchy · else clause · finally clause · multiple exception types

else clause

standardbeginner

else on a try statement runs only if the try block raised nothing at all. It keeps success-path code out of try, so except only ever catches failures from the lines actually being protected.

Think of it as

else is "if nothing above went wrong" — putting success-only code there instead of at the end of try means a bug in that success code cannot accidentally get caught by the except meant for the risky call above it.

python
try:
    value = int(text)
except ValueError:
    print("not a number")
else:
    print(f"parsed {value}")   # only runs if int(text) succeeded

What we're doing: Parse a string and show that else only runs on success, while a failing parse skips it entirely.

parse.pypython
def read_number(text):
    try:
        value = int(text)
    except ValueError:
        print("not a number")
    else:
        print(f"parsed {value}, doubled {value * 2}")

read_number("42")
read_number("abc")
3
int(text) succeeds for "42", so no exception is raised and except is skipped entirely.
6
else only runs after a try block that raised nothing — value is guaranteed to exist here.
Output
parsed 42, doubled 84
not a number

Why this works: value * 2 in else can safely assume value was assigned, because else never runs unless int(text) on the line above it succeeded without raising — the same guarantee would not hold if that line were written inside except by mistake.

Remember: else runs only when try raised nothing — it keeps success-only code from being accidentally caught by the except above it.

See also: try except · finally clause

finally clause

standardbeginner

finally always runs — on success, on a caught exception, on an uncaught exception, and even after a return inside try. It is the place for cleanup that must never be skipped, like closing a file or releasing a lock.

Think of it as

finally is a guarantee, not a convenience — it runs on every single exit path out of the try statement, which is exactly why cleanup code (closing a connection, releasing a lock) belongs there instead of at the bottom of try, where an early return or an exception would skip it.

python
try:
    risky_call()
finally:
    cleanup()   # always runs, even if risky_call() raised and was not caught

What we're doing: Show that finally runs even when the exception it follows is never caught, before the exception propagates to the caller.

cleanup.pypython
def with_finally():
    try:
        raise ValueError("boom")
    finally:
        print("cleanup ran")

try:
    with_finally()
except ValueError as e:
    print(f"caught: {e}")
3
raise ValueError("boom") has no matching except in with_finally, so the exception is not caught here.
4
finally still runs before the exception is allowed to propagate out of with_finally.
9
The exception is finally caught here, one level up — cleanup already happened by this point.
Output
cleanup ran
caught: boom

Why this works: "cleanup ran" prints before "caught: boom" because finally executes at the moment control leaves with_finally, regardless of whether anything inside it caught the exception — only after finally completes does the ValueError continue propagating outward to be caught by the caller.

Returning inside finally, silently discarding a pending exception

Wrong

python
def process(data):
    try:
        return risky_transform(data)
    finally:
        return "default"   # WRONG — swallows any exception from risky_transform

Better

python
def process(data):
    try:
        return risky_transform(data)
    except TransformError:
        return "default"   # handle the failure explicitly instead

What you see: risky_transform(data) raises an exception, but the caller never sees it — process() just returns "default" as if nothing went wrong.

Why: A return (or break/continue) inside finally replaces whatever the try block was doing, including an in-flight exception — Python discards the exception entirely rather than letting it propagate, which is almost never the intended behaviour.

Remember: finally always runs on every exit path. Never put return inside it — that silently discards a pending exception.

See also: try except · else clause

Multiple exception types

standardbeginner

except (TypeError, ValueError): catches either type with one handler, using the same recovery code for both. Stack separate except blocks instead when each type needs a different response.

Think of it as

A tuple in except is "any one of these" — Python checks the raised exception against every type in the tuple, and runs the block if it matches any of them, the same way isinstance(x, (TypeError, ValueError)) would.

python
try:
    value = int(raw)
except (TypeError, ValueError) as e:
    print(f"bad input: {e}")   # same handling for either type

What we're doing: Parse and divide two values, catching TypeError and ValueError with one shared handler while giving ZeroDivisionError its own separate handler.

parse_and_divide.pypython
def parse_and_divide(a, b):
    try:
        return int(a) / int(b)
    except (TypeError, ValueError) as e:
        return f"bad input: {e}"
    except ZeroDivisionError:
        return "cannot divide by zero"

print(parse_and_divide("10", "2"))
print(parse_and_divide("10", "abc"))
print(parse_and_divide("10", None))
print(parse_and_divide("10", "0"))
4
One except clause with a tuple catches both int("abc") (ValueError) and int(None) (TypeError) identically.
6
ZeroDivisionError gets its own separate except, because "cannot divide by zero" needs a different message than "bad input."
Output
5.0
bad input: invalid literal for int() with base 10: 'abc'
bad input: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
cannot divide by zero

Why this works: int('abc') raises ValueError and int(None) raises TypeError — genuinely different exception types — but both are matched by the same except (TypeError, ValueError): because a tuple in except means "any one of these." ZeroDivisionError is unrelated to bad input, so it gets its own except with its own message instead of being merged into the same tuple.

Writing except TypeError, ValueError: without the tuple parentheses

Wrong

python
try:
    value = int(raw)
except TypeError, ValueError:   # SyntaxError in Python 3
    handle_bad_input()

Better

python
try:
    value = int(raw)
except (TypeError, ValueError):   # a tuple — parentheses required
    handle_bad_input()

What you see: SyntaxError: multiple exception types must be parenthesized — the code fails to even run, not just to catch correctly.

Why: except TypeError, ValueError: was valid in Python 2, where the comma bound the exception to a variable named ValueError instead. Python 3 requires the tuple form with parentheses to catch more than one type in a single except clause.

Remember: except (TypeA, TypeB): catches either with one shared handler — parentheses make it a tuple. Split into separate blocks when each type needs its own response.

See also: try except · exception hierarchy · retryable errors

Advertisement

Raising and chaining

Signalling a failure yourself, linking a new exception to the one that caused it, and defining your own exception types.

raise

standardbeginner

raise ExceptionType(message) stops normal execution and starts looking for a matching except, working outward through the call stack. A bare raise inside an except re-raises the exception currently being handled.

Think of it as

raise is Python's way to say "stop — this cannot continue normally" — it interrupts the current line immediately and hands control to the nearest matching except up the call stack, or to the interpreter itself if nothing catches it.

python
def check_age(age):
    if age < 0:
        raise ValueError(f"age cannot be negative: {age}")
    return age

What we're doing: Validate an input and raise a descriptive exception when the value is invalid, showing the caller receives the exact message.

validate.pypython
def check_age(age):
    if age < 0:
        raise ValueError(f"age cannot be negative: {age}")
    return age

try:
    check_age(-5)
except ValueError as e:
    print(f"raised: {e}")
3
raise constructs ValueError with the f-string message and immediately transfers control — return age on the next line never runs.
8
str(e) gives back exactly the message passed to ValueError(...) at the raise site.
Output
raised: age cannot be negative: -5

Why this works: raise ValueError(...) both builds the exception object and immediately starts propagating it — execution never reaches return age, and the search for a handler begins at the try that called check_age.

Remember: raise ExceptionType(message) stops execution and searches outward for a matching except. A bare raise re-raises the exception already being handled.

See also: raise from · custom exceptions · error propagation

raise ... from ...

coreintermediate

raise NewError("...") from original_error sets NewError.__cause__ to original_error and prints both tracebacks, labelled "the direct cause of." It replaces a low-level error with a more meaningful one without losing the original.

Think of it as

raise from is a deliberate hand-off, not an accident — it says "this new, more meaningful error happened BECAUSE of that original one," and keeps both visible in the traceback, instead of the original error just quietly vanishing when you raise a different type in its place.

python
try:
    int(raw_value)
except ValueError as e:
    raise ConfigError("invalid config value") from e   # __cause__ = e

What we're doing: Catch a low-level ValueError and re-raise it as a more meaningful ConfigError with raise from, then inspect __cause__ and the real traceback text.

chain.pypython
class ConfigError(Exception):
    pass

def load_config():
    try:
        int("not-a-number")
    except ValueError as e:
        raise ConfigError("invalid config value") from e

try:
    load_config()
except ConfigError as e:
    print(f"outer: {e}")
    print(f"cause: {e.__cause__!r}")
6
int("not-a-number") raises ValueError, caught here and bound to e.
8
from e explicitly links the new ConfigError to the original — this sets __cause__.
Output
outer: invalid config value
cause: ValueError("invalid literal for int() with base 10: 'not-a-number'")

Why this works: __cause__ is set the moment `from e` is evaluated, so ConfigError permanently carries a reference to the ValueError that triggered it — the traceback for this exact code prints both tracebacks, joined by the line "The above exception was the direct cause of the following exception," making the real root cause visible instead of hidden.

raise ... from ... links two exceptions explicitly

ValueError raised

the original, low-level failure

raise ConfigError(...) from e

sets __cause__ = e explicitly

both shown in traceback

"the direct cause of the following exception"

  1. ValueError raised — the original, low-level failure
  2. raise ConfigError(...) from e — sets __cause__ = e explicitly
  3. both shown in traceback — "the direct cause of the following exception"

Re-raising a different exception type with no `from`, hiding the real cause

Wrong

python
def load_config():
    try:
        int("not-a-number")
    except ValueError:
        raise ConfigError("invalid config value")   # cause not linked explicitly

Better

python
def load_config():
    try:
        int("not-a-number")
    except ValueError as e:
        raise ConfigError("invalid config value") from e   # __cause__ set explicitly

What you see: The traceback still shows both exceptions, but labelled "During handling of the above exception, another exception occurred" instead of "the direct cause of" — and e.__cause__ is None, only __context__ is set.

Why: Without `from`, Python still records the original exception in __context__ automatically (see exception chaining), but __cause__ stays None — that difference matters to any code or logging tool that specifically checks __cause__ to find the deliberate root cause.

raise ... from ..., by what you pass

raise ... from ..., by what you pass
FormEffect
raise New(...) from originalsets __cause__ = original; traceback shows both, labelled "direct cause"
raise New(...) from Nonesets __suppress_context__ = True; traceback shows only the new exception
raise New(...) (no from)sets __context__ automatically if raised inside an except block

Together

python
class ConfigError(Exception):
    pass

def load_config():
    try:
        int("not-a-number")
    except ValueError as e:
        raise ConfigError("invalid config value") from e

try:
    load_config()
except ConfigError as e:
    print(e.__cause__)
    print(e.__suppress_context__)

Remember: raise New(...) from original sets __cause__ and prints "the direct cause of" in the traceback. Use `from None` only when the original adds no value.

See also: exception chaining · raise statement · custom exceptions

Exception chaining

standardintermediate

If a new exception is raised while an except block is already handling one, Python automatically links them via __context__ — even with no `from` at all. The traceback shows both, labelled "during handling of."

Think of it as

Implicit chaining is Python noticing a coincidence, not you declaring one — raising inside an except block without `from` still records what was already being handled, purely because of where the raise happened, not because you asked for a link.

python
try:
    step_one()
except SomeError:
    raise OtherError("cleanup also failed")   # no 'from' — __context__ still set

What we're doing: Raise a new exception inside an except block with no `from` at all, and confirm Python still links it via __context__, plus show `from None` suppressing the chain entirely.

implicit.pypython
def implicit_chain():
    try:
        int("not-a-number")
    except ValueError:
        raise RuntimeError("failed during recovery")

try:
    implicit_chain()
except RuntimeError as e:
    print(f"cause: {e.__cause__}")
    print(f"context: {e.__context__!r}")
3
int("not-a-number") raises ValueError, entering the except block below.
5
raise RuntimeError(...) has no `from` clause at all, yet it happens while ValueError is still being handled.
Output
cause: None
context: ValueError("invalid literal for int() with base 10: 'not-a-number'")

Why this works: __cause__ stays None because no `from` was written, but __context__ is set anyway — Python tracks "what was being handled when this raise happened" automatically, regardless of whether the author intended a link. The real traceback for this code prints both exceptions, joined by "During handling of the above exception, another exception occurred," which reads differently from the "direct cause" wording raise-from produces.

Assuming a chained traceback always means raise ... from ... was used

Wrong

python
# Seeing "During handling of the above exception..." in a traceback
# and assuming the code explicitly linked the two exceptions with from.

Better

python
# Check the actual wording:
# "the direct cause of"  -> explicit: raise ... from original
# "During handling of"   -> implicit, __context__ only, no from written

What you see: Debugging code assumes exc.__cause__ is set because the traceback shows two exceptions, then gets None back — the link was implicit (__context__), not explicit (__cause__).

Why: Both explicit and implicit chaining produce a two-part traceback, but only explicit `from` sets __cause__ — code that specifically needs the deliberate root cause (not just "whatever was being handled at the time") must check __cause__, not just look for a chained traceback.

__cause__ vs __context__

__cause__ vs __context__
AttributeSet byTraceback label
__cause__raise New(...) from original — explicit only"the direct cause of the following exception"
__context__any raise inside an active except block, with or without from"During handling of the above exception, another exception occurred"
__suppress_context__raise New(...) from Nonehides the chain — no second traceback shown

Together

python
def implicit_chain():
    try:
        int("not-a-number")
    except ValueError:
        raise RuntimeError("failed during recovery")   # no 'from' at all

try:
    implicit_chain()
except RuntimeError as e:
    print(e.__cause__)              # None — no explicit from
    print(e.__context__)            # the ValueError, set automatically
    print(e.__suppress_context__)   # False

Remember: Raising inside an active except block sets __context__ automatically, even with no `from`. Use `from None` to suppress the chain.

See also: raise from · raise statement · exception boundaries

Custom exceptions

corebeginner

A custom exception is a class that subclasses Exception, giving your code its own precise error type — InsufficientFundsError instead of a generic ValueError — that callers can catch specifically and attach structured data to.

Think of it as

A custom exception is a labelled box, not just a message — subclassing Exception lets a caller catch exactly InsufficientFundsError without also catching every other ValueError in the program, and __init__ lets the box carry structured data (balance, amount) a plain string message cannot.

python
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"cannot withdraw {amount}, balance is {balance}")

What we're doing: Define a custom exception that carries structured data, raise it from a validation function, and read both the message and the attributes back off the caught instance.

funds.pypython
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"cannot withdraw {amount}, balance is {balance}")


def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount


try:
    withdraw(100, 150)
except InsufficientFundsError as e:
    print(f"error: {e}")
    print(f"balance={e.balance}, amount={e.amount}")
    print(isinstance(e, Exception))
5
super().__init__(...) sets the message str(e) will show, exactly like any built-in exception.
9
raise InsufficientFundsError(balance, amount) passes both values into __init__ above.
16
e.balance and e.amount are ordinary instance attributes — readable on the caught exception, not just the message.
Output
error: cannot withdraw 150, balance is 100
balance=100, amount=150
True

Why this works: InsufficientFundsError behaves like any built-in exception — str(e) works because __init__ called super().__init__(message) — while also carrying e.balance and e.amount as plain attributes, because it is an ordinary Python class underneath. isinstance(e, Exception) is True because it subclasses Exception, so a broad except Exception still catches it if nothing more specific does.

A custom exception, end to end

class InsufficientFundsError(Exception)

subclasses Exception

raise InsufficientFundsError(balance, amount)

carries structured data

except InsufficientFundsError as e

e.balance, e.amount readable

  1. class InsufficientFundsError(Exception) — subclasses Exception
  2. raise InsufficientFundsError(balance, amount) — carries structured data
  3. except InsufficientFundsError as e — e.balance, e.amount readable

Forgetting to call super().__init__(), leaving str(e) empty

Wrong

python
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        # missing: super().__init__(...)

e = InsufficientFundsError(100, 150)
print(str(e))   # '' — empty!

Better

python
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"cannot withdraw {amount}, balance is {balance}")

What you see: print(e) or logging the exception shows an empty message, even though e.balance and e.amount are set correctly — only the message string is missing.

Why: Exception.__str__ returns whatever was passed to Exception.__init__ — skip calling super().__init__(message) and that value is never set, so str(e) falls back to an empty string even though the custom attributes still work fine.

A minimal custom exception vs one carrying structured data

A minimal custom exception vs one carrying structured data
StyleWhen to use
class ConfigError(Exception): passthe type itself is the signal — no extra data needed
__init__ storing extra attributescallers need structured data, not just a message string
A shared base for related errorslets one except catch the whole family when needed

Together

python
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"cannot withdraw {amount}, balance is {balance}")

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    withdraw(100, 150)
except InsufficientFundsError as e:
    print(e.balance, e.amount)

Remember: Subclass Exception, call super().__init__(message), and store extra data as attributes for callers to read.

See also: exception hierarchy · raise statement · user facing vs internal errors

Advertisement