Raising and chaining exceptions

Intermediate40 min

Raise exceptions with clear messages, re-raise them after logging, and chain one error to another with raise from so the cause is never lost.

What you will learn
  • Raise a built-in exception with a message that names the bad value
  • Choose between ValueError and TypeError for invalid arguments
  • Re-raise the current exception with a bare raise
  • Chain exceptions with raise ... from, and explain __cause__ and __context__
On this page

Until now, Python raised every exception for you. A function you write can do the same: when it receives something it cannot work with, it should say so loudly, close to the mistake, instead of returning a wrong answer.

Raising an exception

The raise statement takes an exception object. You build one by calling the type like a function, and whatever you pass becomes its message.

raise_basic.py
def set_age(age):
if age < 0:
raise ValueError(f"age cannot be negative: {age}")
return age
try:
set_age(-3)
except ValueError as error:
print("Caught:", error)
print(error.args)
Output
Caught: age cannot be negative: -3
('age cannot be negative: -3',)

raise stops the function at that line, exactly as a failing int() would. The arguments you passed are kept in a tuple called args, and print(error) shows the first one.

Which type to raise

Pick the type that tells the caller what kind of mistake it was. Two cover most input checks:

  • ValueError: the argument has the right type, but its value is unacceptable. A negative age, an empty name, a month number of 13.
  • TypeError: the argument is the wrong kind of thing altogether, such as text where a number was needed.
raise_types.py
def double(n):
if not isinstance(n, int):
raise TypeError(f"expected an int, got {type(n).__name__}")
return n * 2
for value in [4, "4", 4.5]:
try:
print(double(value))
except TypeError as error:
print("TypeError:", error)
Output
8
TypeError: expected an int, got str
TypeError: expected an int, got float

Write the message for the person who reads it

The message is what appears in a traceback at two in the morning, so make it carry its weight. A good message says what was wrong, shows the offending value, and, if it helps, says what was expected. Compare "bad age" with f"age cannot be negative: {age}". The second one lets the reader fix the problem without opening your code. !r in an f-string (as in {text!r} below) shows a value with its quotes, which makes empty strings and stray spaces visible.

Re-raising with a bare raise

Sometimes a function wants to look at an error, perhaps to log it, and then let it carry on. A raise with nothing after it, inside an except block, raises the exception that is currently being handled:

reraise.py
def read_port(text):
try:
return int(text)
except ValueError:
print("log: bad port text", repr(text))
raise
try:
read_port("80a")
except ValueError as error:
print("caller saw:", error)
Output
log: bad port text '80a'
caller saw: invalid literal for int() with base 10: '80a'

The caller receives the original exception, untouched, with its original traceback. Use a bare raise for this, not raise error: it is shorter and it cannot go wrong. Outside an except block there is nothing to re-raise, and a bare raise fails with RuntimeError: No active exception to reraise.

Chaining: keeping the cause

Often the low-level error is correct but unhelpful. int("80a") fails with “invalid literal for int() with base 10”, which says nothing about which setting was wrong. You want to raise a better error and still keep the original one for debugging. That is exception chaining, and raise ... from does it:

load_port.py
def load_port(text):
try:
return int(text)
except ValueError as error:
raise ValueError(f"invalid port setting: {text!r}") from error
load_port("80a")

Run uncaught, Python prints both exceptions, with a sentence between them:

Traceback (most recent call last):
File "load_port.py", line 3, in load_port
return int(text)
ValueError: invalid literal for int() with base 10: '80a'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "load_port.py", line 8, in <module>
load_port("80a")
~~~~~~~~~^^^^^^^
File "load_port.py", line 5, in load_port
raise ValueError(f"invalid port setting: {text!r}") from error
ValueError: invalid port setting: '80a'

The last line is the error the caller should deal with. The part above it is the history. In code, the original is available as __cause__:

chain_from.py
def load_port(text):
try:
return int(text)
except ValueError as error:
raise ValueError(f"invalid port setting: {text!r}") from error
try:
load_port("80a")
except ValueError as error:
print("error: ", error)
print("cause: ", repr(error.__cause__))
print("hidden:", error.__suppress_context__)
Output
error: invalid port setting: '80a'
cause: ValueError("invalid literal for int() with base 10: '80a'")
hidden: True

Context: chaining you did not ask for

If a new exception happens while an except block is running, Python links the two automatically, even without from. The older error is stored as __context__, and __cause__ stays empty:

chain_context.py
def convert(text):
try:
return int(text)
except ValueError:
return 1 / 0
try:
convert("x")
except ZeroDivisionError as error:
print("error: ", error)
print("cause: ", error.__cause__)
print("context:", repr(error.__context__))
Output
error: division by zero
cause: None
context: ValueError("invalid literal for int() with base 10: 'x'")

In a traceback this version reads “During handling of the above exception, another exception occurred”, instead of “was the direct cause”. The wording matters. “Direct cause” says you converted the error on purpose. “During handling” says something went wrong inside your error handler, which is usually a bug. Use from error whenever the conversion is deliberate.

Hiding the original with from None

Occasionally the original error is only noise, because the new message already says everything. from None removes the history from the traceback:

