Exceptions C: raise — Throwing an Error Ourselves
- Throw an Exception ourselves with
raiseand a clear message - Choose a suitable error type — ValueError, TypeError and more
- Understand the division of work: the function detects a problem and throws, the caller decides how to handle it
- Pass an error on from inside
exceptwithraisealone
What will we learn?
Until now Python threw errors, and we caught them. Today we will learn to throw them ourselves, with raise, when our code detects an invalid state.
Why do we need this?
Suppose you wrote a function withdraw(balance, amount) for withdrawing from a bank account. What should it do when asked to withdraw minus 500? Options:
- Do it silently — the balance grows by 500. A serious money bug.
- Print “error” — but whoever called the function does not know it failed, and keeps working with a wrong result.
- Return a special value (like
-1orNone) — the caller must remember to check it. If they forget, the bug “leaks”. - Throw an error — the caller must deal with it, or the program stops with a clear message exactly at the problematic place.
Option 4 is the professional way. In the OOP topic we will use it for Validation inside classes, and in the backend and databases topics a FastAPI server will turn errors like these into an HTTP 400 or 404 response.
Explanation from the basics
raise
raise throws an Exception, exactly as Python does when something breaks. From the moment of the raise:
- The function stops immediately. No line after it runs, and no value is returned.
- The error “rises” to whoever called the function. If they catch it with
except, excellent. If not, it keeps rising, and in the end the program crashes with a Traceback.
The right division: who detects and who handles
| Role | Who | What they do |
|---|---|---|
| Detects the problem | The function that receives the data | Checks and throws (raise) |
| Decides what to do | Whoever called the function | Catches (except) and decides: a message, another attempt, a default |
The waiter (the caller) asks the storekeeper (the function) for 5 tomatoes. There are no tomatoes. A good storekeeper does not silently bring 5 apples (a wrong value), and does not “decide” on their own to change the dish. They announce (raise): “No tomatoes!”. And the waiter, who knows the customer, decides: offer another dish or apologize. Each one is responsible for their own area.
Which error type to throw?
Use the built-in types, by meaning:
| Type | When |
|---|---|
ValueError | The type is right, but the value is invalid: a negative age, a zero amount, an email without @ |
TypeError | The type is wrong: text was received instead of a number |
KeyError / LookupError | Looking for something that does not exist |
PermissionError | No permission for the action |
The message should explain what is wrong and why: "amount must be positive, got -500", not "error".
(In the OOP topic, after we learn classes, we will see how to create our own error types, like InsufficientFundsError.)
Syntax
raise ValueError("clear message about what is wrong")
# typical pattern: validate at the start of a function
def function_name(value):
if not is_valid(value):
raise ValueError("why it is invalid")
# ... normal work, only with valid data
# the caller decides
try:
function_name(bad_value)
except ValueError as error:
print("Problem:", error)
# re-raise: handle partly, then pass the same error up
except ValueError:
print("logging the problem...")
raiseFirst code example
week-2/withdraw.py:
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("amount must be positive, got " + str(amount))
if amount > balance:
raise ValueError("insufficient funds: balance " + str(balance) + ", requested " + str(amount))
return balance - amount
print(withdraw(1000, 300))
print(withdraw(1000, -500))
print("this line never runs")Output:
700
Traceback (most recent call last):
File "withdraw.py", line 10, in <module>
print(withdraw(1000, -500))
File "withdraw.py", line 3, in withdraw
raise ValueError("amount must be positive, got " + str(amount))
ValueError: amount must be positive, got -500Breaking down the code line by line
| Line | Explanation |
|---|---|
if amount <= 0: | Validation (checking the input): check before doing anything. |
raise ValueError("...") | Create a ValueError object with a message, and throw it. The function stops here. |
if amount > balance: | A second check, for another business rule. |
return balance - amount | We get here only if all the checks passed. From here on, the data is safe. |
withdraw(1000, 300) | Passes the checks and returns 700. |
withdraw(1000, -500) | An error is thrown. Nobody catches it, so the program crashes with a clear Traceback. The last line of code does not run. |
Notice the quality of the Traceback: the message says exactly what the problem is (amount must be positive) and which value caused it (-500). That is what a good error looks like.
Another example
The caller decides. The same function, with code that catches and handles. week-2/atm_safe.py:
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("amount must be positive, got " + str(amount))
if amount > balance:
raise ValueError("insufficient funds: balance " + str(balance) + ", requested " + str(amount))
return balance - amount
balance = 1000
requests = [200, -50, 5000, 300]
for amount in requests:
try:
balance = withdraw(balance, amount)
except ValueError as error:
print("Rejected:", error)
else:
print("Withdrew", amount, "- new balance:", balance)
print("Final balance:", balance)Output:
Withdrew 200 - new balance: 800
Rejected: amount must be positive, got -50
Rejected: insufficient funds: balance 800, requested 5000
Withdrew 300 - new balance: 500
Final balance: 500Breakdown:
withdrawprints nothing and does not know what will happen next. It only detects and throws.- The loop (the caller) decides: the request is rejected with a message, and the loop continues to the next request.
- When the error is thrown, the assignment
balance = withdraw(...)is not executed, so the balance stays as it was. That is exactly what we wanted. - Tomorrow the same
withdrawcan be used on a website, in an app or in a server, and each of them will handle the error its own way (a message on the screen, an HTTP400response…).
raise with nothing after it — passing it on
Sometimes you want to do something when there is an error (for example, record it), but not “make it disappear”:
try:
balance = withdraw(balance, 5000)
except ValueError:
print("Audit: a failed withdrawal attempt was recorded")
raiseraise alone, inside except, throws exactly the same error again. It keeps rising to whoever is above.
Common mistake
Mistake 1: print instead of raise.
def withdraw(balance, amount):
if amount > balance:
print("Not enough money!")
return balance - amountThe function prints a message, but continues and returns a negative balance! The caller does not know something went wrong. The print is also “stuck” on the screen, and means nothing in a server or an app.
Mistake 2: an unhelpful message.
raise ValueError("error")
raise ValueError("bad input")Which input? What was the value? What is expected? A good message = what the condition is + what was received: "age must be between 0 and 120, got 150".
Mistake 3: throwing the most general type.
raise Exception("amount must be positive")Exception is the parent of almost all errors. Whoever wants to catch only this problem will need except Exception, which also catches unrelated bugs. Choose a specific type (ValueError).
Mistake 4: catching and throwing, and losing the context.
except ValueError:
raise ValueError("something failed")The original message (with the details) was lost. If you want to pass it on, raise alone keeps the original error.
Practice
The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.
Exercise 1: A valid age
Write a function set_age(age) that returns the age if it is between 0 and 120, and otherwise throws a ValueError with the message shown in the expected output (including the age that was received). Then write a loop that tries the ages [25, -3, 150]: for a valid age it prints ok: and the age, and for an error — error: and the message.
Expected output:
ok: 25
error: age must be between 0 and 120, got -3
error: age must be between 0 and 120, got 150Show solution
MIN_AGE = 0
MAX_AGE = 120
def set_age(age):
if age < MIN_AGE or age > MAX_AGE:
raise ValueError("age must be between 0 and 120, got " + str(age))
return age
for age in [25, -3, 150]:
try:
print("ok:", set_age(age))
except ValueError as error:
print("error:", error)ok: 25
error: age must be between 0 and 120, got -3
error: age must be between 0 and 120, got 150The function only detects the problem and throws. Its caller — the loop — decides what to do.
Exercise 2: A username
Write a function validate_username(name) that throws a ValueError:
- if the name is shorter than 3 characters — with the message
too short; - if it contains a space — with the message
no spaces allowed.
If the name is valid — it returns it.
for name in ["dana", "al", "big boss"]:
try:
print("valid:", validate_username(name))
except ValueError as error:
print(name, "->", error)Expected output:
valid: dana
al -> too short
big boss -> no spaces allowedShow solution
MIN_USERNAME_LENGTH = 3
def validate_username(name):
if len(name) < MIN_USERNAME_LENGTH:
raise ValueError("too short")
if " " in name:
raise ValueError("no spaces allowed")
return name
for name in ["dana", "al", "big boss"]:
try:
print("valid:", validate_username(name))
except ValueError as error:
print(name, "->", error)valid: dana
al -> too short
big boss -> no spaces allowedExercise 3: The caller decides
The following function throws an error for a negative price:
def apply_discount(price, percent):
if price < 0:
raise ValueError("price cannot be negative")
return price - price * percent / 100Write two functions that use it in two different ways:
report_price(price)— prints the price after a 10% discount, orinvalid priceif there was an error.price_or_zero(price)— returns the price after a 10% discount, or 0 if there was an error.
report_price(200)
report_price(-5)
print(price_or_zero(50))
print(price_or_zero(-5))Expected output:
180.0
invalid price
45.0
0Show solution
def apply_discount(price, percent):
if price < 0:
raise ValueError("price cannot be negative")
return price - price * percent / 100
def report_price(price):
try:
print(apply_discount(price, 10))
except ValueError:
print("invalid price")
def price_or_zero(price):
try:
return apply_discount(price, 10)
except ValueError:
return 0
report_price(200)
report_price(-5)
print(price_or_zero(50))
print(price_or_zero(-5))180.0
invalid price
45.0
0The same function, two different ways of handling it. That is why apply_discount throws and does not print by itself: it does not know what the caller needs.
Exercise 4: return None versus raise
Two versions of the same function:
# A # B
def withdraw(balance, amount): def withdraw(balance, amount):
if amount > balance: if amount > balance:
print("Not enough money!") raise ValueError("not enough money")
return None return balance - amount
return balance - amountSomeone wrote: balance = withdraw(100, 500). What will happen in each version, and why is B better?
Show solution
- A: “Not enough money!” is printed, and
balancegetsNone. The program continues with a balance ofNone, and will crash later somewhere else — far from the real problem. - B: a
ValueErroris thrown exactly at the place of the problem. If the caller did not handle it, the Traceback points straight at the problematic withdrawal. If they did — they decide for themselves what to show. - B also does not print by itself: maybe the caller is a server that needs to return a message, not print to the screen.
Exercise 5: Converting a grade
Write a function parse_grade(text) that converts text to a grade. It throws a ValueError if the text is not a number (that already happens in int), or if the grade is outside the range 0 to 100 — with the message shown in the expected output (including the grade that was received).
for text in ["87", "abc", "120"]:
try:
print("grade:", parse_grade(text))
except ValueError as error:
print("rejected:", error)Expected output:
grade: 87
rejected: invalid literal for int() with base 10: 'abc'
rejected: grade must be 0-100, got 120Show solution
def parse_grade(text):
grade = int(text)
if grade < 0 or grade > 100:
raise ValueError("grade must be 0-100, got " + str(grade))
return grade
for text in ["87", "abc", "120"]:
try:
print("grade:", parse_grade(text))
except ValueError as error:
print("rejected:", error)grade: 87
rejected: invalid literal for int() with base 10: 'abc'
rejected: grade must be 0-100, got 120parse_grade does not catch the error from int — it lets it pass on to the caller, exactly like the error it throws itself.
Summary
raise SomeError("message")throws an error. The function stops immediately, and no value is returned.- The function detects and throws. The caller catches and decides.
ValueErrorfor an invalid value,TypeErrorfor a wrong type. Not a generalException.- A good message = what the rule is + what was received.
raisealone insideexceptpasses the same error on.- Throwing is better than printing “error” or returning a special value, because you cannot “forget to check” an error.