Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 32 of 898 min

Raising your own errors, and choosing the right one

Fail at the moment of the mistake

python
def split_bill(total, people):
    if people < 1:
        raise ValueError(f"people must be at least 1, got {people}")
    return round(total / people, 2)

Without the check, people = 0 gives ZeroDivisionError from inside the function, and people = -2 gives a negative amount per person with no error at all. The second is worse: a wrong number that flows into a report.

Three things make that raise line useful:

  • It fires at the boundary, where the caller's mistake is still visible.
  • It names the constraint — "must be at least 1" — rather than just complaining.
  • It includes the actual value. got -2 is the difference between a five-second fix and a debugging session. Messages without the offending value are the most common waste of everybody's time.

Pick a built-in exception that already means it

Python's hierarchy has a right answer for most cases:

  • ValueError — right type, unacceptable value. The default choice.
  • TypeError — wrong type entirely.
  • KeyError, IndexError — a lookup that is not there.
  • FileNotFoundError, PermissionError — file system, both subclasses of OSError.
  • TimeoutError, ConnectionError — network.
  • NotImplementedError — a method a subclass is supposed to provide.
  • RuntimeError — genuinely none of the above.

Never raise Exception("something went wrong"). A caller cannot catch it without catching everything, so you have forced them into the pattern the previous lesson warned about.

Your own exception classes

Once a project has its own failure modes, give them names:

python
class BillingError(Exception):
    """Base class for every error this package raises."""

class InsufficientCredit(BillingError):
    def __init__(self, needed, available):
        super().__init__(f"needs {needed}, has {available}")
        self.needed = needed
        self.available = available

Three lines of value here. Callers can write except BillingError: and catch everything from your package without catching unrelated bugs. They can catch InsufficientCredit specifically to offer a top-up. And the exception object carries the numbers, so the handler can use them rather than parsing your message text.

A single base class per package is the convention worth copying from the standard library and from requests, which does exactly this.

Where to validate, and where not to

Check the inputs at the edges of your program: what arrives from a user, a file, a network response, a command line. Inside, between your own functions, checking every argument in every function turns into noise nobody reads and slows the code for no benefit.

The practical line: validate anything that came from outside the program, once, as early as possible, and trust it afterwards.

Errors are part of the interface

A function's docstring should say what it raises, because that determines what a caller must handle:

python
def load_config(path):
    """Return the parsed config.

    Raises FileNotFoundError if path does not exist,
    and ValueError if the file is not valid JSON.
    """

Changing which exception a function raises breaks callers exactly as surely as changing its arguments, and it does so silently — their except clause simply stops matching. Treat it as part of the signature.

Do not use exceptions for ordinary control flow

python
try:
    return cache[key]
except KeyError:
    return compute(key)

That is fine and idiomatic. But raising an exception to signal "we reached the end of the list" or "the user chose option 2" makes the code hard to follow and costs more than a comparison — building an exception involves capturing a traceback. Exceptions are for the exceptional; the boundary is roughly whether a reader would call the situation an error.

Warnings, for things that are not yet errors

python
import warnings
warnings.warn("split_bill(round_up=) is deprecated, use rounding=",
              DeprecationWarning, stacklevel=2)

A warning prints once and lets the program continue. It is the right tool for a deprecation or a suspicious-but-legal input. stacklevel=2 makes the warning point at the caller's line rather than at your own, which is the difference between a useful warning and a confusing one.

Try this now

Take a function you have written that assumes something about its input — a non-empty list, a positive number, a key that exists. Add a raise with the value in the message, then call it wrongly and read the traceback. Then change the exception type to a custom class and catch it specifically at the call site.

The one thing to keep

Raise the most specific built-in that already means what went wrong, always put the offending value in the message, and give a package one base exception class so callers can catch its failures without catching everything.

Before you move on

A payment library raises `Exception("declined")` on a refused card. Callers want to retry on a network problem but show a message on a decline. Why is the library's choice a problem?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly