Python

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

JrCodex·7 min read

Jr Codex Python Notes

Level: Intermediate Prerequisites: Chapter 4 Time to complete: ~20 minutes


Table of Contents

  1. Why Create Custom Exceptions?
  2. Defining a Custom Exception
  3. Building an Exception Hierarchy
  4. Reading a Traceback
  5. Debugging with print() — and Its Limits
  6. The pdb Debugger
  7. Defensive Programming with assert
  8. 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 - discount

This 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
CommandAction
nNext line (step over)
sStep into a function call
cContinue running until the next breakpoint
p variablePrint a variable's value
lList surrounding source code
qQuit 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 list

Important 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-in pdb) pauses execution for live inspection — more powerful than scattering print() statements, especially for non-trivial bugs.
  • assert is 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

  1. Why inherit a custom exception from Exception instead of just raising a plain string?
  2. When reading a traceback, which line tells you the actual error type and message?
  3. Why should assert never 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