One custom exception is useful; a well-organized HIERARCHY of them is what lets a real application catch failures at exactly the right level of granularity ā sometimes broad ('any error from this library'), sometimes narrow ('specifically this one failure'). This lesson covers designing that hierarchy deliberately.
1One Base Exception Per Library: The Foundation of a Usable Hierarchy
The single most valuable design decision for a library or application's error handling is establishing one base exception class (PaymentError, here) that every specific exception the module can raise inherits from, directly or indirectly. This single decision is what makes the entire hierarchy *useful* as a hierarchy, rather than a loose, unrelated collection of exception classes that happen to live in the same module.
With that base class established, InsufficientFundsError(PaymentError), CardDeclinedError(PaymentError), and PaymentGatewayTimeoutError(PaymentError) are all, simultaneously, both their own specific, precisely-catchable type *and* a PaymentError ā Python's exception matching uses isinstance() semantics under the hood, so an exception instance genuinely satisfies every class in its inheritance chain, not just its most specific, concrete class.
This is directly analogous to a well-designed class taxonomy anywhere else in an object-oriented system (the Liskov Substitution and SOLID principles from earlier in this curriculum apply equally here): the base class defines the shared 'is a payment failure' contract, and each subclass specializes it with the specific detail relevant to that particular failure mode ā exactly the same is-a relationship reasoning, applied specifically to the domain of error types.
class PaymentError(Exception):
"""Base exception for all payment-related failures."""
class InsufficientFundsError(PaymentError):
pass
class CardDeclinedError(PaymentError):
pass
class PaymentGatewayTimeoutError(PaymentError):
passBoth a specific type AND a PaymentError, simultaneously
2Catching Broadly vs Narrowly, From the Same Hierarchy
The direct payoff of a shared base class is that calling code can choose its catching granularity independently, based on what it actually needs to do. Code that only needs to know 'did any payment-related failure happen at all, so I can show a generic error and log it' can write a single except PaymentError as e: and correctly catch every current and *future* subclass ā including ones added to the library after this calling code was written, without that calling code needing any modification.
Code that needs to respond differently to one specific failure mode ā offering an installment plan specifically for InsufficientFundsError, while treating every other payment failure generically ā lists the specific exception's except clause *before* the general base class's clause, letting the specific handler run for that one case while the general handler still catches everything else.
This dual capability ā broad catching for generic handling, narrow catching for specific handling, both drawing from the exact same hierarchy with zero duplication ā is precisely why designing a coherent hierarchy (rather than a flat pile of unrelated custom exception classes, or worse, everything raised as a generic Exception) is worth the small amount of upfront design effort: it serves both 'I don't care about the details' callers and 'I need to handle this one specific case' callers from the same source of truth.
try:
process_payment(order)
except PaymentError as e: # catches ALL THREE specific types
log.error(f"Payment failed: {e}")
show_generic_payment_error_to_user()except InsufficientFundsError ā narrow, same hierarchy
3Ordering Rules and Extending the Hierarchy Over Time
Python checks except clauses strictly in the order they're written, top to bottom, and executes the *first* one that matches (via isinstance) ā this is why except InsufficientFundsError: must appear before except PaymentError: in the same try block: if the base class's clause were listed first, it would match InsufficientFundsError instances too (since they genuinely are PaymentError instances), and the more specific clause below it would become unreachable dead code, silently never executing. This ordering requirement ā most specific first, most general last ā is a common, easy-to-miss source of subtly broken exception handling, and worth checking deliberately whenever a try block has multiple except clauses from the same hierarchy.
A well-designed hierarchy also anticipates *growth*: adding a new specific exception type later (class RefundFailedError(PaymentError): pass) is purely additive ā any existing calling code with except PaymentError: automatically starts catching the new type too, with zero changes required, exactly the Open/Closed Principle applied to error handling specifically. This is a genuine, practical advantage over relying on generic built-in exceptions for everything: there's no shared base to extend, so adding a new distinguishable failure mode later would require either a breaking change or accepting that it can't be caught alongside the others without individually updating every call site.
For larger applications, hierarchies can meaningfully nest more than two levels deep ā a PaymentError base, with a GatewayError(PaymentError) intermediate category for anything related to the external payment gateway specifically, further specialized into PaymentGatewayTimeoutError(GatewayError) and PaymentGatewayAuthError(GatewayError) ā letting callers catch at whichever of the three levels of granularity actually matches their needs.
try:
process_payment(order)
except InsufficientFundsError as e: # handled FIRST, specifically
offer_installment_plan(e.shortfall)
except PaymentError as e: # catches every OTHER PaymentError
log.error(f"Payment failed: {e}")
show_generic_payment_error_to_user()Otherwise the general clause shadows the specific one entirely
4Step-by-Step Breakdown
A single custom exception solves one problem. An exception HIERARCHY, designed deliberately, lets calling code choose exactly how broadly or narrowly to catch failures ā let's design one.
Design a single base exception for your whole library/module, with every specific exception inheriting from it -- this enables catching broadly OR narrowly.
Catching the BASE class catches every specific subclass too -- useful when the caller just needs 'did payment processing fail at all', without caring exactly how.
Checkpoint: If InsufficientFundsError, CardDeclinedError, and PaymentGatewayTimeoutError all inherit from PaymentError, does except PaymentError catch all three?
- āYes ā catching a base exception class catches every subclass that inherits from it
- āNo ā each subclass must be caught with its own separate except clause
Catching a SPECIFIC subclass first, before the general base class, lets you handle one case precisely while still catching everything else broadly.
Checkpoint: In the second example, why must except InsufficientFundsError come BEFORE except PaymentError?
- āexcept clauses are checked in order, and the first matching one wins ā PaymentError would match everything first if listed earlier
- āThe order doesn't matter ā Python automatically picks the most specific matching clause
With a coherent hierarchy in place, Logging Best Practices covers exactly what to do with an exception once you've caught it.
Catch a Real Base Exception. Finish try_payment(): catching the base class also catches every subclass.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Establish one base exception class per library or application module, from day one
Every specific exception should inherit from it, enabling both broad ("any failure from this module") and narrow (specific failure type) catching from the same coherent hierarchy, with new exception types added purely additively later.
Always order except clauses from most specific to most general
Python matches the FIRST except clause that applies ā listing a base class before its own subclass makes the subclass's specific handler unreachable dead code.
Frequent Bugs
Listing a general except BaseExceptionClass: clause before a more specific except SubclassException: clause in the same try block, silently making the specific handler unreachable.
Always order except clauses from most specific to most general ā verify this ordering explicitly whenever a try block catches multiple exceptions from the same inheritance hierarchy.
Real-World Examples
A Layered Exception Hierarchy for a Data Pipeline Library
A data pipeline library needs callers to be able to catch 'any pipeline failure' broadly for top-level error reporting, while also allowing precise handling of specific, recoverable failure types like a transient network timeout.
class PipelineError(Exception):
"""Base for all pipeline failures."""
class DataSourceError(PipelineError):
"""Base for failures reading from a data source."""
class TransientSourceError(DataSourceError):
"""A source failure likely to succeed if retried."""
class PermanentSourceError(DataSourceError):
"""A source failure that will not succeed on retry."""
try:
run_pipeline()
except TransientSourceError:
retry_with_backoff()
except PipelineError as e:
log.error(f"Pipeline failed: {e}")
alert_oncall()