Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate Python 3.11+ ~28 min· topic 2 of 12

Topic 7.2

Raising Errors and Custom Exception Classes

In one line

raise lets your own code signal a problem, raise ... from records the cause, and a small custom exception class gives callers a precise name to catch. add_note (Python 3.11) attaches extra context, and assert is for checking your own assumptions, not user input.

Think of it like this

A smoke alarm. The alarm does not put out the fire. It only says loudly: "there is a problem here". Someone else (the person in the house, the fire brigade) decides what to do. raise is your code's smoke alarm: a function that finds bad input or a broken rule raises an exception and lets the caller decide what happens next.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

raise
The statement that creates and signals an exception.
Custom exception
An error class you define yourself, usually a one-line subclass of Exception.
Exception chaining
Linking a new exception to the one that caused it, so both appear in the traceback.
__cause__
The attribute holding the original exception when you write raise X from original.
__context__
The attribute holding the exception that was being handled when a new one was raised.
Re-raise
A bare raise inside an except block, which passes the same exception upward.
add_note
A method (Python 3.11) that attaches extra explanatory text to an exception.
assert
A statement that raises AssertionError if a condition is false. Used for checking your own assumptions.

Step by step

01Raise a built-in exception

A function should refuse bad input as early as possible. withdraw below checks the amount and raises ValueError with a clear message. The caller never receives a wrong result: it either gets a good one or an exception. (The f"...{amount}" strings put the value into the message; Topic 1.9 covers f-strings.)

main.pywhole filepython
def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError(f"amount must be positive, got {amount}")
    if amount > balance:
        raise ValueError(f"cannot withdraw {amount} from {balance}")
    return balance - amount

02Define your own exception family

Here is the smallest class form. pass means "nothing extra". The child classes inherit everything from ShopError, and ShopError inherits from Exception. Topic 8.1 explains classes properly.

Arguments given when raising (OutOfStock("tea", 3)) are stored in the tuple e.args. With one argument str(e) is that argument; with several, str(e) shows the whole tuple, such as ('tea', 3).

main.pywhole filepython
class ShopError(Exception):
    """Base class for every error raised by this shop."""


class OutOfStock(ShopError):
    pass


class UnknownProduct(ShopError):
    pass
Define your own exception familydiagram
Rendering diagram…

03Catch the parent or the child

Because OutOfStock is a kind of ShopError, one except ShopError: handles every shop problem, and except OutOfStock: handles just one. Callers pick the level of detail they need.

main.pywhole filepython
try:
    raise OutOfStock("tea")
except OutOfStock:
    print("Sorry, sold out")

try:
    raise UnknownProduct("pizza")
except ShopError as e:
    print("Shop problem:", e)

04Chain errors with raise ... from

Low-level errors ("KeyError: 'tea'") mean little to the caller. Catch the low-level error, then raise a meaningful one with from, so nothing is lost. Here stock[name] -= 1 takes one away from the count for name (Topic 4.4 covers dictionaries), and it fails with KeyError if name is not a key. The original error stays in e.__cause__, and the traceback prints both.

main.pywhole filepython
stock = {"coffee": 5}

def reserve(name):
    try:
        stock[name] -= 1
    except KeyError as original:
        raise UnknownProduct(f"no such product: {name}") from original

05Re-raise, and add a note

Sometimes you want to do something (log, clean up, add context) and let the error continue. A bare raise re-raises the same exception. add_note (Python 3.11) stores extra lines in __notes__ and prints them under the error.

main.pywhole filepython
def load_order(order_id):
    try:
        return int("not-a-number")
    except ValueError as e:
        e.add_note(f"while loading order {order_id}")
        raise

06assert is for you, not for users

assert documents something that must be true if your own code is correct. If it fails, it is a bug in the program, and the failure should be loud during development. User input and files can be wrong in ordinary ways, so they must be checked with if and raise, which are never switched off.

