Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
runs/seed_41/sim.log on shard c
runs/seed_-1/sim.log on shard c

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
guarded.py (BROKEN ON PURPOSE)
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: -1

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
invalid seed
seed must not be negative: -1

The 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:

  1. what was expected — "must not be negative";
  2. what arrived — -1;
  3. enough context to locate it — which the traceback supplies for free.

For strings, use !r so that whitespace and emptiness are visible:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for raw in ["182.4", " ", ""]:
    print(f"could not parse runtime: {raw!r}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

SituationRaise
The type is right, the content is notValueError
The type itself is wrongTypeError
A key or name that should exist does notKeyError
The operation makes no sense in this stateRuntimeError
You have not written it yetNotImplementedError
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ok: 41
ValueError: seed must not be negative: -1
TypeError: seed must be an int, got str

Picking 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
running with seed 41
rejected: seed must not be negative: -5

Why 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:

Azvya Education Pvt. Ltd.VLSI Mentor
as.py (BROKEN ON PURPOSE)
def set_seed(seed):
    assert seed >= 0, f"seed must not be negative: {seed}"
    return seed


print(set_seed(41))
print(set_seed(-1))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: -1

That 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ python3 -O as.py
41
-1

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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_all above, every check is independent; if timeout_s had to be compared against seed, 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ok: 182.4
ValueError: runtime must not be negative: -3
TypeError: runtime must be numeric, got str
ValueError: runtime exceeds one day: 90000
ok: 0

Order 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

  • raise throws 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 !r for 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 — ValueError for bad content, TypeError for 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 -O removes it entirely.
  • assert is for invariants you believe cannot fail; raise is 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 raise inside 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.