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 Errors & Exceptions interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.