main.pywhole filepython
def middle(sorted_values):
    assert sorted_values == sorted(sorted_values), "list must be sorted"
    return sorted_values[len(sorted_values) // 2]

# Validate input with if + raise, not assert:
def set_age(age):
    if not isinstance(age, int) or age < 0:
        raise ValueError(f"age must be a whole number >= 0, got {age!r}")
    return age

Try it yourself

  1. 1

    Add a third child

    In the first example add class PaymentFailed(ShopError): pass, raise it from a new branch in buy, and check that the catch-all except ShopError handler reports it without any other change.

  2. 2

    Remove the from

    In the second example change from original to nothing. Run it. Is e.__cause__ filled in now? Print e.__context__ instead and compare. This is the difference between a deliberate cause and an accident.

  3. 3

    Try from None

    Change the raise to raise ConfigError("invalid port setting") from None and print both attributes. from None hides the original on purpose, which is useful when the internal error would only confuse the caller.

Code & diagrams

A small family of custom errors New tab

Catching the parent class catches both children. Change the last raise to OutOfStock and watch which handler runs.

Sign in to run this example in your browser.

Expected output

bought coffee
sold out: tea
shop problem: UnknownProduct pizza
Chaining: __cause__ and __context__ New tab

The new exception remembers the old one. Look at which attribute is filled in each case.

Sign in to run this example in your browser.

Expected output

port: 8080
error: invalid port setting
  caused by: KeyError 'port'
error: invalid port setting
  caused by: ValueError invalid literal for int() with base 10: 'eighty'
add_note and a bare raise Python 3.11+ New tab

The note is stored on the exception and travels with it as it is re-raised.

Sign in to run this example in your browser.

Expected output

1
2
failed: invalid literal for int() with base 10: 'three'
notes: ["on line 3: 'three'"]
Validate with raise, check assumptions with assert New tab

The first function protects against bad input. The second protects against a bug in our own code.

Sign in to run this example in your browser.

Expected output

age ok: 30
rejected: age must be a whole number >= 0, got -5
rejected: age must be a whole number >= 0, got 'ten'
assertion failed: values must be sorted

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Raise something that is not an exception

Raise a plain string.

main.pywhole filepython
def check(age):
    if age < 0:
        raise "age is negative"
    return age

check(-1)
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 6, in <module>
check(-1)
~~~~~^^^^
File "main.py", line 3, in check
raise "age is negative"
TypeError: exceptions must derive from BaseException

Break #2

Raise inside except without from

Translate a KeyError into a custom error but forget from.

main.pywhole filepython
class MissingSetting(Exception):
    pass

settings = {}
try:
    value = settings["port"]
except KeyError:
    raise MissingSetting("port is not set")
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 6, in <module>
value = settings["port"]
~~~~~~~~^^^^^^^^
KeyError: 'port'
 
During handling of the above exception, another exception occurred:
 
Traceback (most recent call last):
File "main.py", line 8, in <module>
raise MissingSetting("port is not set")
MissingSetting: port is not set

Myth vs fact

Myth

You must write a long class with lots of code to make a custom exception.

Fact

class MyError(Exception): pass is a complete, correct custom exception. Add attributes or methods only when callers need them.

Myth

assert is a good way to check function arguments.

Fact

Assertions vanish under python -O. Use if plus raise ValueError for anything that depends on outside data.

Myth

raise e and raise do the same thing.

Fact

Inside except, a bare raise re-raises the original error with its original traceback. raise e works but adds an extra traceback line pointing at the re-raise.

Myth

Custom exceptions should inherit from BaseException.

Fact

Inherit from Exception. BaseException is reserved for things like KeyboardInterrupt that ordinary handlers must not catch.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Library design rule: expose one base exception per package (requests.RequestException, httpx.HTTPError). Application code can then guard a call to the library with one except, and the library can add subclasses later without breaking anyone.

  • ▸

    Exceptions are cheap objects but the traceback they carry holds references to stack frames and all their local variables. Keep long-lived exceptions (stored in logs, queues, caches) as strings, or call e.with_traceback(None), to avoid keeping large data alive.

  • ▸

    raise ... from sets __cause__ and __suppress_context__. The printed traceback shows the cause if __cause__ is set, the context otherwise (unless suppressed). Tools such as Sentry and logging.exception follow the same chain and print all of it.

  • ▸

    When an LLM call fails, wrapping the vendor's error in your own ModelUnavailable(...) from err keeps your application code independent of any one vendor's exception names.

  • ▸

    If your exception defines its own __init__ with different parameters, call super().__init__(...) with the same arguments callers pass. Python rebuilds an exception from cls(*e.args) when it is copied or pickled (for example when a worker process in Topic 12.5 sends an error back), so mismatched args can raise a TypeError or rebuild a wrong object.

  • ▸

    raise X from None sets __cause__ to None and __suppress_context__ to True, but X.__context__ still holds the original. Hiding it from the printed traceback does not delete it, and logging with exc_info obeys the same rule. Also remember that assert (cond, "msg") with parentheses is a non-empty tuple, always true: CPython warns with a SyntaxWarning, so keep the comma outside the parentheses.

Remember this

  1. 1

    raise ValueError("age must not be negative") creates an exception object and raises it. Choose a built-in class when it fits: ValueError for a right type with a wrong value, TypeError for the wrong type, KeyError or IndexError for missing items, FileNotFoundError for a missing file. Write a message that says what was wrong and, if possible, what value was received. The message is often all you see in a log (a file where a program writes down what happened).

  2. 2

    When none of the built-in names describe your problem, make your own. A custom exception is a class that inherits from Exception (it gets everything Exception has, and is counted as a kind of Exception): class OrderError(Exception): pass. That one line is enough. (class and inheritance are explained fully in Topic 8.1; for now read it as "make a new kind of error called OrderError, which is a kind of Exception".) Callers can now write except OrderError: and handle exactly your problem, without catching unrelated errors.

  3. 3

    Build small families. A library usually defines one base class (class ShopError(Exception): pass) and specific children (class OutOfStock(ShopError): pass). Callers who want to handle anything from your library catch ShopError; callers who care about one case catch OutOfStock. Name classes after what went wrong, end the name with Error when it is an error, and keep the base inheriting from Exception, never from BaseException.

  4. 4

    Exception chaining keeps the story of what happened. Inside an except block you often want to raise a different, more meaningful exception while keeping the original. (Recall from Topic 7.1: except ... as e gives the caught error a name.) raise OrderError("cannot read order") from original stores the original in __cause__. The traceback then prints both errors, joined by the words "The above exception was the direct cause of the following exception".

  5. 5

    If you raise a new exception inside an except block without from, Python still links the old one, in __context__. It then says "During handling of the above exception, another exception occurred". Use from None to hide the original on purpose. (Names with double underscores, like __cause__, are attributes that Python itself creates and uses. You only read them.)

  6. 6

    A bare raise (with nothing after it) inside an except block re-raises the exception being handled, with its original traceback. Use it when you want to log or clean up and then let the error continue upward. Writing raise e instead works too but adds a confusing extra line to the traceback; prefer the bare form.

  7. 7

    Python 3.11 added add_note(): e.add_note("while loading order 17") attaches extra text that is shown under the message in the traceback and kept in e.__notes__. It lets code in the middle add context without replacing the exception.

  8. 8

    Finally, assert condition, "message" raises AssertionError when the condition is false. Use it to check your own assumptions (for example, that an internal function was given a sorted list). Never use it to validate user input or files. Python removes assertions entirely when it runs with the -O option (a switch that turns on optimisations), so the check would silently disappear.

Explain it without notes

01

How do you create a custom exception, and why would you rather do that than raise Exception("...")?

02

Explain the difference between raise X from Y, raising inside an except block without from, and raise X from None.

03

When is assert appropriate, and when is it a bug waiting to happen?

04

What does a bare raise do, and where is it legal?

Practice

01

Write class InvalidScore(ValueError): pass and a function grade(score) that raises InvalidScore if the score is not between 0 and 100, otherwise returns "pass" for 40 or more and "fail" below.

02

Write load_age(data) for a dict like {"age": "x"}. It must raise ValueError("invalid age") chained from the original error when data["age"] is missing or not a number.

03

Write a function that tries int(text), adds a note with the text when it fails and re-raises. Catch it outside and print the notes.

Trade-offs

  • ↔

    Deep hierarchies versus flat ones. Many specific classes let callers be precise, but each one is something to document and keep stable. Start with one base and a few children you actually catch separately.

  • ↔

    Translating errors versus letting them through. Wrapping a low-level error in a domain error hides vendor details and gives a better message, but loses nothing only if you chain with from. Without chaining you lose the real cause.

Done when you can

  • Done when you can raise a built-in exception with a message that includes the bad value.

  • Done when you can define a base exception and two children, and catch the parent and a child.

  • Done when you can explain raise ... from, __cause__ and from None.

  • Done when you can re-raise with a bare raise and add a note with add_note.

  • Done when you can say why assert must not validate user input.