Raising Exceptions & Custom Messages
try/except handles other people’s errors. raise creates your own — the mechanism that lets your functions enforce their own rules and fail with messages that actually help.
The problem: silent wrongness
def set_age(age):
if age < 0:
print("Age can't be negative") # just... prints and continues?
return age
age = set_age(-5) # prints a warning, then returns -5 anyway!
print(age) # -5 — the bad value is loose in your program
A printed warning doesn’t stop anything. The invalid value flows onward, corrupting data downstream. What set_age should do is refuse — loudly:
raise — refusing with authority
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
set_age(-5)
Output:
ValueError: Age cannot be negative
raise throws the error immediately — the function stops, the invalid value never escapes, and the caller gets a clear, actionable message. You can raise any built-in exception type with a custom message:
raise ValueError("expected a number")
raise TypeError("expected a list")
raise KeyError(f"no config for {key}")
raise ZeroDivisionError("denominator was zero")
Pick the type that names the kind of problem; use the message for the specifics.
The caller decides what to do
Here’s the beauty of the design — the function raises, the caller chooses:
def divide(a, b):
if b == 0:
raise ValueError("denominator cannot be zero")
return a / b
# Caller 1: handle it
try:
print(divide(10, 0))
except ValueError as err:
print(f"Blocked: {err}")
# Caller 2: let it crash (maybe it's a programmer error)
print(divide(10, 0)) # full traceback — loud and proud
Same function, two policies. That separation — the function detects, the caller decides — is the architecture of all error handling.
Validating inputs — the guard pattern
def calculate_average(marks):
if len(marks) == 0:
raise ValueError("marks list is empty")
if any(m < 0 or m > 100 for m in marks):
raise ValueError("marks must be between 0 and 100")
return sum(marks) / len(marks)
print(calculate_average([80, 90])) # 85.0
print(calculate_average([])) # ValueError: marks list is empty
Guards at the top of the function check every assumption, then the real logic runs clean. Notice the empty-list guard before the math — without it, sum([]) / len([]) would crash with the confusing ZeroDivisionError instead of your clear message.
When to raise vs when to return
| Situation | Choice |
|---|---|
| The input breaks the function’s contract | raise — the caller must know |
| A user typed something odd | return a message / handle upstream |
| An expected alternative outcome | return normally (empty list, None, False) |
Rule of thumb: raise for programmer errors and impossible values; return for expected outcomes. int("abc") raising is correct — that’s a contract violation. A search function returning an empty list is correct — “not found” is a normal result.
Common Errors & Fixes
TypeError: exceptions must derive from BaseException— you raised a string:raise "oops". Raise an exception type:raise ValueError("oops").- My raise is skipped — check it’s actually reachable (right branch, right condition).
- Raised error crashes my own test — wrap the test call in try/except; that’s the caller’s job now.
✅ Checkpoint
- What does
raise ValueError("bad input")do to the function’s execution? (Stops it immediately) - Who decides how a raised error is handled? (The caller — via try/except)
- Search returns “not found” — raise or return? (Return — an empty result is a normal outcome)
- Write the guard for a function that requires a non-empty list. (if len(items) == 0: raise ValueError(“items is empty”))
Next: file I/O — making data outlive the program.