Tech With Galvan
Courses/Python Mastery/Errors & Files

Raising Exceptions & Custom Messages

15 min • Beginner • Module 8

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

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

  1. What does raise ValueError("bad input") do to the function’s execution? (Stops it immediately)
  2. Who decides how a raised error is handled? (The caller — via try/except)
  3. Search returns “not found” — raise or return? (Return — an empty result is a normal outcome)
  4. 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.