How does Python's exception hierarchy work, and how do you create custom exceptions?

6 minintermediateexceptionshierarchycustom-exceptions

Quick Answer

Every exception ultimately derives from `BaseException`; almost all user code should derive from `Exception` (its subclasses cover things you're expected to catch — `BaseException` also covers `SystemExit`/`KeyboardInterrupt`, which normally shouldn't be caught by a generic handler). Custom exceptions are just classes subclassing `Exception` (or a more specific built-in exception), typically adding fields for structured error context.

Detailed Answer

The hierarchy, top-down

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── TypeError
      ├── KeyError, IndexError (-> LookupError)
      ├── OSError (FileNotFoundError, PermissionError, ...)
      ├── ArithmeticError (ZeroDivisionError, ...)
      └── ... (all "normal" errors your code raises/catches)

Exception is the practical root for everything you raise and catch in application code. BaseException additionally covers SystemExit (raised by sys.exit()) and KeyboardInterrupt (Ctrl+C) — these deliberately sit outside Exception so that a broad except Exception: doesn't accidentally swallow a user's Ctrl+C or a deliberate sys.exit() call.

Defining a custom exception

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

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

try:
    withdraw(100, 150)
except InsufficientFundsError as e:
    print(e)              # the formatted message
    print(e.balance, e.amount)   # structured fields for programmatic handling

Subclassing Exception and calling super().__init__(message) gives you the standard string representation (str(e)) for free, while attaching extra attributes (self.balance, self.amount) lets calling code inspect structured error details instead of parsing a message string.

Building an exception hierarchy for a library/app

class AppError(Exception):
    '''Base class for all errors raised by this application.'''

class ValidationError(AppError):
    pass

class NotFoundError(AppError):
    pass

class PermissionDeniedError(AppError):
    pass

try:
    do_something()
except AppError as e:      # catches ANY of the app's custom errors
    log.error(e)
except Exception:            # anything unexpected/unrelated to the app's domain
    raise

Defining one base exception per library/module (AppError) and deriving all specific errors from it lets calling code choose the granularity it needs: catch a specific subtype, or catch the whole family via the shared base, without needing a tuple of every specific exception class.

When to subclass a built-in instead

class InvalidEmailError(ValueError):   # "is a" ValueError -- caller code that
    pass                                 # already catches ValueError still works

If your custom error is genuinely a more specific case of a built-in (a malformed email is a kind of ValueError), subclassing the built-in lets existing code that catches the built-in continue to work correctly without changes.

Interview-ready summary: Exception is the practical base for application errors (BaseException also covers SystemExit/ KeyboardInterrupt, deliberately excluded from Exception so broad handlers don't eat them). Custom exceptions are ordinary subclasses, usually of Exception or a relevant built-in, adding structured fields via __init__ — and a shared base exception per module/library lets callers choose how broadly to catch.