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.)
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 - amount02Define 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).
class ShopError(Exception):
"""Base class for every error raised by this shop."""
class OutOfStock(ShopError):
pass
class UnknownProduct(ShopError):
pass03Catch 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.
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.
stock = {"coffee": 5}
def reserve(name):
try:
stock[name] -= 1
except KeyError as original:
raise UnknownProduct(f"no such product: {name}") from original05Re-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.
def load_order(order_id):
try:
return int("not-a-number")
except ValueError as e:
e.add_note(f"while loading order {order_id}")
raise06assert 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.
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 ageTry it yourself
- 1
Add a third child
In the first example add
class PaymentFailed(ShopError): pass, raise it from a new branch inbuy, and check that the catch-allexcept ShopErrorhandler reports it without any other change. - 2
Remove the from
In the second example change
from originalto nothing. Run it. Ise.__cause__filled in now? Printe.__context__instead and compare. This is the difference between a deliberate cause and an accident. - 3
Try from None
Change the raise to
raise ConfigError("invalid port setting") from Noneand print both attributes.from Nonehides the original on purpose, which is useful when the internal error would only confuse the caller.
Code & diagrams
Catching the parent class catches both children. Change the last raise to OutOfStock and watch which handler runs.
Expected output
bought coffee
sold out: tea
shop problem: UnknownProduct pizzaThe new exception remembers the old one. Look at which attribute is filled in each case.
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'The note is stored on the exception and travels with it as it is re-raised.
Expected output
1
2
failed: invalid literal for int() with base 10: 'three'
notes: ["on line 3: 'three'"]The first function protects against bad input. The second protects against a bug in our own code.
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 sortedBreak 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.
def check(age):
if age < 0:
raise "age is negative"
return age
check(-1)Break #2
Raise inside except without from
Translate a KeyError into a custom error but forget from.
class MissingSetting(Exception):
pass
settings = {}
try:
value = settings["port"]
except KeyError:
raise 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 oneexcept, 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 ... fromsets__cause__and__suppress_context__. The printed traceback shows the cause if__cause__is set, the context otherwise (unless suppressed). Tools such as Sentry andlogging.exceptionfollow the same chain and print all of it. - ▸
When an LLM call fails, wrapping the vendor's error in your own
ModelUnavailable(...) from errkeeps your application code independent of any one vendor's exception names. - ▸
If your exception defines its own
__init__with different parameters, callsuper().__init__(...)with the same arguments callers pass. Python rebuilds an exception fromcls(*e.args)when it is copied or pickled (for example when a worker process in Topic 12.5 sends an error back), so mismatchedargscan raise aTypeErroror rebuild a wrong object. - ▸
raise X from Nonesets__cause__toNoneand__suppress_context__toTrue, butX.__context__still holds the original. Hiding it from the printed traceback does not delete it, andloggingwithexc_infoobeys the same rule. Also remember thatassert (cond, "msg")with parentheses is a non-empty tuple, always true: CPython warns with aSyntaxWarning, so keep the comma outside the parentheses.
Remember this
- 1
raise ValueError("age must not be negative")creates an exception object and raises it. Choose a built-in class when it fits:ValueErrorfor a right type with a wrong value,TypeErrorfor the wrong type,KeyErrororIndexErrorfor missing items,FileNotFoundErrorfor 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
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 everythingExceptionhas, and is counted as a kind ofException):class OrderError(Exception): pass. That one line is enough. (classand 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 writeexcept OrderError:and handle exactly your problem, without catching unrelated errors. - 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 catchShopError; callers who care about one case catchOutOfStock. Name classes after what went wrong, end the name withErrorwhen it is an error, and keep the base inheriting fromException, never fromBaseException. - 4
Exception chaining keeps the story of what happened. Inside an
exceptblock you often want to raise a different, more meaningful exception while keeping the original. (Recall from Topic 7.1:except ... as egives the caught error a name.)raise OrderError("cannot read order") from originalstores 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
If you raise a new exception inside an
exceptblock withoutfrom, Python still links the old one, in__context__. It then says "During handling of the above exception, another exception occurred". Usefrom Noneto hide the original on purpose. (Names with double underscores, like__cause__, are attributes that Python itself creates and uses. You only read them.) - 6
A bare
raise(with nothing after it) inside anexceptblock 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. Writingraise einstead works too but adds a confusing extra line to the traceback; prefer the bare form. - 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 ine.__notes__. It lets code in the middle add context without replacing the exception. - 8
Finally,
assert condition, "message"raisesAssertionErrorwhen 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-Ooption (a switch that turns on optimisations), so the check would silently disappear.
Explain it without notes
How do you create a custom exception, and why would you rather do that than raise Exception("...")?
Explain the difference between raise X from Y, raising inside an except block without from, and raise X from None.
When is assert appropriate, and when is it a bug waiting to happen?
What does a bare raise do, and where is it legal?
Practice
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.
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.
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__andfrom None.Done when you can re-raise with a bare
raiseand add a note withadd_note.Done when you can say why
assertmust not validate user input.