Python Part I · Module 14 · Errors and Exception Handling
Raising an Error on Purpose
Every exception so far has come from Python noticing that an operation was impossible. But most bad values are not impossible — they are merely wrong, and Python has no way to know that a seed of minus one is meaningless when minus one is a perfectly good integer. Nothing will stop it. It will be formatted into a directory name, used to pick a shard, written into a filename, and the run will complete and report success. This chapter is about the one statement that turns that silence into a sentence, and about the two questions that decide where to put it.
Foundation16 min readPythonPart IFundamentalsErrorsExceptions
Part I · Module 14 · Chapter 14.7 · Raising an Error on Purpose
1. The Statement
raise ValueError("seed must not be negative")raise creates an exception and throws it, exactly as a failing operation would.
From that instant it behaves identically to anything in Chapter 14.3: it unwinds
the stack, it is catchable by type, and if nothing catches it, it prints a
traceback and sets a non-zero exit status.
The difference is only that you decided the situation was an error.
2. What Happens When You Do Not
This is the case for the whole chapter, and it is worth running before any syntax. Four small functions, none of them wrong in isolation:
def run_dir(seed):
return f"runs/seed_{seed}"
def log_path(seed):
return run_dir(seed) + "/sim.log"
def shard_for(seed):
return ["a", "b", "c"][seed % 3]
def launch(seed):
return f"{log_path(seed)} on shard {shard_for(seed)}"
print(launch(41))
print(launch(-1))runs/seed_41/sim.log on shard c
runs/seed_-1/sim.log on shard cThe second call did not fail. There is no traceback, no warning, nothing.
A seed of -1 produced a real-looking path, runs/seed_-1/sim.log, and picked
a real shard.
The shard is not even arbitrary: -1 % 3 is 2 in Python, because the modulo
operator follows the sign of the divisor (Chapter 5.4), so the index is in range
and "c" comes back. Every operation succeeded. Every operation was also
meaningless.
In a real flow this creates a directory, launches a simulation, writes a log and
reports a result. Nobody finds out for a week, and when they do, the evidence is
a directory called seed_-1 that nobody can explain.
The usual cost of an unchecked value is not a crash. It is a plausible wrong answer.
3. Raising at the Boundary
One statement, at the point the value arrives, removes all of it:
def set_seed(seed):
if seed < 0:
raise ValueError(f"seed must not be negative: {seed}")
return seed
def run_dir(seed):
return f"runs/seed_{set_seed(seed)}"
print(run_dir(41))
print(run_dir(-1))runs/seed_41
Traceback (most recent call last):
File "guarded.py", line 12, in <module>
print(run_dir(-1))
~~~~~~~^^^^
File "guarded.py", line 8, in run_dir
return f"runs/seed_{set_seed(seed)}"
~~~~~~~~^^^^^^
File "guarded.py", line 3, in set_seed
raise ValueError(f"seed must not be negative: {seed}")
ValueError: seed must not be negative: -1The last line is the entire point. It names what rule was broken and which
value broke it. Compare that to debugging a directory called seed_-1 three
days later.
Note the traceback's deepest frame is the raise itself, which reads slightly
differently from the errors in Chapter 14.3 — there is no caret line, because
the whole statement is the failure.
4. Put the Value in the Message
A message without the offending value forces whoever reads it to go and find it:
def describe(seed, with_value):
if with_value:
return f"seed must not be negative: {seed}"
return "invalid seed"
print(describe(-1, False))
print(describe(-1, True))invalid seed
seed must not be negative: -1The second can be acted on from a log file alone. The first cannot, and it is what most people write.
A message worth the keystrokes answers three things:
- what was expected — "must not be negative";
- what arrived —
-1; - enough context to locate it — which the traceback supplies for free.
For strings, use !r so that whitespace and emptiness are visible:
for raw in ["182.4", " ", ""]:
print(f"could not parse runtime: {raw!r}")could not parse runtime: '182.4'
could not parse runtime: ' '
could not parse runtime: ''Without the !r, the last two messages would end in nothing at all, and a
single space and an empty string would look identical. Chapter 4.12 introduced
the conversion; this is where it earns its place.
5. Choosing the Type
Use the same rule Chapter 14.3 gave, read backwards:
| Situation | Raise |
|---|---|
| The type is right, the content is not | ValueError |
| The type itself is wrong | TypeError |
| A key or name that should exist does not | KeyError |
| The operation makes no sense in this state | RuntimeError |
| You have not written it yet | NotImplementedError |
def set_seed(seed):
if not isinstance(seed, int):
raise TypeError(f"seed must be an int, got {type(seed).__name__}")
if seed < 0:
raise ValueError(f"seed must not be negative: {seed}")
return seed
for candidate in [41, -1, "41"]:
try:
print("ok:", set_seed(candidate))
except (TypeError, ValueError) as e:
print(f"{type(e).__name__}: {e}")ok: 41
ValueError: seed must not be negative: -1
TypeError: seed must be an int, got strPicking the conventional type is not pedantry — it is what lets a caller write
except ValueError and have it mean something. A caller who must catch
Exception because your function raises an unpredictable type has lost
Chapter 14.5 entirely.
6. Where to Raise
The rule is a scope argument. Raise at the boundary where the value enters your control — the function that accepts it from the outside world:
def parse_args(fields):
"""The boundary: everything past here can assume a valid seed."""
seed = int(fields["seed"])
if seed < 0:
raise ValueError(f"seed must not be negative: {seed}")
return seed
def run(fields):
seed = parse_args(fields)
return f"running with seed {seed}"
print(run({"seed": "41"}))
try:
run({"seed": "-5"})
except ValueError as e:
print("rejected:", e)running with seed 41
rejected: seed must not be negative: -5Why there and not deeper? Because the boundary is the last place where the
offending value and the knowledge of what it should have been are in the same
function. shard_for in Section 2 has the value but no idea what a legal seed
is — it is just doing arithmetic. By the time the value reaches it, the
information needed to judge it has been left behind.
And why not shallower? Because then you have already acted on it.
Everything downstream of a boundary check gets to assume the value is good, which is what makes the rest of the code simple.
7. Do Not Validate With assert
assert looks like it does this job, and for input validation it is a trap:
def set_seed(seed):
assert seed >= 0, f"seed must not be negative: {seed}"
return seed
print(set_seed(41))
print(set_seed(-1))41
Traceback (most recent call last):
File "as.py", line 7, in <module>
print(set_seed(-1))
~~~~~~~~^^^^
File "as.py", line 2, in set_seed
assert seed >= 0, f"seed must not be negative: {seed}"
^^^^^^^^^
AssertionError: seed must not be negative: -1That looks fine. Now run the same file with Python's optimise flag, which a colleague, a wrapper script or a deployment image may well set:
$ python3 -O as.py
41
-1The check is gone. Not failed — removed from the program entirely, along
with its message. set_seed(-1) returned -1 and the script carried on into
Section 2's silent-corruption scenario.
8. One Problem, or All of Them
Fail-fast has a cost that shows up the moment a human is in the loop. A config with three mistakes, validated one check at a time, produces three separate runs: fix the seed, rerun, learn the test list is empty, fix it, rerun, learn the timeout is not a number.
Collecting the problems and raising once removes two of those round trips:
CONFIG = {"seed": "-1", "tests": "", "timeout_s": "abc", "shard": "2"}
def check_first(cfg):
"""Stops at the first problem -- the operator fixes one, reruns, finds the next."""
if int(cfg["seed"]) < 0:
raise ValueError(f"seed must not be negative: {cfg['seed']}")
if not cfg["tests"]:
raise ValueError("tests must not be empty")
if not cfg["timeout_s"].isdigit():
raise ValueError(f"timeout_s must be a whole number: {cfg['timeout_s']!r}")
return "config ok"
def check_all(cfg):
"""Collects every problem, then raises once."""
problems = []
if int(cfg["seed"]) < 0:
problems.append(f"seed must not be negative: {cfg['seed']}")
if not cfg["tests"]:
problems.append("tests must not be empty")
if not cfg["timeout_s"].isdigit():
problems.append(f"timeout_s must be a whole number: {cfg['timeout_s']!r}")
if problems:
raise ValueError("bad config: " + "; ".join(problems))
return "config ok"
try:
check_first(CONFIG)
except ValueError as e:
print("first ->", e)
try:
check_all(CONFIG)
except ValueError as e:
print("all ->", e)first -> seed must not be negative: -1
all -> bad config: seed must not be negative: -1; tests must not be empty; timeout_s must be a whole number: 'abc'Both are correct. check_all is kinder, and in a flow where each run costs a
licence checkout and twenty minutes, it is the difference between one correction
and three.
The rule for choosing:
- Collect when the checks are independent and a human will act on the result — configuration, command-line arguments, a submitted file.
- Fail fast when a later check depends on an earlier one passing. In
check_allabove, every check is independent; iftimeout_shad to be compared againstseed, collecting would mean running a comparison on a value already known to be bad.
Note what check_all does not do: it does not continue past validation. It
still raises, still stops the program, and still produces a non-zero exit. It
collects within the validation step only — which is why it is not Chapter
14.9's mistake wearing a disguise.
9. Re-Raising, Briefly
Chapter 14.5 Section 6 used the other form — a bare raise inside a handler,
which re-throws the exception currently being handled with its original
traceback:
def parse(field):
try:
return int(field)
except ValueError:
print(f" note: {field!r} is not an integer")
raise
try:
parse("n/a")
except ValueError as e:
print("outer saw:", e) note: 'n/a' is not an integer
outer saw: invalid literal for int() with base 10: 'n/a'raise with no argument is only legal inside a handler, and it is the correct
way to add information without taking responsibility for the failure.
10. Predict, Run, Observe
def check_runtime(value):
if not isinstance(value, (int, float)):
raise TypeError(f"runtime must be numeric, got {type(value).__name__}")
if value < 0:
raise ValueError(f"runtime must not be negative: {value}")
if value > 86400:
raise ValueError(f"runtime exceeds one day: {value}")
return value
for candidate in [182.4, -3, "182.4", 90000, 0]:
try:
print("ok:", check_runtime(candidate))
except (TypeError, ValueError) as e:
print(f"{type(e).__name__}: {e}")ok: 182.4
ValueError: runtime must not be negative: -3
TypeError: runtime must be numeric, got str
ValueError: runtime exceeds one day: 90000
ok: 0Order matters. The type check is first, because value < 0 on a string
would raise a TypeError with a worse message — Python's, not yours.
0 passes. Worth checking deliberately: a boundary value that should be
legal, and is. Writing if not value: instead of if value < 0: would have
rejected it, which is the kind of bug Chapter 9.5's truthiness chapter warned
about.
Each message names the value. Four different failures, four different sentences, none of which require opening the source to interpret.
11. Exercises
Exercise 1 — silent corruption. Reproduce Section 2 and add a fifth function that uses the seed in some other way. Confirm no call raises anything, and list every wrong artefact a real run would leave behind.
Exercise 2 — one line. Add a single raise to that program so the bad seed
is rejected. Run it and copy the last line of the traceback.
Exercise 3 — the message. Write the same check three ways: no message, a message without the value, and a message with the value. Say which you could debug from a log file alone.
Exercise 4 — !r. Write a message about a field that is an empty string,
with and without !r. Explain the difference to someone reading a log.
Exercise 5 — the right type. For each, say which exception you would raise: (a) a seed that is a string (b) a seed that is negative (c) a config key that is missing (d) a function stub you have not written (e) a runtime of 400000.
Exercise 6 — the boundary. Take any two-function script and identify its boundary. Move a validation check from the inner function to the boundary and say what the inner function may now assume.
Exercise 7 — the -O proof. Write a validation using assert, run it
normally and then with python3 -O. Record both outputs and write one sentence
for a code review.
Exercise 8 — ordering. Write three checks where putting them in the wrong order produces a worse message, and show both orders.
12. Summary
raisethrows an exception deliberately, and from that moment it behaves exactly like one Python raised.- An unchecked bad value usually does not crash — it produces a plausible wrong answer, which is far more expensive.
- Raise at the boundary, where the value enters your control and the knowledge of what is legal still exists.
- Everything downstream of a boundary check may assume the value is good.
- Put the offending value in the message, and use
!rfor strings so empty and whitespace values are visible. - A good message says what was expected and what arrived; the traceback supplies the location.
- Choose the conventional type —
ValueErrorfor bad content,TypeErrorfor a bad type — so callers can catch meaningfully. - Check the type before the value, or Python's error replaces yours.
- Never validate input with
assert—python3 -Oremoves it entirely. assertis for invariants you believe cannot fail;raiseis for anything coming from outside.- Collect independent validation problems and raise once; fail fast only when a later check depends on an earlier one passing.
- A bare
raiseinside a handler re-throws with the original traceback.
The next chapter gives those raises their own types, so that a tool failure and a parse failure can be told apart by a caller who sees neither.
Where this fits
Part of the Python curriculum.
