So far exceptions have come to us: raised by float(), open(), json.load(). But module 5 hid a preview that went almost unnoticed: Book's __post_init__ and the properties of Product were already doing raise ValueError(...) whenever somebody attempted a negative price. Back then it was an act of faith; today it becomes a design decision. In this lesson you'll learn to raise exceptions with intent: when a situation deserves a raise and when returning None is enough, how to write messages that save a debugging session, how to re-raise what isn't yours to handle, and how to chain with raise ... from e so the trail of the original error is never lost. The central example will be Papyrus's most delicate function: sell(), the one that touches stock and till.

Contents

  1. raise: raising an exception on purpose
  2. The message matters: actionable errors
  3. Raise or return None? The contract criterion
  4. Guard clauses: validate at the door
  5. sell(title, units): the central example
  6. Re-raising (bare raise) and chaining (raise ... from e)
  7. Revisiting module 5: properties and __post_init__ with the full vocabulary

raise: raising an exception on purpose

The raise statement creates and throws an exception, exactly as Python would on its own:

def apply_discount(price, rate):
    if not 0 <= rate <= 1:
        raise ValueError(f"rate must be between 0 and 1, not {rate}")
    return price * (1 - rate)

Anatomy: raise + an exception instance (built like any object: class and arguments, usually the message). From there, the mechanism is the one from 07-01: execution stops, the exception propagates upwards, and either somebody catches it with 07-02's try/except or the program ends with its traceback.

And which type should you raise? Until we have exceptions of our own (07-04), you pick from the built-ins while respecting their meaning, the same one from the 07-01 table:

Situation Suitable type
The value is unacceptable, even though the type is right (rate=3) ValueError
The type isn't what was expected (units="two") TypeError
A key/element was looked up and isn't there KeyError / LookupError
The operation isn't valid in the object's current state RuntimeError (or one of your own, in 07-04)

Raising a ValueError when the problem is one of type misleads whoever debugs it. The exception's type is part of the message.

The message matters: actionable errors

Compare these two raises for the same problem:

raise ValueError("units error")                                    # ❌ what error? what value arrived?
raise ValueError(f"units must be a positive integer, not {units!r}")   # ✅

A good message answers three questions: what was expected, what arrived and — if it isn't obvious — what to do about it. The !r in the f-string (equivalent to applying repr()) shows the value with its quotes and its type visible: not '3' reveals that a string "3" arrived instead of the integer 3, a nuance that not 3 would hide. Remember 07-01: the raise message is exactly what will appear on the last line of the traceback, the first one whoever debugs will read. Write it for that person — who three months from now will be you.

Raise or return None? The contract criterion

This is the lesson's central design decision, and member_price() from exercise 3 of 07-02 left it open. The compass is the idea of a contract: every function promises something ("give me a valid title and units and I'll deduct stock"). So:

  • Raise an exception when the caller breaks the contract — asks for something absurd or impossible: selling −3 units, a negative price, a member code with an invalid format. That's a programming or usage error and it should make noise.
  • Return None when absence is a legitimate answer to a well-formed question: "do you have Moby-Dick?" — not having it is no error at all, it's information.
Criterion Return None Raise an exception
Is the situation a normal business outcome? Yes: "we don't have it" is an answer No: it's a contract violation
Whose fault is it? Nobody's: the question was valid The caller's: they asked for something invalid
What will the caller do, almost always? Check and move on (if book is None:) It shouldn't happen; if it does, better noise than silence
Risk if ignored? Low: None blows up soon and nearby (AttributeError) High: continuing with negative stock corrupts data
Papyrus example find_book("Moby-Dick")None sell("Hamlet", -3)ValueError

The canonical case in the left column is find_book(), which we formalize like this:

def find_book(catalog, title):
    """Return the Book matching the title, or None if it isn't there.

    A book not being in the catalog is NOT an error: it's the answer
    to the question 'do you have it?'. Hence None, and not an exception.
    """
    return catalog.get(title.strip().casefold())

dict.get() (module 4) returns None when the key doesn't exist: it fits this contract like a glove. The caller must check the result — if book is None: — and decide. Beware, though, of the None that travels unchecked: it will end up as an AttributeError: 'NoneType' object has no attribute 'price' two functions away. That's why in 07-04 we'll create a strict sister, get_book(), which raises instead of returning None, and we'll see that both can coexist: each serves a different kind of caller.

Guard clauses: validate at the door

A guard clause is a check at the start of the function that raises immediately if an argument doesn't meet the contract. It's the good kind of LBYL — the one 07-01 declared legitimate: validating data you already have in hand, with no time window and no outside world involved.

def register_member(name, code):
    # --- guards: the front door ---
    if not name.strip():
        raise ValueError("the member's name must not be empty")
    if not code.strip():
        raise ValueError("the member's code must not be empty")
    # --- from here on, safe ground ---
    ...

Advantages of validating at the door rather than mid-function:

  • Fail fast: the exception fires on the first line, with the freshly arrived data, not twenty lines later when it's already hard to tell where the bad value came from. Negative stock detected while selling is a mystery; detected at the entrance, it's a traceback pointing at the culprit.
  • The body breathes: once past the guards, the rest of the function can assume valid data without nested ifs.
  • The contract is written down: the guards document, better than any comment, what the function accepts.

sell(title, units): the central example

Let's put it all together in Papyrus's most sensitive function. Selling touches stock and till: here a silent error costs money, so the contract is defended with raise:

BOOK_VAT = 0.04

def sell(catalog, title, units):
    """Deduct stock and return the VAT-inclusive amount of the sale.

    Raises ValueError if the units aren't valid or if the sale
    is impossible (unknown title or insufficient stock).
    """
    # Guards on the arguments: the caller broke the contract
    if not isinstance(units, int):
        raise TypeError(f"units must be int, not {type(units).__name__}")
    if units <= 0:
        raise ValueError(f"units must be positive, not {units}")

    # Guards on the state: the requested sale is impossible
    book = find_book(catalog, title)
    if book is None:
        raise ValueError(f"'{title}' is not in the catalog")
    if book.stock < units:
        raise ValueError(
            f"insufficient stock of '{book.title}': "
            f"{book.stock} left, {units} requested"
        )

    # Safe ground: execute the sale
    book.stock -= units
    return round(book.price * units * (1 + BOOK_VAT), 2)

Design decisions, one by one:

  • units <= 0 is a ValueError, not a return None: asking for −3 copies is not "a legitimate business answer", it's a broken caller (or a bug upstream). Noise, not silence.
  • sell() uses find_book() internally and translates its None into an exception. It's an elegant pattern: the tolerant function answers questions; the function that executes actions turns absence into a contract violation, because selling what doesn't exist is indeed an error.
  • The stock message carries the three numbers that matter: which book, how many are left, how many were requested. In 07-04 those three pieces of data will be promoted to attributes of a custom exception (InsufficientStockError), so that the code — not just the human — can read them.
  • The mutation goes last. All the guards are evaluated before touching book.stock. If anything fails, the catalog stays intact: no half-finished sales.

With the canonical figures: sell(catalog, "Hamlet", 2) → the 6 in stock become 4 and it returns 9.95 * 2 * 1.04 = 20.70. sell(catalog, "Hamlet", 99)ValueError: insufficient stock of 'Hamlet': 6 left, 99 requested (nothing, not even the 6, has changed).

Re-raising (bare raise) and chaining (raise ... from e)

Sometimes you catch an exception but discover it's not entirely yours to handle. Two tools:

Bare raise inside an except re-raises the same exception, with its original traceback intact. Useful for reacting in passing (reporting, logging, cleaning up) without appropriating the error:

try:
    amount = sell(catalog, title, units)
except ValueError:
    print("Sale failed; the catalog has NOT been modified.")
    raise           # let whoever is higher up decide

raise NewException(...) from e raises a different exception chained to the original. It's the tool for translating low-level technical errors into the caller's language, without destroying the evidence:

def load_catalog():
    path = BASE / "data" / "catalog.json"
    try:
        with open(path, encoding="utf-8") as f:
            records = json.load(f)
    except json.JSONDecodeError as e:
        raise RuntimeError(
            f"unreadable catalog at {path}; restore a copy from backups/"
        ) from e
    return {r["title"].strip().casefold(): Book(**r) for r in records}

The resulting traceback shows both exceptions, joined by the line The above exception was the direct cause of the following exception: — the JSONDecodeError at the top with the exact line of the broken JSON, the RuntimeError underneath with the advice to restore a backup. Whoever calls handles a high-level error; whoever debugs keeps the root cause. (Note for comparison: in 07-02 we resolved this case with SystemExit(1), a terminal-program decision; from e is the library version, which informs without deciding for the caller. In 07-04, RuntimeError will give way to an exception of our own.)

And what happens if you raise without from inside an except? Python chains anyway, but with the label During handling of the above exception, another exception occurred: — which suggests an accident inside the handler, not a deliberate translation. from e declares intent; use it whenever you translate.

Tool What it does When to use it
raise Type("msg") Raises a new exception Contract violation detected by you (guards)
Bare raise (in except) Re-raises the current one, traceback intact Reacting in passing without appropriating the error
raise Type("msg") from e Raises another, chaining the cause Translating a technical error into the caller's language

Revisiting module 5: properties and __post_init__ with the full vocabulary

Let's close the circle. This is what we wrote in module 5, back then as a recipe:

@dataclass
class Book:
    title: str
    price: float
    stock: int = 0

    def __post_init__(self):
        if self.price < 0:
            raise ValueError(f"price cannot be negative: {self.price}")
        if self.stock < 0:
            raise ValueError(f"stock cannot be negative: {self.stock}")

With today's vocabulary, every piece has a name: __post_init__ is a constructor guard clause — it validates at the door, fails fast, and guarantees the invariant that a Book with a negative price cannot exist anywhere in Papyrus. It raises ValueError because the type is right and the value isn't. The setter of Product's price property does the same on every later assignment: the contract isn't signed only at birth, it's defended for the object's whole life. And this explains something from 07-02: when load_catalog() rebuilds with Book(**data), a JSON with "price": -5 triggers this ValueError automatically — module 5's validation also protects module 6's persistence boundary, for free. Well-designed systems fit together like that.

Common Mistakes and Tips

  • Returning error codes (-1, False, "") instead of raising. The error travels disguised as data and blows up far away, unreadable. In Python, broken contract = exception; legitimate absence = documented None.
  • Raising for everything, even normal absences. The opposite extreme: if find_book() raised for every title not found, querying the catalog would fill up with trivial try/except blocks. Apply the contract table, not a reflex.
  • Catching your own exception two lines later. If you raise and catch in the same function, the raise was almost certainly unnecessary: it was a plain if. raise is for crossing responsibility boundaries.
  • Losing the original cause when translating. except JSONDecodeError: raise RuntimeError("unreadable catalog") without from e leaves the translation looking like an accident. With from e the chain is explicit and complete.
  • Messages without the guilty data. raise ValueError("invalid units") forces a debugging session; raise ValueError(f"units must be positive, not {units!r}") is fixed at a glance.
  • Tip: write the docstring with the contract first ("raises X if...") and the guards that enforce it afterwards. If you don't know what to promise, you don't yet know what to raise.
  • Tip: always mutate state after all the guards. A function that half-validates and half-modifies leaves corrupt data, which is worse than any exception.

Exercises

  1. restock(catalog, title, units). Write the function that adds stock to an existing book. Contract: TypeError if units isn't an int; ValueError if units <= 0; ValueError if the title isn't in the catalog (Ana doesn't restock what she hasn't registered). It returns the resulting stock. Test it by restocking 5 copies of Faust (which M6 left at 5): it must return 10, and the invalid cases must raise with messages that include the guilty data.
  2. None or raise? Decide and justify in one sentence, using the contract table: (a) find_member(code) when "LUIS-001" isn't in members.json; (b) the same function when it receives code=""; (c) price_with_vat(price) when price is negative; (d) last_sale() when sales.csv is empty after a day with no sales.
  3. Translating with from. Write load_members() so that it reads data/members.json and, if the JSON is corrupt, raises RuntimeError("members file is unreadable; restore a backup") chained to the original error with from. If the file doesn't exist, it must return [] (why the asymmetry between the two failures?).

Solutions

  1. def restock(catalog, title, units):
        """Add units to the stock of an already catalogued book.
    
        Raises TypeError/ValueError if the contract is violated.
        Returns the resulting stock.
        """
        if not isinstance(units, int):
            raise TypeError(f"units must be int, not {type(units).__name__}")
        if units <= 0:
            raise ValueError(f"units must be positive, not {units}")
        book = find_book(catalog, title)
        if book is None:
            raise ValueError(f"'{title}' is not in the catalog; register it first")
        book.stock += units
        return book.stock
    

    restock(catalog, "Faust", 5) → 10. The structure mirrors sell(): argument guards, state guard, mutation last. When two functions share a contract this closely, they're asking for a common exception hierarchy — 07-04.

  2. (a) None: asking about a member who doesn't exist is a legitimate query; the absence is information ("not a member → no discount"). (b) raise ValueError: an empty code isn't a valid question but a broken caller — guard clause. The same function can thus return None and raise: they answer different situations. (c) raise ValueError: a negative price violates the contract; returning None would let the bug slip by in silence. (d) None (or [] if it returns a list): a day with no sales is a normal business outcome, not an error — the till close should show 0.00 EUR, not a traceback.

  3. def load_members():
        path = BASE / "data" / "members.json"
        try:
            with open(path, encoding="utf-8") as f:
                return json.load(f)
        except FileNotFoundError:
            return []
        except json.JSONDecodeError as e:
            raise RuntimeError("members file is unreadable; restore a backup") from e
    

    The asymmetry is the one from 07-02, now with contract vocabulary: missing file is a legitimate state (no members yet: empty list, like the None in the table); corrupt file means data exists but we can't read it — returning [] would write it off in silence and the next save would clobber it. A contract broken by the outside world: an exception, translated into Papyrus's language with the cause chained on.

Conclusion

raise has turned Papyrus from reactive into assertive: its functions no longer just absorb other people's errors, they defend contracts of their own. You have the criterion (None for legitimate absences like find_book(), an exception for broken contracts like sell() with negative units), the technique (guard clauses that fail fast and mutate late, messages with the guilty data via !r) and the propagation tools (bare raise to avoid appropriating the error, raise ... from e to translate without destroying the evidence). Even module 5's validations have revealed their true name: constructor guards protecting invariants. But notice a crack: sell() raises ValueError for "negative units" and for "unknown title" and for "insufficient stock" alike — and whoever catches that ValueError can't tell them apart without reading the message with their eyes. Code deserves better than parsing strings. The next lesson builds Papyrus's own exception hierarchy — PapyrusError, BookNotFoundError, InsufficientStockError — with attributes machines can read, not just humans.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved