Courses/Python Mastery/Module 8: Errors & Files
Module 8 · Lesson 215 minBeginner

Raising Exceptions & Custom Messages

Lesson goal
Fail loudly and clearly when your code's rules are broken.

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

code
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

code
def set_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    return age

set_age(-5)

Output:

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

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

code
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

code
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

SituationChoice
The input breaks the function's contractraise — the caller must know
A user typed something oddreturn a message / handle upstream
An expected alternative outcomereturn 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.