Use raise...from to chain exceptions and preserve the full traceback story.
Production pitfall: forgetting super().__init__(message) leaves the error message blank.
Test by asserting on exception type and attributes, not on message strings.
✦ Definition~90s read
What is Custom Exceptions in Python?
Custom exceptions in Python are user-defined subclasses of the built-in Exception class that let you signal and handle domain-specific error conditions in your code. They exist because generic exceptions like ValueError or RuntimeError lose semantic meaning — when a payment fails, you want PaymentDeclinedError, not a generic Exception with a string message.
★
Imagine you work at a bank.
The problem this article addresses is that many developers create custom exceptions incorrectly, causing tracebacks to vanish or become useless: they inherit from BaseException (which catches system-exit events), they override __init__ without calling super(), or they chain exceptions with bare raise instead of raise ... from, which discards the original traceback. In a production system handling thousands of requests per second, a vanished traceback means hours of debugging lost.
In the Python ecosystem, custom exceptions sit between built-in exceptions and logging frameworks. You use them when you need to distinguish error types programmatically — for example, in an API gateway, you might have RateLimitExceeded, AuthenticationFailed, and ValidationError instead of a single HTTPError.
You should NOT use custom exceptions when a built-in exception already captures the meaning (use KeyError for missing dict keys, not MissingKeyError), or when you're writing a quick script that won't be maintained. Real-world projects like Django, SQLAlchemy, and Requests all define rich exception hierarchies — Django alone has over 30 custom exception classes in django.core.exceptions.
The key insight is that a well-designed custom exception carries structured data (error codes, HTTP statuses, field names) that downstream handlers can inspect, rather than forcing consumers to parse error messages with regex.
Plain-English First
Imagine you work at a bank. When something goes wrong, a good teller doesn't just say 'ERROR' — they say 'Sorry, your account is frozen' or 'Insufficient funds for this transaction.' Custom exceptions are exactly that: instead of Python throwing a generic 'ValueError' or 'RuntimeError', you teach your code to throw a specific, named error that tells the next developer (or your future self) exactly what went wrong and why. It's the difference between a smoke alarm and a smoke alarm that says which room is on fire.
Every production Python codebase eventually hits the same wall: generic exceptions stop being helpful. You catch a ValueError deep in a payment processing flow, and you have no idea if it came from a bad card number, an expired date, or a negative charge amount. You start writing long if/else chains in your except blocks just to figure out what actually broke. That's a design smell, and custom exceptions are the cure.
Python's exception hierarchy is a class hierarchy — exceptions are just classes that inherit from BaseException. That single insight unlocks everything. You can create your own exception types that carry extra context, sit in a logical hierarchy, and communicate intent at a glance. When a DatabaseConnectionError bubbles up through your stack, no one needs to read five lines of error message to understand what happened.
By the end of this article you'll know how to define clean custom exception classes, build a domain-specific exception hierarchy for a real project, attach useful context to your exceptions, and avoid the three mistakes that trip up almost everyone the first time they try this. You'll also know exactly how to answer the custom exception questions that come up in Python interviews.
Why Your Custom Exceptions Vanish in Python
A custom exception in Python is a user-defined class that inherits from Exception (or one of its subclasses). The core mechanic is simple: you subclass Exception, optionally add attributes, and raise instances of your class. This gives you control over the exception type, message, and payload — but only if you understand how Python's exception hierarchy and traceback machinery actually work.
In practice, the critical property is that custom exceptions are just regular classes. They can carry arbitrary data (e.g., an error code, a failed record ID), and they participate in the MRO (method resolution order). A common mistake is inheriting from BaseException instead of Exception — that makes your exception catchable by bare except: but also by KeyboardInterrupt handlers, which is almost never what you want. Another pitfall: forgetting to call super().__init__() can silently drop your message from the traceback.
Use custom exceptions when you need to distinguish error types in a try/except block — for example, a ValidationError vs. a DatabaseTimeoutError. They matter in real systems because they let you attach structured context (like a user ID or a request ID) directly to the exception, so your error handling and logging can act on it without parsing strings. Without them, you're stuck with generic Exception and string matching — fragile and opaque.
Inheritance Trap
Inheriting from BaseException instead of Exception makes your custom exception catchable by bare except: — but also by KeyboardInterrupt handlers, which is almost never what you want.
Production Insight
A team defined a custom exception inheriting from BaseException to avoid catching it accidentally — but then a KeyboardInterrupt during a deploy was silently swallowed, leaving the process alive but unresponsive.
The symptom: the process didn't die on Ctrl+C, and health checks timed out because the event loop was stuck.
Rule of thumb: always inherit from Exception, never from BaseException, unless you have a very specific reason to be catchable by bare except:.
Key Takeaway
Custom exceptions must inherit from Exception, not BaseException, to avoid swallowing system signals.
Always call super().__init__() in your custom exception's __init__ to preserve the message in the traceback.
Use custom exceptions to carry structured context (e.g., error codes, record IDs) — never rely on string parsing in except blocks.
thecodeforge.io
Custom Exceptions in Python: Traceback Preservation
Custom Exceptions Python
Why Inheriting From Exception (Not BaseException) Is the Right Starting Point
Python's exception tree has two main branches rooted at BaseException. System-level signals like KeyboardInterrupt and SystemExit live on one branch — these are things the runtime needs to handle, not your application. The Exception class is the root of everything your application code should throw and catch.
When you define a custom exception, you almost always inherit from Exception or one of its subclasses. Inheriting from BaseException directly means your exception would survive a bare except clause that's meant to catch only application errors, and it could accidentally suppress keyboard interrupts. That's a nasty, hard-to-debug bug.
Inheriting from a more specific built-in — like ValueError or TypeError — is even better when the semantics fit. If your custom error truly represents 'the value was wrong', subclass ValueError. That way, callers who catch ValueError will automatically catch yours too, which is the correct behaviour in most library code. But for domain errors that don't map to a built-in concept (think InsufficientFundsError or UserNotAuthorisedError), a direct Exception subclass is the cleaner choice.
The rule of thumb: be as specific as possible in the hierarchy, and prefer Exception over BaseException unless you have a very deliberate reason.
exception_hierarchy_basics.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# ── exception_hierarchy_basics.py ──────────────────────────────────────────# Demonstrates: correct base classes and why it matters at catch-time# ❌ Inheriting from BaseException — almost never do this for app errorsclassBadBaseException(BaseException):
pass# ✅ Inheriting from Exception — the right default for application errorsclassAppError(Exception):
"""Base class for all errors raised by this application."""pass# ✅ Inheriting from a built-in subclass when semantics matchclassNegativeAmountError(ValueError):
"""Raised when a monetary amount is negative where only positive is valid."""passdefprocess_payment(amount: float) -> str:
if amount < 0:
# We raise NegativeAmountError, but the except block below catches ValueErrorraiseNegativeAmountError(f"Payment amount cannot be negative. Got: {amount}")
return f"Payment of £{amount:.2f} processed successfully."# --- Scenario 1: catching the specific type ---try:
process_payment(-50.0)
exceptNegativeAmountErroras error:
print(f"[Specific catch] {error}")
# --- Scenario 2: catching the PARENT type (ValueError) still works ---# This is why subclassing ValueError is useful in library code:# callers don't need to know about YOUR custom type to handle it sensibly.try:
process_payment(-50.0)
exceptValueErroras error:
print(f"[Parent catch] {error}")
# --- Scenario 3: proving BadBaseException escapes a normal except block ---try:
raiseBadBaseException("I escape normal except clauses!")
exceptException:
# BaseException subclasses are NOT caught hereprint("This line will NOT print.")
exceptBadBaseExceptionas error:
print(f"[BaseException catch] Had to catch it explicitly: {error}")
Output
[Specific catch] Payment amount cannot be negative. Got: -50.0
[Parent catch] Payment amount cannot be negative. Got: -50.0
[BaseException catch] Had to catch it explicitly: I escape normal except clauses!
Watch Out:
Never inherit from BaseException for application errors. If you do, a bare 'except Exception' block — which is how most framework error handlers work — will silently miss your exception, and it'll propagate all the way to the top of your stack as an unhandled crash.
Production Insight
A production app crashed silently because a custom exception inherited from BaseException was never caught by Django's global handler.
The on-call engineer found it only after checking uWSGI error logs.
Rule: always inherit from Exception unless you're writing system signals like KeyboardInterrupt.
Key Takeaway
Inherit from Exception for all application exceptions.
BaseException subclasses escape normal error handlers.
Never inherit from BaseException unless you're writing system signals.
Adding Context to Custom Exceptions So They Actually Tell You Something
A custom exception class with no attributes is better than a generic one, but it still forces you to pack all your context into a string message. That means the calling code has to parse a string to understand what went wrong — and string parsing is fragile.
The better pattern is to treat your exception like a small data class. Override __init__ to accept structured fields, store them as attributes, and build the human-readable message from them. Now the code that catches the exception can branch on exception.status_code or exception.user_id without touching a string.
This also pays dividends in logging. When your exception carries structured data, your logger can record machine-readable fields alongside the message. That makes it searchable in tools like Datadog or Splunk — you can query for all InsufficientFundsError events where amount_requested > 10000 rather than running regex over log lines.
Don't forget __str__ and optionally __repr__. Python calls __str__ when the exception is printed or logged as a string. If you've overridden __init__ and don't set args correctly, the default string representation will be empty, which makes debugging a nightmare. The safest approach: always call super().__init__(message) with your constructed message string.
context_rich_exceptions.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# ── context_rich_exceptions.py ─────────────────────────────────────────────# Demonstrates: exceptions as data carriers, not just message stringsfrom datetime import datetime
classInsufficientFundsError(Exception):
"""
Raised when a withdrawal or payment exceeds the available account balance.
Attributes:
account_id -- the account that triggered the error
amount_requested -- the amount the caller tried to move
available_balance -- what was actually in the account
timestamp -- when this error occurred (useful for audit logs)
"""
def__init__(
self,
account_id: str,
amount_requested: float,
available_balance: float,
) -> None:
self.account_id = account_id
self.amount_requested = amount_requested
self.available_balance = available_balance
self.shortfall = amount_requested - available_balance
self.timestamp = datetime.utcnow()
# Build the human-readable message ONCE and pass it to the parent.# This ensures str(exception) and repr(exception) both work correctly.
message = (
f"Account '{account_id}' has insufficient funds. "
f"Requested: £{amount_requested:.2f}, "
f"Available: £{available_balance:.2f}, "
f"Shortfall: £{self.shortfall:.2f}"
)
super().__init__(message) # ← critical: always call super().__init__defwithdraw(account_id: str, amount: float, balance: float) -> float:
"""Simulate a withdrawal. Returns new balance on success."""if amount > balance:
raiseInsufficientFundsError(
account_id=account_id,
amount_requested=amount,
available_balance=balance,
)
return balance - amount
# --- Happy path ---
new_balance = withdraw(account_id="ACC-001", amount=200.0, balance=500.0)
print(f"Withdrawal successful. New balance: £{new_balance:.2f}")
# --- Error path: catching and inspecting structured attributes ---try:
withdraw(account_id="ACC-002", amount=1500.0, balance=300.0)
exceptInsufficientFundsErroras error:
# We can branch on the data, not on parsing a stringprint(f"\nCaught: {error}") # uses __str__ via super().__init__print(f"Account : {error.account_id}")
print(f"Requested: £{error.amount_requested:.2f}")
print(f"Available: £{error.available_balance:.2f}")
print(f"Shortfall: £{error.shortfall:.2f}")
print(f"At : {error.timestamp.strftime('%Y-%m-%d %H:%M:%S')} UTC")
# Real-world: trigger a different UX flow depending on shortfall sizeif error.shortfall < 50:
print("Suggestion: You're just short — consider a small top-up.")
else:
print("Suggestion: Please review your account balance before retrying.")
Suggestion: Please review your account balance before retrying.
Pro Tip:
Always call super().__init__(message) with a fully-formed message string. Skip it and str(your_exception) returns an empty string — which means your logs will show 'InsufficientFundsError: ' with nothing after the colon, and you'll waste an hour wondering why.
Production Insight
A team spent two hours debugging a payment failure because the exception had no structured attributes — they had to grep log lines for 'ACC-002' in a message string.
After switching to structured fields, they could query every InsufficientFundsError with shortfall > 1000 in seconds.
Rule: always store context as accessible attributes, not just in the message.
Key Takeaway
Store structured data as exception attributes.
Build the message once and pass it to super().__init__().
Structured logs are searchable; string-only logs are not.
Building a Domain Exception Hierarchy for a Real Project
In a real codebase you don't just have one custom exception — you have a family of them. The smartest architecture defines a single base exception for your entire application or module, then branches from there. This gives callers maximum flexibility: they can catch everything from your module with one except clause, or pinpoint a specific error type when they need to.
Think about an e-commerce backend. At the top you might have ECommerceError. Below that: PaymentError, InventoryError, AuthenticationError. Below PaymentError: CardDeclinedError, InsufficientFundsError, FraudDetectedError. This mirrors how you'd talk about the domain in a meeting, which means new developers understand the code structure immediately.
Another huge win: middleware and framework error handlers can catch your top-level base exception and render a consistent error response, without knowing anything about the specific subclasses. A FastAPI exception handler that catches ECommerceError can return a structured JSON error to the client, while individual route functions handle the specific subtypes for business logic.
Keep your exception hierarchy in a dedicated exceptions.py file at the module root. Import from there everywhere. This single-source approach prevents circular imports and makes the hierarchy easy to document.
domain_exception_hierarchy.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# ── domain_exception_hierarchy.py ──────────────────────────────────────────# Demonstrates: a real-world exception hierarchy for an e-commerce module# In a real project this lives in src/ecommerce/exceptions.py# ── LAYER 1: Application root ───────────────────────────────────────────────classECommerceError(Exception):
"""Root exception for the entire e-commerce module.
Catch this in middleware/framework handlers to guarantee a
structured error response forANY problem from this domain.
"""
pass# ── LAYER 2: Domain groupings ────────────────────────────────────────────────classPaymentError(ECommerceError):
"""All payment-related failures."""passclassInventoryError(ECommerceError):
"""All stock/inventory-related failures."""passclassAuthorisationError(ECommerceError):
"""All permission/authentication failures."""pass# ── LAYER 3: Specific errors with rich context ───────────────────────────────classCardDeclinedError(PaymentError):
"""Raised when a payment gateway declines a card."""def__init__(self, card_last_four: str, decline_code: str) -> None:
self.card_last_four = card_last_four
self.decline_code = decline_code
super().__init__(
f"Card ending {card_last_four} was declined (code: {decline_code})"
)
classOutOfStockError(InventoryError):
"""Raised when a product cannot fulfil the requested quantity."""def__init__(self, product_sku: str, requested: int, available: int) -> None:
self.product_sku = product_sku
self.requested = requested
self.available = available
super().__init__(
f"SKU '{product_sku}': requested {requested}, only {available} in stock."
)
classInsufficientPermissionsError(AuthorisationError):
"""Raised when a user attempts an action they're not allowed to perform."""def__init__(self, user_id: str, required_role: str) -> None:
self.user_id = user_id
self.required_role = required_role
super().__init__(
f"User '{user_id}' lacks required role '{required_role}'."
)
# ── Simulated service functions ───────────────────────────────────────────────defcharge_card(card_last_four: str, amount: float) -> None:
"""Simulate a payment gateway call."""# In real life: call Stripe/Braintree SDK here
if card_last_four == "0000": # simulate a declined card for demoraiseCardDeclinedError(card_last_four=card_last_four, decline_code="insufficient_funds")
defreserve_stock(product_sku: str, quantity: int) -> None:
"""Simulate an inventory reservation."""
stock_levels = {"WIDGET-42": 3, "GADGET-7": 100}
available = stock_levels.get(product_sku, 0)
if quantity > available:
raiseOutOfStockError(product_sku=product_sku, requested=quantity, available=available)
defadmin_action(user_id: str) -> None:
"""Simulate an action that requires admin privileges."""
non_admin_users = {"user-123", "user-456"}
if user_id in non_admin_users:
raiseInsufficientPermissionsError(user_id=user_id, required_role="admin")
# ── Demonstrate catching at different levels of the hierarchy ─────────────────
test_cases = [
lambda: charge_card("0000", 99.99),
lambda: reserve_stock("WIDGET-42", 10),
lambda: admin_action("user-123"),
]
for test in test_cases:
try:
test()
exceptCardDeclinedErroras error:
# Specific handler: offer the user a chance to retry with another cardprint(f"[CardDeclined] {error} — prompt user for alternative card")
exceptInventoryErroras error:
# Catches OutOfStockError AND any future InventoryError subclassesprint(f"[InventoryError] {error} — notify warehouse team")
exceptECommerceErroras error:
# Safety net: catches everything else from this moduleprint(f"[ECommerceError] {error} — log and return 400 to client")
Output
[CardDeclined] Card ending 0000 was declined (code: insufficient_funds) — prompt user for alternative card
[InventoryError] SKU 'WIDGET-42': requested 10, only 3 in stock. — notify warehouse team
[ECommerceError] User 'user-123' lacks required role 'admin'. — log and return 400 to client
Interview Gold:
Interviewers love asking 'how would you structure exceptions for a large module?' The answer is always: one root base exception per module/package, domain groupings in layer 2, specific context-rich exceptions in layer 3. Name the hierarchy in your answer and you'll stand out immediately.
Production Insight
A team caught ECommerceError at the API gateway and returned a generic 500, hiding the fact that a CardDeclinedError needed a different client response.
They couldn't differentiate specific errors because they caught only the root type.
Fix: catch specific types first, then fall back to the base exception after logging.
Key Takeaway
Design a 3-layer hierarchy: root, domain, specific.
Catch specific errors first, then base errors as safety net.
A consistent hierarchy makes error handling predictable.
Exception Chaining: Using 'raise ... from' to Preserve the Full Story
Here's a scenario you'll hit constantly in production: you call a low-level library (database driver, HTTP client), it raises its own exception, and you want to wrap it in your domain exception — but you don't want to lose the original traceback. That original traceback is gold when you're debugging at 2am.
Python's raise ... from syntax is built for exactly this. When you write raise MyError('something went wrong') from original_exception, Python chains the two exceptions together. The full traceback shows both the root cause and the point where it was re-raised as your domain exception. Users of your library see your clean domain exception; you and your on-call engineer see the full story.
The alternative — catching and re-raising without from — loses the original exception context in Python 3 (though Python 3 does attach it implicitly in some cases via __context__). Being explicit with from is clearer and is considered the professional pattern.
If you deliberately want to suppress the original exception from the traceback (for security reasons, for example — you don't want a database error leaking internal table names to a client), use raise MyError('sanitised message') from None. That completely hides the original cause.
exception_chaining.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# ── exception_chaining.py ──────────────────────────────────────────────────# Demonstrates: raise...from to preserve root cause during exception translationimport sqlite3
classDatabaseUnavailableError(Exception):
"""Raised when the application cannot reach or query the database."""def__init__(self, operation: str, original: Exception) -> None:
self.operation = operation
super().__init__(
f"Database operation '{operation}' failed. See cause above for details."
)
classUserRepository:
"""Thin data-access layer that translates low-level DB errors into domain errors."""def__init__(self, db_path: str) -> None:
# Using an intentionally invalid path to simulate a connection failureself.db_path = db_path
deffind_user(self, user_id: int) -> dict:
try:
# sqlite3 will raise OperationalError if the DB doesn't exist/is locked
connection = sqlite3.connect(self.db_path)
cursor = connection.cursor()
cursor.execute("SELECT id, name FROM users WHERE id = ?", (user_id,))
row = cursor.fetchone()
if row isNone:
return {}
return {"id": row[0], "name": row[1]}
except sqlite3.OperationalErroras db_error:
# Translate the low-level driver error into our domain exception.# The 'from db_error' chain preserves the original traceback.raiseDatabaseUnavailableError(
operation="find_user",
original=db_error,
) from db_error # ← this is the key line# --- Example 1: raise...from shows BOTH tracebacks ---
repo = UserRepository(db_path="/nonexistent/path/app.db")
try:
repo.find_user(user_id=42)
exceptDatabaseUnavailableErroras error:
print(f"Domain error caught: {error}")
# In a real app you'd log the full traceback with logging.exception()# which automatically includes the chained causeprint(f"Root cause type : {type(error.__cause__).__name__}")
print(f"Root cause msg : {error.__cause__}")
print()
# --- Example 2: raise...from None hides sensitive internals ---classUserFetchError(Exception):
"""Sanitised error safe to return to an API client."""passtry:
try:
# Simulate an internal error with sensitive detailsraise sqlite3.OperationalError("no such table: internal_user_pii")
except sqlite3.OperationalError:
# We don't want 'internal_user_pii' leaking to the clientraiseUserFetchError("Unable to retrieve user. Please try again.") fromNoneexceptUserFetchErroras error:
print(f"Safe client error : {error}")
print(f"Hidden cause : {error.__cause__}") # None — original is suppressed
Output
Domain error caught: Database operation 'find_user' failed. See cause above for details.
Use 'raise YourError(...) from original_error' when wrapping third-party exceptions — it preserves the full debugging story. Use 'from None' only when the original exception contains sensitive data (table names, file paths, internal IDs) that shouldn't reach logs the client might see.
Production Insight
When a database driver error was re-raised without 'from', the original traceback was lost. On-call engineers had no way to see the actual SQL failure.
Fix: Always use raise DomainError('msg') from db_error.
Rule: Never discard the original exception unless you have a security reason.
Key Takeaway
Use raise...from to chain exceptions.
The original exception lives in __cause__.
Use from None only to hide sensitive internals.
Testing Custom Exceptions: Assert on Attributes, Not Messages
After building a rich custom exception with structured data, you need to test that the right exception is raised with the right attributes. The obvious approach is to assert on the string message: assert str(error) == 'some message'. But that's brittle. The message is a presentation detail that may change with locale, formatting tweaks, or refactoring. When it changes, your tests break even though the semantics are identical.
The robust pattern is to use pytest.raises and inspect the exception object directly. Check its type with isinstance or type, and assert on its attributes. This tests the contract (what data is passed) rather than the presentation (how it's formatted). This way, you can change the message format without touching your tests.
Additionally, test that the exception is properly picklable if you use it in multiprocessing or distributed systems. Custom exceptions that override __init__ without setting the args tuple correctly can break pickling. To be safe, ensure your __init__ passes a tuple to super().__init__(*args) or a single message string (which Python wraps into args).
test_custom_exceptions.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# ── test_custom_exceptions.py ─────────────────────────────────────────────# Demonstrates: testing custom exceptions by asserting on attributes, not messagesimport pytest
from context_rich_exceptions importInsufficientFundsError, withdraw
deftest_insufficient_funds_attributes():
"""Test that InsufficientFundsError carries the correct structured data."""with pytest.raises(InsufficientFundsError) as exc_info:
withdraw(account_id="ACC-001", amount=500.0, balance=200.0)
error = exc_info.value
assert error.account_id == "ACC-001"assert error.amount_requested == 500.0assert error.available_balance == 200.0assert error.shortfall == 300.0# Do NOT assert on error.message or str(error) — that's presentationdeftest_insufficient_funds_type_is_specific():
"""Test that the raised exception is exactly InsufficientFundsError, not a parent."""with pytest.raises(InsufficientFundsError):
withdraw(account_id="ACC-002", amount=100.0, balance=50.0)
deftest_withdrawal_success_returns_balance():
"""Happy path: no exception raised, correct returned value."""
new_balance = withdraw(account_id="ACC-003", amount=100.0, balance=500.0)
assert new_balance == 400.0deftest_exception_picklable():
"""Ensure the exception can be serialized (useful for multiprocessing)."""import pickle
try:
raiseInsufficientFundsError("ACC-004", 200.0, 150.0)
exceptInsufficientFundsErroras e:
pickled = pickle.dumps(e)
unpickled = pickle.loads(pickled)
assert unpickled.account_id == "ACC-004"assert unpickled.shortfall == 50.0
Output
All tests pass.
$ pytest test_custom_exceptions.py -v
============================= test session starts ==============================
============================== 4 passed in 0.12s ===============================
Pro Tip:
Never assert on exception message strings in tests. They change, they break, and they don't test the actual contract. Assert on the exception type and its named attributes instead.
Production Insight
A refactor changed a message format from 'Account {id} has insufficient funds' to 'Insufficient funds for account {id}'. Tests that asserted on the message string all broke at once.
The fix was to switch to attribute assertions, which ignored the message format change.
Rule: Test the data, not the wording.
Key Takeaway
Test exception type and attributes, not message strings.
Messages change; types and fields are contract.
Write tests that survive message refactoring.
Why Define Custom Exceptions? (Because Built-Ins Are Not a Taxonomy)
Built-in exceptions like ValueError or RuntimeError are generic. They tell you what went wrong — not where or why in your domain. When a payment fails, raising a plain Exception forces every handler to parse a string to decide the next action. That’s error handling by regex, and it’s brittle.
Custom exceptions let you encode business logic directly into the exception type. A PaymentDeclinedError carries different remediation steps than InsufficientFundsError or FraudDetectionError. Downstream handlers use isinstance() to branch behavior without touching a single error message. This turns error handling from a guessing game into a type switch.
You also get grep-ability. Search your codebase for class PaymentDeclinedError and you instantly find every place the payment flow can fail. Try that with a string "declined" buried in an exception message.
PaymentGateway.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// io.thecodeforge — python tutorial
classPaymentError(Exception):
"""Base for all payment failures in the gateway."""classPaymentDeclinedError(PaymentError):
"""Card issuer declined the transaction."""classInsufficientFundsError(PaymentError):
"""Balance too low for the requested amount."""classFraudDetectionError(PaymentError):
"""Payment flagged by the fraud engine."""defprocess_payment(amount: float, account_id: str) -> bool:
# Simulate gateway logicif amount > 10000.0:
raiseFraudDetectionError(amount, account_id)
if amount > get_balance(account_id):
raiseInsufficientFundsError(amount, get_balance(account_id))
ifrandom_issuer_decline():
raisePaymentDeclinedError(account_id)
returnTruetry:
process_payment(15000.0, "acct_8472")
exceptFraudDetectionError:
trigger_manual_review(account_id)
exceptInsufficientFundsError:
suggest_lower_amount()
Output
No output — exception is raised and caught by the matching except block.
Production Trap:
Never inherit from BaseException directly. That includes KeyboardInterrupt and SystemExit. If you catch BaseException in a handler, you will swallow the shutdown signal and leave processes hanging in production.
Key Takeaway
Model your exception hierarchy after your business domain — not the Python's built-in taxonomy.
Raising and Handling Custom Exceptions (Don’t Catch What You Can’t Handle)
Raising a custom exception is trivial:raise MyCustomError("message"). The real skill is knowing where to catch it. The golden rule: catch exceptions at the layer that has enough context to handle them — not earlier, not later.
If you catch a DatabaseConnectionError inside the repository layer, what can you do there? Retry? Log? Close and open a new pool? That logic belongs in the service layer or the orchestrator that understands retry windows and backoff policies. Catching too early forces a cascade of re-raises or, worse, swallowing.
When you do catch, be specific. Never write except Exception: unless you are logging and re-raising. A bare except masks every bug — import errors, type errors, and your custom exceptions all collapse into a single silent handler. Catch the exact exception type or its parent hierarchy so that unrelated failures bubble up to the top-level handler.
InventoryService.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge — python tutorial
classInventoryError(Exception):
"""Base inventory failure."""classOutOfStockError(InventoryError):
"""Item quantity is zero."""classAllocationError(InventoryError):
"""Cannot reserve items due to conflict."""defallocate_item(order_id: str, sku: str, qty: int) -> None:
if qty > get_stock(sku):
raiseOutOfStockError(f"SKU {sku}: requested {qty}, available {get_stock(sku)}")
# allocation logic...deffulfill_order(order_id: str) -> bool:
try:
for item inget_order_items(order_id):
allocate_item(order_id, item['sku'], item['qty'])
returnTrueexceptOutOfStockErroras e:
notify_customer_refund(order_id, str(e))
returnFalseexceptAllocationErroras e:
trigger_reconciliation(order_id, str(e))
raise # re-raise, orchestrator must decide order-level impact
Output
If OutOfStockError is raised, customer gets refund notification.
If AllocationError is raised, reconciliation is triggered and exception re-raised for the top-level handler.
Senior Shortcut:
Use except SomeError as e: then raise (bare) to re-raise the same exception with its original traceback intact. Never write raise e — that erases the stack and makes debugging useless.
Key Takeaway
Catch exceptions at the boundary where you can take meaningful action — retry, compensate, or fail gracefully. Anything else is noise.
8.7-8.8. Defining and Predefined Clean-up Actions: Guaranteeing Finality
When an exception is raised, resources like file handles or locks may be left dangling. Python provides two canonical ways to define clean-up actions that execute regardless of how a block is exited: the try...finally statement and the with statement context manager. finally blocks are guaranteed to run after the try block, even if an exception propagates upward. For predefined clean-up, Python’s with statement automatically calls the __exit__ method on context managers (like open(), Lock.acquire()) to release external state. This ensures that custom exceptions don't leave resources in an inconsistent state—a silent failure that corrupts data. When designing custom exceptions, pair them with clean-up patterns: wrap risky code in try...finally to close connections, or define your own context manager via __enter__ and __exit__ to encapsulate cleanup logic. This prevents resource leaks that would otherwise mask your exception's root cause. Always prefer with over explicit finally for built-in types.
Never rely on __del__ to clean up exceptions; it's non-deterministic. Use finally or with for immediate, predictable resource release.
Key Takeaway
Pair every resource acquisition with either a finally block or a with statement to prevent resource leaks when custom exceptions are raised.
8.10. Enriching Exceptions with Notes: Attach Live Debug Info
Standard exception messages are static—they capture the state at the moment of the exception, but debugging often requires contextual data that accumulates as the error propagates. Python 3.11 introduced BaseException.add_note(), allowing you to append structured, human-readable notes to an exception object without altering its original traceback. This is invaluable for custom exceptions in large codebases: you can enrich a ValidationError with the exact field values that failed, or add processing steps that occurred before a PaymentFailedError. Notes appear in the traceback under "Exception notes:", so they remain visible during logging. The pattern is simple: catch a custom exception, call exc.add_note("context"), and re-raise it (or let it propagate). This keeps your exception hierarchy clean while supplying the forensic details needed for debugging. Do not use notes for sensitive data like passwords—they may appear in logs. Instead, use them for transaction IDs, field names, or partial payloads.
// Output in traceback: Exception notes: order_id=42, pricing_version=2.1
Production Trap:
Avoid adding personal identifiable information (PII) in notes—they're logged in plain text. Use correlation IDs instead of raw user data.
Key Takeaway
Use add_note() to attach transient debugging context to custom exceptions without breaking encapsulation or hierarchy.
● Production incidentPOST-MORTEMseverity: high
The Lost Traceback Incident: A 2-Hour Debug That One 'from' Would Have Prevented
Symptom
In production, a payment processing endpoint returned HTTP 500 with 'Internal server error: PaymentTimeoutError'. The original exception from the Stripe SDK was completely invisible in logs.
Assumption
The developer assumed that raising a custom exception automatically preserved the original traceback. They didn't know about exception chaining.
Root cause
The code was raise PaymentTimeoutError("Stripe timeout") instead of raise PaymentTimeoutError(...) from stripe_error. The original StripeException was lost because __cause__ was never set.
Fix
Changed the raise to: raise PaymentTimeoutError("Stripe timeout for customer {customer_id}") from original_stripe_error. Also added logging.exception() in the catch block.
Key lesson
Always use raise YourCustomError(...) from original_exception when translating underlying library errors.
Never assume Python auto-preserves the original traceback — be explicit with from.
Train your team to spot missing from in code reviews — it's a common oversight.
Production debug guideSymptom → Action quick reference for the most common custom exception problems3 entries
Symptom · 01
Custom exception message is empty in logs (shows just the type name with nothing after colon)
→
Fix
Check the __init__ method: it must call super().__init__(message) with a fully-formed message string. If you override __init__ and don't pass the message to the parent, str(exception) returns empty.
Symptom · 02
Custom exception is not caught by the framework's global error handler (e.g., FastAPI, Django, Flask return 500 with no domain context)
→
Fix
Verify that the custom exception inherits from Exception, not BaseException. Framework handlers typically catch Exception. BaseException subclasses escape and crash unhandled.
Symptom · 03
The original traceback (e.g., from a DB driver) is missing when a custom exception is raised in an except block
→
Fix
Look for raise statements without from. Change to raise YourError(...) from original_exception. The original is stored in __cause__ and included in traceback output.
★ Quick Cheat Sheet: Custom Exception DebuggingFast commands to diagnose and fix custom exception issues in production.
exception.__cause__ is None when you expected a chained exception−
Immediate action
Inspect the raise statement that created the exception.
Commands
import traceback; traceback.print_exc()
logging.exception('Failed to process payment')
Fix now
Add from original_exc to the raise line: raise MyError(...) from original_exc
Custom exception shows no message or attributes in the log+
Immediate action
Check if __init__ now passes message to super().__init__().
Commands
try: raise MyError(account_id='xyz', amount=100) except MyError as e: print(str(e), e.account_id)
Add super().__init__(message) at the end of your __init__ method
A broad except Exception clause is catching your custom exception but you wanted it to propagate+
Immediate action
Check the exception's inheritance — does it inherit from Exception? If it inherits from BaseException, it bypasses except Exception.
Commands
print(issubclass(MyError, BaseException))
print(issubclass(MyError, Exception))
Fix now
Change the base class from BaseException to Exception or a more specific subclass.
Aspect
Generic Built-in Exception
Custom Domain Exception
Readability at catch site
except ValueError — could mean anything
except InsufficientFundsError — self-documenting
Structured data access
Must parse error.args[0] string
Access error.account_id, error.shortfall directly
Hierarchy / grouping
Fixed Python hierarchy
You design it to match your domain
Exception chaining
Supported but no domain context
Translate AND chain for full story
Logging quality
Generic message, hard to query
Structured fields, machine-searchable
Library consumer UX
Caller must know your internals
Caller catches your root base exception safely
Test assertions
Assert message string content (fragile)
Assert exception type and attribute values (robust)
Key takeaways
1
Inherit from Exception (not BaseException) for all application exceptions
BaseException subclasses escape framework error handlers and cause silent crashes
2
Treat custom exceptions like small data classes
store structured fields as attributes and build the human-readable message once inside __init__, always passing it to super().__init__()
3
Design a three-layer hierarchy
one root base exception per module, domain groupings in layer 2, specific context-rich types in layer 3 — so callers can catch as broadly or narrowly as they need
4
Use 'raise DomainError(...) from original_error' to translate third-party exceptions without losing the root cause, and 'from None' only when the original exception contains sensitive information you must hide
Common mistakes to avoid
3 patterns
×
Not calling super().__init__(message) in __init__
Symptom
str(your_exception) returns an empty string, so logs show 'InsufficientFundsError:' with nothing after the colon, and traceback messages are blank.
Fix
Always pass your constructed message string as the first argument to super().__init__() at the end of your custom __init__.
×
Catching the base exception too early and swallowing specifics
Symptom
You catch ECommerceError at every level, so CardDeclinedError and OutOfStockError are never handled differently, leading to one-size-fits-all error responses.
Fix
Catch specific subtypes first (CardDeclinedError), then progressively broader types (PaymentError, ECommerceError) as safety nets. Python matches the first matching except clause.
×
Inheriting directly from BaseException instead of Exception
Symptom
Your custom exception escapes framework-level except Exception handlers (Django, FastAPI, Flask all use these), crashes propagate to the WSGI/ASGI layer as unhandled errors, and you get a 500 with no domain context.
Fix
Always inherit from Exception or a subclass of it unless you are deliberately writing a system-level signal (which you almost never are).
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Why would you create a custom exception hierarchy rather than just using...
Q02SENIOR
What is the difference between 'raise MyError() from original_error' and...
Q03SENIOR
If you define a custom exception class and override __init__ but forget ...
Q01 of 03SENIOR
Why would you create a custom exception hierarchy rather than just using built-in exceptions like ValueError or RuntimeError throughout your codebase?
ANSWER
Custom exceptions make the code self-documenting — when you see except InsufficientFundsError you know exactly what went wrong without reading the message. A custom hierarchy also allows different levels of catch granularity (catch all from a module, or just a specific subtype). It also supports structured data attributes, which generic exceptions don't encourage. Built-in exceptions are fine for low-level utilities, but domain errors need domain-specific types.
Q02 of 03SENIOR
What is the difference between 'raise MyError() from original_error' and 'raise MyError() from None', and when would you use each?
ANSWER
raise MyError() from original_error chains the original exception as the cause, so the traceback shows both the original error and the translation point. It's used when you translate a low-level exception into a domain exception and want to preserve debugging details. raise MyError() from None suppresses the cause, hiding the original exception from the traceback. This is used when the original exception contains sensitive information (e.g., SQL table names, file paths) that should not be exposed in logs visible to users or clients.
Q03 of 03SENIOR
If you define a custom exception class and override __init__ but forget to call super().__init__(), what breaks and why?
ANSWER
The exception's string representation (str(exception)) becomes empty because Python's BaseException.__str__ relies on the args attribute, which is set by super().__init__(message). Without it, args remains an empty tuple, and str() returns an empty string. This means logging statements that include the exception will show only the type name with no message. Additionally, accessing args[0] will raise an IndexError. The fix is to always call super().__init__(message) with the constructed message.
01
Why would you create a custom exception hierarchy rather than just using built-in exceptions like ValueError or RuntimeError throughout your codebase?
SENIOR
02
What is the difference between 'raise MyError() from original_error' and 'raise MyError() from None', and when would you use each?
SENIOR
03
If you define a custom exception class and override __init__ but forget to call super().__init__(), what breaks and why?
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
Should I always create a custom exception or can I just reuse ValueError and TypeError?
Reuse built-in exceptions when the semantics genuinely match and you're writing utility/library code — subclassing ValueError for invalid input is totally fine and lets callers use familiar catch patterns. Create custom exceptions when you're modelling domain concepts (payments, inventory, authorisation) that have no natural built-in equivalent, or when you need to carry structured data beyond a message string.
Was this helpful?
02
What is the minimum code needed for a useful custom exception in Python?
At its simplest: a class that inherits from Exception with a docstring explaining when it's raised. That's literally 'class MyError(Exception): pass' plus a docstring. You only need to override __init__ when you want to attach structured attributes. The docstring alone makes it dramatically more useful than a bare ValueError because the type name is self-documenting.
Was this helpful?
03
How do I access the original exception when using raise...from?
The original exception is stored on the __cause__ attribute of the new exception. So if you write 'raise DomainError() from db_error', you can later access caught_error.__cause__ to get the original db_error object, including its type and message. If Python implicitly chained exceptions (without from), the original is on __context__ instead.