RizTech Academy logo
RizTech Academy
Errors and DebuggingLesson 4 of 520 min

Raising your own exceptions

So far you have handled errors other people's code raised. Now you raise your own — so your functions can refuse bad input loudly instead of quietly producing a wrong answer.

raise

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

set_age(-5)
Traceback (most recent call last):
  File "app.py", line 6, in <module>
    set_age(-5)
  File "app.py", line 3, in set_age
    raise ValueError("Age cannot be negative")
ValueError: Age cannot be negative

raise stops the function immediately — like return, but signalling failure rather than producing a result. The caller either handles it or the program stops.

Why this is better than returning an error

Module 5 flagged a function that returned a number sometimes and a string others. Compare the options:

def set_age(age):
    if age < 0:
        return None          # caller must remember to check
def set_age(age):
    if age < 0:
        return "Error"       # caller might do arithmetic on it
def set_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")

Only the third cannot be ignored. A caller who forgets to check a None gets a confusing TypeError twenty lines later, in a function that did nothing wrong. A caller who ignores an exception gets a clear traceback pointing straight at the actual problem.

This is the important idea: fail where the problem is, not where the symptom appears. An exception at the point of bad input is far cheaper to debug than a wrong value travelling through three functions before breaking something else.

Returning None is still fine when "not found" is a normal, expected outcome — find_user returning None is reasonable, and dict | None documents it. Raising is for input that is genuinely wrong.

Choosing the type

Use the built-in that fits:

raise ValueError("Age cannot be negative")          # right type, bad value
raise TypeError("Expected a list, got a string")    # wrong type entirely
raise KeyError("email")                             # missing key
raise FileNotFoundError("config.json not found")
raise NotImplementedError("Not written yet")

ValueError covers most validation. Reach for TypeError only when the type is genuinely wrong, not merely the value.

Avoid raise Exception("..."). It is the raising equivalent of a bare except: — callers cannot catch it selectively without catching everything.

Write messages for whoever has to fix it

raise ValueError("Invalid input")
raise ValueError(f"Age must be between 0 and 150, got {age}")

The second names the rule and the offending value, so the traceback alone usually solves the problem. Error messages are documentation that only appears when it is needed — spend the extra ten seconds.

Validating at the edges

The most useful place to raise is where data enters your program:

def create_user(name: str, email: str, age: int) -> dict:
    """Create a user record.

    Raises:
        ValueError: If any field is missing or out of range.
    """
    if not name.strip():
        raise ValueError("Name cannot be empty")
    if "@" not in email:
        raise ValueError(f"Not a valid email address: {email!r}")
    if not 0 < age < 150:
        raise ValueError(f"Age must be between 1 and 149, got {age}")

    return {"name": name.strip(), "email": email.lower(), "age": age}

Guard clauses again, now enforcing rules. Past those checks, the rest of the function can assume its inputs are good — which is what keeps the body simple.

Note {email!r} — the !r uses repr(), so the value appears quoted. That matters when the problem is an invisible space or an empty string, which print identically to nothing at all.

Note also the Raises: line in the docstring. A caller cannot see what a function throws from its signature, so that is the only place to record it.

Your own exception types

When you want callers to handle your failures specifically, define a type:

class InsufficientStockError(Exception):
    """Raised when an order asks for more than is available."""


def reserve(item: str, quantity: int, available: int) -> None:
    if quantity > available:
        raise InsufficientStockError(
            f"Requested {quantity} of {item}, only {available} available"
        )

Classes are module 9, and you can use this now without understanding the machinery: a line naming your exception, (Exception) after it, and a docstring as the body.

The reason to bother is that callers can single it out:

try:
    reserve("rice", 10, 3)
except InsufficientStockError:
    suggest_alternatives()
except ValueError:
    show_form_error()

With ValueError for both, that distinction would be impossible.

Do not create one per error. A handful of meaningful types for an application is plenty; the built-ins cover the rest.

Re-raising

Sometimes you want to react to an error without swallowing it:

try:
    process(order)
except ValueError:
    log("Order processing failed")
    raise

A bare raise inside except re-raises the exception you caught, with its original traceback intact. Logging and letting it continue upward is a common and correct pattern.

Compare with raising a new one, which loses the original context unless you chain it:

try:
    config = json.loads(text)
except json.JSONDecodeError as error:
    raise ValueError(f"Could not read config: {error}") from error

from error produces the "direct cause of the following exception" traceback from the reading lesson — the caller sees a meaningful error and can still see what actually broke. Without from, you would be throwing away the only clue.

assert is not for this

assert age > 0, "Age must be positive"

That raises AssertionError when false, and looks like a neat shortcut. It is not, for one reason: assertions are removed entirely when Python runs with -O. Validation that vanishes in production is worse than none.

Use assert for checking your own assumptions during development, and in tests — which is exactly how the refactor lesson used it. Use raise for anything that validates real input.

Practice

  1. Write set_age(age) raising ValueError for negatives. Call it and read the traceback.
  2. Rewrite it three ways — returning None, returning a string, raising — and write a careless caller for each. Which failures are hardest to diagnose?
  3. Write create_user with the three validations. Test each failing case.
  4. Improve a vague message like "Invalid input" to name the rule and show the value. Use !r and test it with " ".
  5. Define InsufficientStockError and use it in reserve. Write a caller that handles it differently from ValueError.
  6. Write a function that logs and re-raises with a bare raise. Confirm the original traceback survives.
  7. Use raise ... from ... to wrap a ValueError from int() in something more meaningful. Read both halves of the resulting traceback.
  8. Add Raises: sections to the docstrings of everything above.
  9. Explain in one sentence why assert should not validate user input.

Next: finding bugs that do not raise anything at all.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship