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
raise: raising an exception on purpose- The message matters: actionable errors
- Raise or return
None? The contract criterion - Guard clauses: validate at the door
sell(title, units): the central example- Re-raising (bare
raise) and chaining (raise ... from e) - 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
Nonewhen 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 <= 0is aValueError, not areturn None: asking for −3 copies is not "a legitimate business answer", it's a broken caller (or a bug upstream). Noise, not silence.sell()usesfind_book()internally and translates itsNoneinto 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 decideraise 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 = documentedNone. - 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 trivialtry/exceptblocks. Apply the contract table, not a reflex. - Catching your own exception two lines later. If you raise and catch in the same function, the
raisewas almost certainly unnecessary: it was a plainif.raiseis for crossing responsibility boundaries. - Losing the original cause when translating.
except JSONDecodeError: raise RuntimeError("unreadable catalog")withoutfrom eleaves the translation looking like an accident. Withfrom ethe 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
restock(catalog, title, units). Write the function that adds stock to an existing book. Contract:TypeErrorifunitsisn't anint;ValueErrorifunits <= 0;ValueErrorif 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.Noneorraise? Decide and justify in one sentence, using the contract table: (a)find_member(code)when"LUIS-001"isn't inmembers.json; (b) the same function when it receivescode=""; (c)price_with_vat(price)whenpriceis negative; (d)last_sale()whensales.csvis empty after a day with no sales.- Translating with
from. Writeload_members()so that it readsdata/members.jsonand, if the JSON is corrupt, raisesRuntimeError("members file is unreadable; restore a backup")chained to the original error withfrom. If the file doesn't exist, it must return[](why the asymmetry between the two failures?).
Solutions
-
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.stockrestock(catalog, "Faust", 5)→ 10. The structure mirrorssell(): argument guards, state guard, mutation last. When two functions share a contract this closely, they're asking for a common exception hierarchy — 07-04. -
(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 returnNoneand raise: they answer different situations. (c)raise ValueError: a negative price violates the contract; returningNonewould 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. -
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 eThe asymmetry is the one from 07-02, now with contract vocabulary: missing file is a legitimate state (no members yet: empty list, like the
Nonein 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
