Day 18 · ~15m

Python Custom Exceptions: Raise Errors That Actually Mean Something

Create custom exception classes to raise domain-specific errors. Learn when and why custom exceptions beat generic ValueError — and how your caller knows exactly what went wrong.

student (thinking)

I saw this in the codebase last week. An exception with a name I'd never heard of — InvalidOrderError or something. The standard one says ValueError. Why would Amir make his own?

teacher (neutral)

Let me show you why. Say you're processing an order — quantity, stock, total. You need to validate all three. Here's what most people write:

def process_order(order_id, quantity, stock):
    if quantity <= 0:
        raise ValueError("Quantity must be positive")
    if quantity > stock:
        raise ValueError("Not enough stock")
    return f"Order {order_id} processed"
student (curious)

That works. What's the problem?

teacher (serious)

Call this function from somewhere. Three lines up, three lines down, you have catch blocks:

try:
    result = process_order(123, 5, 3)
except ValueError:
    # Which ValueError? Quantity? Stock? Something in a helper function?
    print("Something was wrong with the order")
student (thinking)

You can't tell which validation failed without reading the message string. That feels fragile.

teacher (focused)

Exactly. The caller catches ValueError and has no idea whether to blame the customer or the inventory system. Now watch what happens with a custom exception:

class InvalidOrderError(ValueError):
    pass

class OutOfStockError(Exception):
    pass

def process_order(order_id, quantity, stock):
    if quantity <= 0:
        raise InvalidOrderError("Quantity must be positive")
    if quantity > stock:
        raise OutOfStockError("Not enough stock")
    return f"Order {order_id} processed"

try:
    result = process_order(123, 5, 3)
except InvalidOrderError:
    print("Customer sent us bad data")
except OutOfStockError:
    print("Inventory team needs to know")
student (excited)

Oh. The caller can now catch the specific error. The except clause tells them exactly what happened.

teacher (encouraging)

Without reading a message. Without string parsing. Without guessing. Your error is the information.

student (confused)

But both of those classes are just pass. They don't do anything. How is that better than a string?

teacher (neutral)

The class name is the information. When you raise InvalidOrderError, every line of code that touches it — the person reading the traceback, the except block catching it, the monitoring service logging it — they all know instantly what category of problem it is.

student (thinking)

Okay. So it's not about what the class does, it's about its identity. The type is the meaning.

teacher (surprised)

You've got it in one sentence. Most people take a week to land there.

student (curious)

So where do you add the message? The string in the raise?

teacher (focused)

Yes. You inherit from Exception and pass the message like any other exception:

class InvalidOrderError(ValueError):
    pass

raise InvalidOrderError(f"Invalid order total: {total}")
student (thinking)

What's the difference between inheriting from ValueError and Exception?

teacher (serious)

ValueError is a built-in exception that Python raises for value problems — bad arguments, bad data. OutOfStockError is your domain — inventory. So OutOfStockError inherits from Exception, the base class. InvalidOrderError is a value problem, so it inherits from ValueError. When someone catches ValueError, they catch your InvalidOrderError too — it's a subclass.

try:
    raise InvalidOrderError("Bad quantity")
except ValueError:
    print("Caught it — ValueError hierarchy includes InvalidOrderError")
student (focused)

So by inheriting from ValueError, I'm saying "this is a ValueError, but more specific." Callers can catch either the general ValueError or the specific InvalidOrderError.

teacher (proud)

Exactly. That's inheritance at work. The hierarchy lets you catch at the level you care about.

student (curious)

Can I add more to the class? Like, store the invalid value?

teacher (encouraging)

You can. Here's a more sophisticated version:

class InvalidOrderError(ValueError):
    def __init__(self, total):
        self.total = total
        super().__init__(f"Invalid order total: {total}")

class OutOfStockError(Exception):
    def __init__(self, requested, available):
        self.requested = requested
        self.available = available
        super().__init__(f"Requested {requested} but only {available} in stock")

def process_order(order_id, quantity, stock):
    if quantity <= 0:
        raise InvalidOrderError(quantity)
    if quantity > stock:
        raise OutOfStockError(quantity, stock)
    return f"Order {order_id} processed"

try:
    result = process_order(123, 5, 3)
except OutOfStockError as e:
    print(f"Stock is short by {e.requested - e.available} units")
student (amused)

Now the caller can pull the data out of the exception object. Not just catch it — actually use its attributes.

teacher (amused)

There's a pattern in the codebase. Caught you using it last week without knowing what it was.

student (thinking)

So the simple version — just pass — is fine for "I want callers to know the type." The version with __init__ is for "I want callers to know the type AND extract data from it."

teacher (focused)

Right. Build the complexity you need. Start with the simple one. If callers are asking for the data, add __init__ and store attributes.

student (focused)

And the super().__init__() part sends the message up to Exception, so when people print the exception or see it in a traceback, they still get the readable message.

teacher (encouraging)

Perfect. The message is for humans reading tracebacks. The attributes are for code catching it and making decisions.

student (curious)

What if I need a whole family of order-related errors? InvalidOrderError, OutOfStockError, ShippingError?

teacher (serious)

Make a parent OrderError class. All three inherit from it. Then callers can catch OrderError if they want to handle anything order-related, or catch the specific one if they need to.

class OrderError(Exception):
    """Base class for all order processing errors"""
    pass

class InvalidOrderError(OrderError):
    pass

class OutOfStockError(OrderError):
    pass

class ShippingError(OrderError):
    pass

try:
    result = process_order(123, 5, 3)
except ShippingError:
    print("Call the shipping partner")
except OrderError:
    print("Something went wrong with the order")
student (proud)

I could catch all order errors, or just the specific ones. That's the whole idea of class hierarchies.

teacher (excited)

And now you're thinking like the codebase. Amir probably has an OrderError hierarchy somewhere. Next time you see InvalidOrderError, you'll know why it's there.

student (thinking)

Tomorrow we're doing the with statement, right?

teacher (neutral)

Tomorrow. Which is really about "what happens when your try block raises an exception — how do we guarantee cleanup?"

student (curious)

Like database connections or file handles?

teacher (proud)

Exactly. You'll see why custom exceptions pair with context managers. Today you learned to raise errors that mean something. Tomorrow you'll learn to raise them safely.