chain_none.py
def load_port(text):
try:
return int(text)
except ValueError:
raise ValueError(f"invalid port setting: {text!r}") from None
try:
load_port("80a")
except ValueError as error:
print("error: ", error)
print("cause: ", error.__cause__)
print("context:", repr(error.__context__))
print("hidden: ", error.__suppress_context__)
Output
error: invalid port setting: '80a'
cause: None
context: ValueError("invalid literal for int() with base 10: '80a'")
hidden: True

The context is still stored, only the traceback display skips it. Reach for from None sparingly. The hidden error is often exactly what a developer needs.

Common pitfalls

  • Raising a vague type. raise Exception("bad") forces callers to catch Exception, which also catches bugs. Raise the most specific built-in type that fits.
  • Raising a new error and forgetting from. The result is the “during handling” wording, which looks like a bug in your handler. Say from error when the conversion is on purpose.
  • Logging and re-raising at every level. If each layer prints the error and raises it again, one failure appears in the log five times. Handle an error at the layer that can do something about it.
  • Putting raise in a function and expecting the caller to know. Nothing in the signature says a function can raise. Mention it in a comment or docstring, as you would a return value.
  • Returning from a finally block. A return inside finally throws away any exception that is on its way out. The next block shows it.
finally_return.py
def read_port(text):
try:
return int(text)
finally:
return 8080
print(read_port("x"))
Output
8080

int("x") raises ValueError, but the finally block runs before the exception leaves the function, and its return replaces the exception with a normal result. The error vanishes and the caller sees a valid-looking 8080. A break or continue that leaves a finally block has the same effect. Requires version 3.14 or later Python 3.14 (PEP 765) makes the compiler warn about all three. Running the file prints the result above, plus this on the error stream:

Terminal window
finally_return.py:5: SyntaxWarning: 'return' in a 'finally' block
return 8080

Keep finally for cleanup only, and put the return outside it.

ExerciseRefuse bad withdrawals

Write withdraw(balance, amount). It returns the new balance, but raises ValueError("amount must be positive") when amount is zero or less, and ValueError with the message insufficient funds: balance 100, requested 500 (using the real numbers) when amount is more than balance. Call it with a balance of 100 and amounts 30, 0 and 500, catching the error each time. Expected output:

new balance: 70
refused: amount must be positive
refused: insufficient funds: balance 100, requested 500
Show solution
withdraw.py
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("amount must be positive")
if amount > balance:
raise ValueError(f"insufficient funds: balance {balance}, requested {amount}")
return balance - amount
for amount in [30, 0, 500]:
try:
print("new balance:", withdraw(100, amount))
except ValueError as error:
print("refused:", error)
Output
new balance: 70
refused: amount must be positive
refused: insufficient funds: balance 100, requested 500

Each check raises as soon as it finds a problem, so the arithmetic on the last line only runs for valid amounts. The order of the two checks matters: a zero amount is reported as “must be positive” and never reaches the funds test.

ExerciseParse a setting, keep the cause

Write parse_setting(line) for lines like port=80. It returns a tuple (name, number). If the line has no =, it raises ValueError with the message no '=' in line: 'oops' (using the real line). If the part after = is not a whole number, it raises a ValueError saying setting 'port' needs a whole number, chained from the original error. Hint: line.split("=", 1) splits at the first = only. For the lines "port=80", "oops" and "port=x", print the result, or "error:", error, "| cause:", error.__cause__. Expected output:

('port', 80)
error: no '=' in line: 'oops' | cause: None
error: setting 'port' needs a whole number | cause: invalid literal for int() with base 10: 'x'
Show solution
parse_setting.py
def parse_setting(line):
if "=" not in line:
raise ValueError(f"no '=' in line: {line!r}")
name, value = line.split("=", 1)
try:
return name, int(value)
except ValueError as error:
raise ValueError(f"setting {name!r} needs a whole number") from error
for line in ["port=80", "oops", "port=x"]:
try:
print(parse_setting(line))
except ValueError as error:
print("error:", error, "| cause:", error.__cause__)
Output
('port', 80)
error: no '=' in line: 'oops' | cause: None
error: setting 'port' needs a whole number | cause: invalid literal for int() with base 10: 'x'

The first error has no cause because it is raised directly, not inside an except. The second is raised inside the except block with from error, so __cause__ holds the int failure.

ExercisePredict the order

What does this print? Write all three lines before you run it, and say where finally fits.

predict_reraise.py
def risky():
try:
raise ValueError("first")
except ValueError:
print("handling")
raise
finally:
print("cleanup")
try:
risky()
except ValueError as error:
print("outer got", error)
Show solution
handling
cleanup
outer got first

The except block prints handling and re-raises. Before that exception leaves the function, the finally block runs and prints cleanup. Only then does the exception reach the outer except, which prints outer got first.

Summary

  • raise ValueError("message") stops the function with an exception you built. Use ValueError for a bad value and TypeError for a bad kind of value, and put the offending value in the message.
  • A bare raise inside except re-raises the current exception unchanged.
  • raise NewError(...) from error records the original as __cause__. Without from, Python still records it as __context__, and the traceback blames your handler. from None hides it from the traceback.
  • Custom exception classes wait for the object-oriented module.