Modules Data And Errors
Custom Exceptions & Debugging
Python's built-in exceptions (ValueError, TypeError, etc.) are generic. In real applications, you often want errors that describe your domain precisely — making
Jr Codex Python Notes
Level: Intermediate Prerequisites: Chapter 4 Time to complete: ~20 minutes
Table of Contents
- Why Create Custom Exceptions?
- Defining a Custom Exception
- Building an Exception Hierarchy
- Reading a Traceback
- Debugging with
print()— and Its Limits - The
pdbDebugger - Defensive Programming with
assert - Summary & Next Steps
1. Why Create Custom Exceptions?
Python's built-in exceptions (ValueError, TypeError, etc.) are generic. In real applications, you often want errors that describe your domain precisely — making them easier to catch selectively and easier to understand in logs.
# Generic — the caller has to guess what went wrong from the message alone
def withdraw(balance, amount):
if amount > balance:
raise ValueError("Insufficient funds")
# Custom — the exception TYPE itself carries meaning
class InsufficientFundsError(Exception):
pass
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(f"Cannot withdraw {amount}, balance is {balance}")2. Defining a Custom Exception
A custom exception is just a class that inherits from Exception (or one of its subclasses) — full class syntax is covered in Module 4, but this pattern is simple enough to use now.
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the available balance."""
pass
class InvalidAmountError(Exception):
"""Raised when an amount is zero or negative."""
pass
def withdraw(balance, amount):
if amount <= 0:
raise InvalidAmountError(f"Amount must be positive, got {amount}")
if amount > balance:
raise InsufficientFundsError(f"Cannot withdraw {amount}, balance is {balance}")
return balance - amount
try:
withdraw(100, 150)
except InsufficientFundsError as e:
print(f"Transaction blocked: {e}")
except InvalidAmountError as e:
print(f"Invalid input: {e}")Adding Custom Data to an Exception
class InsufficientFundsError(Exception):
def __init__(self, balance, requested):
self.balance = balance
self.requested = requested
self.shortfall = requested - balance
super().__init__(
f"Cannot withdraw {requested}: balance is {balance}, "
f"short by {self.shortfall}"
)
try:
raise InsufficientFundsError(balance=100, requested=150)
except InsufficientFundsError as e:
print(e) # the formatted message
print(e.shortfall) # 50 — extra structured data, not just a string!Custom exceptions become far more powerful once you know classes and __init__ (Module 4) — this preview shows why it's worth learning both together.
3. Building an Exception Hierarchy
Group related custom exceptions under a common base class, so callers can catch broadly or narrowly as needed:
class BankError(Exception):
"""Base class for all banking-related errors."""
pass
class InsufficientFundsError(BankError):
pass
class InvalidAmountError(BankError):
pass
class AccountFrozenError(BankError):
pass
def process_transaction(account, amount):
if account.frozen:
raise AccountFrozenError("Account is frozen")
if amount <= 0:
raise InvalidAmountError("Amount must be positive")
if amount > account.balance:
raise InsufficientFundsError("Not enough funds")
try:
process_transaction(my_account, -50)
except BankError as e: # catches ANY of the three subclasses
print(f"Transaction failed: {type(e).__name__}: {e}")This mirrors exactly how Python's own hierarchy works (Chapter 4) — except BankError catches InsufficientFundsError, InvalidAmountError, and AccountFrozenError, since all three inherit from it.
4. Reading a Traceback
A traceback shows the exact chain of function calls that led to an exception — read it from the bottom up.
def divide(a, b):
return a / b
def calculate(x, y):
return divide(x, y)
calculate(10, 0)Traceback (most recent call last):
File "script.py", line 7, in <module>
calculate(10, 0)
File "script.py", line 5, in calculate
return divide(x, y)
File "script.py", line 2, in divide
return a / b
ZeroDivisionError: division by zero
Reading Order — bottom to top
─────────────────────────────────────────
1. Bottom line: ZeroDivisionError: division by zero ← WHAT went wrong
2. Line above: return a / b (inside divide) ← WHERE it happened
3. Next up: return divide(x, y) (inside calculate) ← who called it
4. Top: calculate(10, 0) (module level) ← where the chain started
─────────────────────────────────────────
The last line is almost always the most important — it tells you the exception type and message. The lines above show the call chain that led there, useful for tracing back to the root cause in a large codebase.
5. Debugging with print() — and Its Limits
The simplest debugging tool: sprinkle print() statements to see what's happening.
def calculate_discount(price, discount_percent):
print(f"DEBUG: price={price}, discount_percent={discount_percent}")
discount = price * (discount_percent / 100)
print(f"DEBUG: discount={discount}")
return price - discountThis works for small scripts but doesn't scale — you end up adding/removing prints repeatedly, and it can't pause execution to let you inspect state interactively. That's what a real debugger (Section 6) is for.
A better middle ground for anything beyond a quick script: use the logging module (covered in depth in Module 5) instead of print() — it can be turned on/off and leveled (debug/info/warning/error) without editing code.
6. The pdb Debugger
Python's built-in interactive debugger. Insert a breakpoint, and execution pauses there, letting you inspect variables live.
def calculate_discount(price, discount_percent):
breakpoint() # modern equivalent of "import pdb; pdb.set_trace()"
discount = price * (discount_percent / 100)
return price - discount
calculate_discount(100, 20)When execution hits breakpoint(), you get an interactive prompt:
(Pdb) price
100
(Pdb) discount_percent
20
(Pdb) n # next line
(Pdb) p discount # print a variable's value
16.0
(Pdb) c # continue execution
| Command | Action |
|---|---|
n | Next line (step over) |
s | Step into a function call |
c | Continue running until the next breakpoint |
p variable | Print a variable's value |
l | List surrounding source code |
q | Quit the debugger |
In practice: most developers today use their editor's built-in debugger (VS Code, PyCharm) rather than typing pdb commands — but knowing breakpoint() exists is essential for quick debugging in any environment, including a plain terminal or remote server.
7. Defensive Programming with assert
assert checks that a condition holds, and raises AssertionError immediately if it doesn't — useful for catching bugs early, close to their source, rather than letting bad data propagate silently.
def calculate_average(numbers):
assert len(numbers) > 0, "Cannot calculate average of an empty list"
return sum(numbers) / len(numbers)
calculate_average([]) # AssertionError: Cannot calculate average of an empty listImportant caveat: assert statements are stripped out entirely when Python runs in optimized mode (python -O). Never use assert for real validation logic (like checking user input or security conditions) — reserve it for catching programmer errors during development, and use proper if/raise (Chapter 4) for anything that must always be checked.
# Wrong — using assert for real, must-always-run validation
def withdraw(balance, amount):
assert amount <= balance, "Insufficient funds" # ✗ silently skipped with `python -O`!
# Correct — real validation uses raise, always runs
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError("Insufficient funds")8. Summary & Next Steps
Key Takeaways
- Custom exceptions (
class MyError(Exception)) make error handling self-documenting and let callers catch precisely what they expect. - Grouping custom exceptions under a shared base class lets code catch broadly (
except BankError) or narrowly (except InsufficientFundsError) as needed. - Read tracebacks bottom-up: the last line is the error itself; the lines above trace the call chain that led there.
breakpoint()(built-inpdb) pauses execution for live inspection — more powerful than scatteringprint()statements, especially for non-trivial bugs.assertis for catching programmer errors during development — never use it for validation logic that must always run in production.
Module 3 Complete — Next Module
You can now organize code across files, read/write real data (text, CSV, JSON), and handle errors gracefully instead of crashing. Module 4 introduces Object-Oriented Programming — the foundation for structuring larger, more complex programs (and a prerequisite for understanding most Python libraries you'll use later, including NumPy, pandas, and PyTorch).
Concept Check
- Why inherit a custom exception from
Exceptioninstead of just raising a plain string? - When reading a traceback, which line tells you the actual error type and message?
- Why should
assertnever be used to validate untrusted user input?
Next Chapter
→ Module 4: Object-Oriented Programming
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index