Advanced exceptions

Part of the free LogicWiz Python course, Module 14: Exceptions, context managers and testing. The examples and practice run in the app.

A customer ordered a "paneer roll" and Canteen Counter crashed with KeyError: 'paneer roll'. Shankar bhaiya has no idea what a KeyError is. He needs to read: paneer roll is not on today's menu.

You'll learn to

  • Read the exception hierarchy and know what `except Exception` really catches
  • Write your own exception classes with extra data
  • Chain exceptions with `raise ... from ...`
  • Avoid the `finally` + `return` trap

Learn

Exceptions are classes in a family tree

Every exception is a class, and they form a tree:

  • BaseException is the root.
    • SystemExit, KeyboardInterrupt (Ctrl+C), GeneratorExit: "please stop" signals, not errors.
    • Exception: the parent of all normal errors.
      • ArithmeticError → ZeroDivisionError
      • LookupError → KeyError, IndexError
      • ValueError, TypeError, OSError → FileNotFoundError …

except X catches X and all its children. So except Exception catches every normal error but lets Ctrl+C through, which is what you want. A bare except: catches even Ctrl+C.

Ask the tree

print(issubclass(ZeroDivisionError, ArithmeticError))
print(issubclass(KeyError, LookupError))
print(issubclass(KeyboardInterrupt, Exception))
print([c.__name__ for c in IndexError.__mro__])

for bad in ({}, []):
    try:
        bad[5]
    except LookupError as e:
        print("caught", type(e).__name__)

One except LookupError handles both a missing dict key and a list index out of range, because both are its children.

Your own exceptions

Built-in errors describe Python's problems. Your app has its own: an item out of stock, a wrong UPI ID. Make an exception class for each:

  1. One base class for your app, class CanteenError(Exception), so callers can catch all your errors at once.
  2. Specific children like OutOfStock(CanteenError).
  3. Store useful data as attributes, and pass a readable message to super().__init__(message).

Name them ending in Error (or a clear noun), and subclass Exception, never BaseException.

A Canteen Counter error family

class CanteenError(Exception):
    """Base class for all Canteen Counter errors."""

class OutOfStock(CanteenError):
    def __init__(self, item, left):
        super().__init__(f"{item}: only {left} left")
        self.item = item
        self.left = left

try:
    raise OutOfStock("samosa", 2)
except CanteenError as e:
    print("Sorry!", e)
    print(e.item, e.left)

Chaining: `raise ... from ...`

class CanteenError(Exception):
    pass

def price_of(menu, item):
    try:
        return menu[item]
    except KeyError as e:
        raise CanteenError(f"{item} is not on today's menu") from e

try:
    price_of({"chai": 10}, "paneer roll")
except CanteenError as err:
    print(err)
    print("caused by:", repr(err.__cause__))

from e stores the original error in __cause__. An uncaught chained error prints both tracebacks with "The above exception was the direct cause of the following exception". Use raise ... from None to hide the low-level one.

Implicit chaining and `finally`

If a new error happens while handling another (inside except), Python links them by itself in __context__ and prints "During handling of the above exception, another exception occurred". from makes the link on purpose and says "this was the cause".

finally always runs: after try succeeds, after an except, even after a return inside try. That makes it the place for cleanup. But a return inside finally replaces whatever was happening, including an exception on its way out.

The `finally` + `return` trap

def risky():
    try:
        1 / 0
    finally:
        return "finally won"

print(risky())

def which():
    try:
        return "from try"
    finally:
        print("finally runs first")

print(which())

In risky, the ZeroDivisionError simply vanishes: the return in finally threw it away. Newer Pythons (3.14+) even warn about this. Never return, break or continue inside finally.

CBSE Class 12 and university papers ask the order of try → except → else → finally: else runs only if try raised nothing; finally runs every time. Interviews add: "how do you create a custom exception?" and "what is the difference between raise and raise ... from?"

In one line

Subclass Exception for your own errors (with a base class), chain with raise New(...) from e, and never return inside finally.

Interview corner

How do you create a custom exception, and why?

Subclass Exception (usually through one base class for your app) and pass a message to super().__init__. Store extra data as attributes. Custom exceptions let callers catch your errors precisely (except OutOfStock) or all of them (except CanteenError), instead of guessing from a generic ValueError.

class OutOfStock(CanteenError):
    def __init__(self, item):
        super().__init__(f"{item} is out of stock")
        self.item = item

What is the difference between `BaseException` and `Exception`?

BaseException is the root of all exceptions, including SystemExit, KeyboardInterrupt and GeneratorExit, which are signals to stop rather than errors. Exception is the base for normal errors. Catch Exception, not BaseException or a bare except:, so Ctrl+C and sys.exit() still work.

What does `raise ... from ...` do?

It raises a new exception and sets its __cause__ to the original, so the traceback shows both with "the direct cause of". It is used to translate a low-level error into a meaningful one without losing the evidence. raise X from None hides the original; without from, Python still records the original in __context__ if you raise inside an except.

What happens if `finally` contains a `return`?

The return in finally wins: it overrides the try block's return value and discards any exception that was propagating. That silently hides errors, so never return (or break/continue) from finally.