Raising Your Own Exceptions
James writes a function that sets a note's word count. He passes in -5. The function silently stores it. Later, another function tries to categorize the note and produces nonsense. James spends ten minutes tracing the bug back to the negative word count.
"If the function had complained when you passed -5, you would have found the bug instantly," Emma says. "That is what raise does. It stops execution right at the point where something is wrong, instead of letting bad data travel through your program."
James remembers seeing raise in Chapter 52, Lesson 4. He read AI-generated code that contained raise ValueError(...) and tested it with pytest.raises. He understood what it did. Now he writes it himself.
Raising an exception means your code deliberately signals an error. Instead of returning a bad value and hoping the caller notices, you stop execution with a clear message: "this input is invalid, here is why." The caller can then catch the exception (Lesson 1) or let it crash with a useful error message.
Python's raise ValueError("message") is equivalent to throw new ValueError("message") in Java or throw new Error("message") in JavaScript. Python does not use the throw keyword. The raise keyword both creates and throws the exception in one step.
The raise Statement
The raise keyword creates an exception and stops execution:
def set_word_count(count: int) -> int:
"""Set a word count, rejecting negative values.
Raises:
ValueError: If count is negative.
"""
if count < 0:
raise ValueError(f"Word count cannot be negative: {count}")
return count
When count is -5, Python executes raise ValueError(...) and immediately stops. No return value is produced. The caller sees a ValueError with the message "Word count cannot be negative: -5".
Notice the docstring: it documents which exceptions the function raises and when. In Chapter 49, Lesson 3, your docstrings said "Raises ValueError" as a promise. Now you implement that promise.
Testing Every raise
Every raise statement needs a corresponding test. Write the function and the test together:
import pytest
def set_word_count(count: int) -> int:
"""Set a word count, rejecting negative values.
Raises:
ValueError: If count is negative.
"""
if count < 0:
raise ValueError(f"Word count cannot be negative: {count}")
return count
def test_set_word_count_valid() -> None:
assert set_word_count(100) == 100
def test_set_word_count_zero() -> None:
assert set_word_count(0) == 0
def test_set_word_count_negative() -> None:
with pytest.raises(ValueError):
set_word_count(-5)
Three tests: two for valid input (positive and zero) and one for the error case. The pytest.raises(ValueError) block from Chapter 52 confirms that calling set_word_count(-5) raises a ValueError. If the function returned -5 instead of raising, the test would fail.
Meaningful Error Messages with f-strings
Compare these two error messages:
# Vague
raise ValueError("invalid input")
# Clear
raise ValueError(f"Word count cannot be negative: {count}")
The first tells you something is wrong. The second tells you what is wrong, which value caused the problem, and what the constraint is. When you are debugging at 2 AM, the difference matters.
Use f-strings to include the actual bad value in the message:
def validate_title(title: str) -> str:
"""Validate a note title.
Raises:
TypeError: If title is not a string.
ValueError: If title is empty.
"""
if not isinstance(title, str):
raise TypeError(f"Title must be a string, got {type(title).__name__}")
if len(title) == 0:
raise ValueError("Title cannot be empty")
return title
The type(title).__name__ expression gives the type name as a string (like "int" or "list"), making the error message human-readable.
When to Raise vs Return None
You have two options when a function receives bad input:
| Strategy | Code | Caller Must |
|---|---|---|
Return None | return None | Check for None before using the result |
| Raise | raise ValueError(...) | Wrap in try/except or let the program crash |
When should you choose each?
Raise when the input violates the function's contract. If set_word_count promises to return an int, returning None would break that promise. The return type would need to be int | None, forcing every caller to check for None. Raising makes the error impossible to ignore.
Return None when "not found" is a normal outcome, not an error. A find_note_by_title function might legitimately find nothing. That is not an error; it is a valid result.
# RAISE: bad input is an error
def set_word_count(count: int) -> int:
if count < 0:
raise ValueError(f"Word count cannot be negative: {count}")
return count
# RETURN NONE: "not found" is a valid outcome
def find_note_by_title(notes: list[str], title: str) -> str | None:
for note in notes:
if note == title:
return note
return None
Test both patterns:
import pytest
def test_set_word_count_rejects_negative() -> None:
with pytest.raises(ValueError):
set_word_count(-1)
def test_find_note_returns_none_when_missing() -> None:
result: str | None = find_note_by_title(["alpha", "beta"], "gamma")
assert result is None
Raising TypeError
Use TypeError when the input is the wrong type entirely, not just the wrong value:
def format_tag(tag: str) -> str:
"""Format a tag for display.
Raises:
TypeError: If tag is not a string.
ValueError: If tag is empty.
"""
if not isinstance(tag, str):
raise TypeError(f"Tag must be a string, got {type(tag).__name__}")
if len(tag) == 0:
raise ValueError("Tag cannot be empty")
return tag.lower().strip()
def test_format_tag_valid() -> None:
assert format_tag(" Python ") == "python"
def test_format_tag_wrong_type() -> None:
import pytest
with pytest.raises(TypeError):
format_tag(42) # type: ignore[arg-type]
def test_format_tag_empty() -> None:
import pytest
with pytest.raises(ValueError):
format_tag("")
The # type: ignore[arg-type] comment tells the type checker "I know this is the wrong type; I am doing it on purpose for testing." Without it, Pyright would flag the line as a type error.
PRIMM-AI+ Practice: Raising Exceptions
Predict [AI-FREE]
Look at this code without running it. For each call, predict whether it returns a value or raises an exception. Write your predictions and a confidence score from 1 to 5 before checking.
def validate_author(name: str) -> str:
if not isinstance(name, str):
raise TypeError(f"Author must be a string, got {type(name).__name__}")
if len(name) == 0:
raise ValueError("Author name cannot be empty")
if len(name) > 100:
raise ValueError(f"Author name too long: {len(name)} characters (max 100)")
return name.strip()
# Scenario 1
result1 = validate_author("Emma")
# Scenario 2
result2 = validate_author("")
# Scenario 3
result3 = validate_author("A" * 150)
Check your predictions
Scenario 1: Returns "Emma". All checks pass: it is a string, it is not empty, its length (4) is under 100.
Scenario 2: Raises ValueError with message "Author name cannot be empty". The isinstance check passes (empty string is still a string), but len(name) == 0 triggers the raise.
Scenario 3: Raises ValueError with message "Author name too long: 150 characters (max 100)". The isinstance and empty checks pass, but len(name) > 100 triggers the raise.
If you predicted all three correctly, you understand the guard-clause pattern: checks at the top, return at the bottom.
Run
Create a file called raise_practice.py with the validate_author function and the three calls. Wrap the calls that raise in try/except so the script does not crash. Run uv run python raise_practice.py and compare output to your predictions.
Investigate
Add a fourth scenario: validate_author(42). Predict which exception type is raised. After running, trace through the function and explain which if statement caught the problem first.
Modify
Change the maximum length from 100 to 50. Which scenario's behavior changes? Predict, then run to verify.
Hint
Scenario 3 passes "A" * 150, which is 150 characters. It would still exceed 50. But if you also tested "A" * 75, that would now fail with the new limit. The key insight: changing the threshold changes which inputs are valid.
Make [Mastery Gate]
Without looking at any examples, write a function called validate_word_count(count: int) -> int that:
- Raises
TypeErrorifcountis not an integer (useisinstance) - Raises
ValueErrorwith messagef"Word count cannot be negative: {count}"ifcountis negative - Returns
countif valid
Then write four tests:
test_valid_count: verifiesvalidate_word_count(100)returns100test_zero_count: verifiesvalidate_word_count(0)returns0test_negative_count: verifiesvalidate_word_count(-1)raisesValueErrortest_wrong_type: verifiesvalidate_word_count("five")raisesTypeError
Run uv run pytest to verify all tests pass.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Check Your Understanding
I just learned about raise in Python. Here is my
understanding: "You should return None when input is
invalid, because raise crashes the program." Is my
summary accurate? What am I missing?
Read the AI's response carefully. Did it explain that raise does not crash the program if the caller catches the exception? Did it discuss when to raise vs return None? Compare its explanation to the raise-vs-return table in this lesson.
Prompt 2: Generate and Review
Write a Python function called validate_email that takes
a string parameter and returns the email if it contains
an @ symbol and a dot after the @. Raise ValueError with
a descriptive message if validation fails. Use type
annotations. Include a docstring that documents the
Raises section.
Review the AI's output. Check: does it raise ValueError (not return None)? Does the error message include the actual bad value? Does the docstring have a Raises: section? If anything looks wrong, tell the AI what to fix.
What you're learning: You are applying the "meaningful error messages" principle from this lesson to evaluate AI-generated validation code.
Prompt 3: Generate Tests
Write pytest tests for the validate_email function.
Include tests for valid emails, missing @ symbol,
missing dot after @, and empty string. Use
pytest.raises where appropriate.
Review the generated tests. Does each error case use pytest.raises(ValueError)? Are the valid cases tested with assert? Run the tests to confirm they pass.
What you're learning: You are reinforcing the pattern from this lesson: every raise gets a pytest.raises test.
Key Takeaways
-
raisesignals an error deliberately. Instead of returning a bad value, your function stops execution with a clear message. The caller can catch it withtry/except(Lesson 1) or let it crash with useful diagnostic information. -
Use f-strings for meaningful messages. Include the actual bad value in the error message:
f"Word count cannot be negative: {count}"is far more helpful than"invalid input". -
Raise when input violates the contract. If the function promises to return
int, do not returnNonefor bad input. Raise an exception instead. ReturnNoneonly when "not found" is a normal, expected outcome. -
Every
raisegets apytest.raisestest. Write the raise and the test together. If you add a new validation check, add a new test that triggers it. -
ValueErrorvsTypeError: value vs type. UseValueErrorwhen the type is correct but the value is wrong (negative count). UseTypeErrorwhen the type itself is wrong (string instead of int).
Looking Ahead
You now know how to catch exceptions (Lesson 1) and raise them (this lesson). In Lesson 3, you will explore the built-in exception hierarchy to understand how Python organizes its exception types, learn the finally clause for guaranteed cleanup, and build a decision table for choosing the right exception.