Skip to content

Custom Exceptions & the Hierarchy Interview Questions & Answers

16 questions Updated 2026-06-18 Share:

Python interview questions on the exception hierarchy, BaseException vs Exception, defining custom exception classes, passing messages, and how catching a base class catches its subclasses.

Read the in-depth guidePython Custom Exceptions Explained — The Exception Hierarchy and Designing Your Own Errors(opens in new tab)
16 of 16

All exceptions inherit from BaseException at the root. Directly under it sit a few special ones — SystemExit, KeyboardInterrupt, and GeneratorExit — that are not meant to be caught by normal error handling. Everything you'd normally catch (and every built-in error like ValueError) descends from Exception, which is itself a child of BaseException.

# BaseException
#  ├── SystemExit
#  ├── KeyboardInterrupt
#  └── Exception          <- catch THIS, not BaseException
#       ├── ValueError
#       ├── KeyError
#       └── ...

Catching BaseException (or a bare except:) traps KeyboardInterrupt and SystemExit, so Ctrl-C and clean shutdowns stop working. Catch Exception (or narrower) and let the system-level ones propagate.

Subclass Exception (almost never BaseException). The simplest custom exception needs no body at all — pass is enough, since it inherits message handling from Exception.

class ConfigError(Exception):
    """Raised when configuration is invalid."""

raise ConfigError("missing 'port' key")

try:
    ...
except ConfigError as e:
    print(e)            # missing 'port' key

Give the class a clear, specific name ending in Error, and a docstring describing when it's raised. Even an empty subclass is valuable because it lets callers catch your error specifically instead of a generic Exception.

Create a custom exception when callers need to distinguish your error from others and handle it differently. A common pattern is a single base exception for your library/app, with specific subclasses beneath it — so users can catch the base to handle "anything from this library" or a subclass for fine-grained control.

class PaymentError(Exception):
    """Base for all payment problems."""

class CardDeclined(PaymentError): pass
class InsufficientFunds(PaymentError): pass

try:
    charge(card)
except InsufficientFunds:
    retry_later()
except PaymentError:          # catches any other payment error
    alert_support()

Don't invent a custom exception when a built-in already fits — bad input is a ValueError, a missing key is a KeyError. Add your own only when it carries meaning the built-ins can't.

Arguments passed to an exception are stored in its .args tuple, and the first one becomes the string returned by str(exception). To attach structured data, add attributes in a custom __init__ (and call super().__init__() so the message still works).

raise ValueError("bad value", 42)
# e.args == ("bad value", 42)

class ApiError(Exception):
    def __init__(self, message, status_code):
        super().__init__(message)   # sets the message / .args
        self.status_code = status_code

try:
    raise ApiError("not found", 404)
except ApiError as e:
    print(e, e.status_code)         # not found 404

Use a plain message for simple cases; add attributes when handlers need to inspect details (like an HTTP status or the offending value) rather than parse the text.

except matches via isinstance, so a handler for a base class catches the base and every subclass. That's why except Exception catches almost everything, and why ordering matters: put specific subclasses before their base, or the base will intercept them first.

class AppError(Exception): pass
class NotFound(AppError): pass

try:
    raise NotFound("user")
except AppError:            # matches — NotFound IS-A AppError
    print("caught by base")

try:
    raise NotFound("user")
except AppError:            # this runs first...
    print("base")
except NotFound:            # ...so this is unreachable!
    print("specific")

Order except clauses most-specific first. The same rule is why except Exception should come last among your handlers.

Knowing the standard ones lets you catch precisely and raise the right error. ValueError — right type, wrong value (int("abc")). TypeError — wrong type entirely ("x" + 1). KeyError — missing dict key. IndexError — list index out of range. AttributeError — missing attribute. KeyError and IndexError both subclass LookupError.

int("abc")          # ValueError
"x" + 1             # TypeError
{"a": 1}["b"]       # KeyError
[1, 2][5]           # IndexError
None.foo            # AttributeError

Raise the built-in that best describes the problem instead of a generic Exception — ValueError for bad arguments, TypeError for wrong types — so callers can handle them idiomatically.

BaseException is the root and also the parent of control-flow exceptions you should not normally catch: SystemExit, KeyboardInterrupt, GeneratorExit. Inheriting from Exception (the base for "ordinary" errors) ensures a broad except Exception catches your error without you accidentally interfering with shutdown or Ctrl-C.

class MyError(Exception):     # correct base
    pass

try:
    risky()
except Exception:             # catches MyError, not KeyboardInterrupt
    handle()

Rule of thumb: always derive custom exceptions from Exception (or a more specific subclass), never from BaseException.

Exception.__init__ stores its positional arguments in .args, and str(exc) renders them (a single arg shows as-is). Overriding __str__ lets you control the message; calling super().__init__(msg) keeps .args populated for tooling.

e = ValueError("bad", 42)
e.args            # ('bad', 42)
str(e)            # "('bad', 42)"

class HTTPError(Exception):
    def __init__(self, status):
        super().__init__(f"HTTP {status}")
        self.status = status

Rule of thumb: pass the message to super().__init__ and add custom attributes for structured data callers can inspect.

ExceptionGroup bundles multiple exceptions raised together (e.g. from concurrent tasks), and except* matches and handles subsets of them by type, leaving the rest to propagate. It solves "many things failed at once" that a single except can't express.

try:
    raise ExceptionGroup("fails", [ValueError("a"), TypeError("b")])
except* ValueError as eg:
    print("values:", eg.exceptions)
except* TypeError as eg:
    print("types:", eg.exceptions)

Rule of thumb: use ExceptionGroup/except* for concurrent or batched operations where several independent errors can surface simultaneously.

A narrow except only handles errors you actually anticipate, letting unexpected ones surface instead of being silently mishandled. Catching Exception (or worse, bare except) can mask bugs, typos (NameError), and KeyboardInterrupt, making failures hard to diagnose.

try:
    value = data[key]
except KeyError:                 # only the expected case
    value = default
# not: except Exception -> would also hide a real bug above

Rule of thumb: catch the most specific exception that you can genuinely recover from; let everything else propagate.

Use a bare raise inside the except block — it re-raises the current exception with its original traceback. Writing raise e works but can reset context; raise ... from e deliberately chains a new exception to the cause.

try:
    work()
except IOError:
    log.error("io failed")
    raise                       # re-raises with original traceback

except ValueError as e:
    raise ConfigError("bad config") from e   # chained cause

Rule of thumb: bare raise to propagate as-is; raise New from e when you wrap it in a more meaningful exception.

EAFP — "Easier to Ask Forgiveness than Permission" — means try the operation and catch the failure rather than pre-checking. It's idiomatic Python, avoids race conditions (the state can change between check and use), and is often faster on the success path.

# EAFP (preferred)
try:
    return cache[key]
except KeyError:
    return compute(key)

# LBYL (race-prone, more code)
if key in cache: return cache[key]

Rule of thumb: prefer try/except (EAFP) over look-before-you-leap checks for operations that usually succeed.

Store extra fields on the instance in __init__, then handlers can read them to react programmatically (retry, format a response, log details) instead of parsing a message string.

class APIError(Exception):
    def __init__(self, message, status, payload=None):
        super().__init__(message)
        self.status = status
        self.payload = payload

try:
    call()
except APIError as e:
    if e.status == 429:
        retry_later(e.payload)

Rule of thumb: put machine-readable data (codes, IDs) on attributes; keep the message human-readable.

Define one base exception for your package and derive specific errors from it. Callers can then catch the base to handle "anything from this library" or a specific subclass for fine-grained handling — without depending on message text.

class LibError(Exception): ...
class NotFound(LibError): ...
class RateLimited(LibError): ...

try:
    client.get(...)
except RateLimited:
    backoff()
except LibError:               # catch-all for the library
    report()

Rule of thumb: give every library a common base exception so users can catch broadly or narrowly as they choose.

finally runs on normal exit, exceptions, return, break, and continue. It is skipped only in rare hard-stop cases: the process is killed (os._exit, SIGKILL), the interpreter crashes, or an infinite loop / deadlock never reaches it. sys.exit() (which raises SystemExit) still runs finally.

try:
    sys.exit(1)
finally:
    print("still runs")        # SystemExit is a normal exception here

Rule of thumb: treat finally as guaranteed for cleanup except under os._exit/SIGKILL or a true crash.

Raise an exception when execution cannot sensibly continue. Emit a warnings.warn(...) for non-fatal advisories — deprecations, suspicious but recoverable conditions — that the program can keep running past. Warnings are filterable and can be escalated to errors in tests.

import warnings
def old_api():
    warnings.warn("use new_api()", DeprecationWarning, stacklevel=2)
    ...

Rule of thumb: exception = "stop, this failed"; warning = "heads up, but carrying on".

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel