Week 2 · Python for real work
Exceptions and error handling
What happens when your code hits a problem, how to catch errors and raise your own, and how to decide which one to do.
Real data is messy, and files go missing. You can't prevent every problem, but you can decide what your program does when one happens. This page shows you how.
How to follow along
Work in your learn-python project. Create a file called errors.py, type each example into it and run it with:
uv run errors.py
Compare what you see with the Output under each example, and clear the file before you type the next one. Some examples end in an error on purpose. For those, the output shows only the last line of the traceback.
What an exception is
When Python can't carry on, it stops what it's doing and creates an exception: an object that describes what went wrong. "Exception" and "error" mean almost the same thing, and you'll hear both.
If nothing deals with the exception, the program stops and shows a traceback. Read a traceback from the bottom up: the last line names the error and what went wrong, and the lines above show where it happened.
amount = int("12,800")
print("This line never runs")
Output:
ValueError: invalid literal for int() with base 10: '12,800'
Catching an exception
Put the code that might fail inside try, and say what to do instead inside except:
user_input = "twelve"
try:
quantity = int(user_input)
print(f"Quantity: {quantity}")
except ValueError:
print(f"'{user_input}' is not a whole number")
print("The program carries on")
Output:
'twelve' is not a whole number
The program carries on
As soon as int() fails, Python jumps straight to except and skips the rest of the try block. After that, the program continues as normal.
Here's a common real use. A missing file isn't always a bug: it may just mean there are no orders yet.
import json
try:
with open("orders.json") as file:
orders = json.load(file)
except FileNotFoundError:
print("orders.json not found: starting with no orders")
orders = []
print(orders)
Output:
orders.json not found: starting with no orders
[]
Looking at the error
Add as error to get the exception object, so you can see what's inside:
try:
quantity = int("twelve")
except ValueError as error:
print(type(error))
print(error)
Output:
<class 'ValueError'>
invalid literal for int() with base 10: 'twelve'
print(error) shows the message. type(error) shows which kind of error it is. Each kind is a class, which you'll learn about later this week.
Catch the specific error you expect
Always name the error you expect, such as ValueError or FileNotFoundError. A bare except: with no name catches everything, including your own typos, and hides the bugs you need to see.
Raising your own exception
Your own functions can raise errors too. When a function gets a value it can't work with, use raise to stop with a clear message instead of returning a wrong answer:
def apply_discount(total, rate):
if rate < 0 or rate > 1:
raise ValueError(f"Discount rate must be between 0 and 1, got {rate}")
return total - total * rate
print(apply_discount(12800, 0.5))
print(apply_discount(12800, 10))
Output:
6400.0
ValueError: Discount rate must be between 0 and 1, got 10
This is called failing early, and it makes bugs much easier to find. Without the check, the function would return a negative total, which might only be noticed weeks later on an invoice, far from the line that caused it.
When to raise and when to catch
Two simple rules cover most situations:
- Raise an error when your code can't safely carry on.
- Catch an error only when you can do something useful about it: carry on with a sensible fallback, or add information that makes it easier to fix.
If you can't do either, don't catch it. Let the program stop with its message. A message that says what went wrong and which value caused it is worth far more than a script that silently prints the wrong total.
Carrying on with a fallback
Sometimes there's a sensible value to use instead, such as the default for an optional setting:
raw_limit = "not-a-number"
try:
results_per_page = int(raw_limit)
except ValueError:
results_per_page = 20
print(results_per_page)
Output:
20
Adding useful information
Some error messages don't tell you enough. invalid literal for int() doesn't say which order is broken. You can catch the error and raise a new one that does:
def parse_total(order_id, raw_total):
try:
return int(raw_total)
except ValueError as error:
raise ValueError(f"Order {order_id}: total '{raw_total}' is not a whole number") from error
print(parse_total("A101", "4500"))
print(parse_total("A102", "12,800"))
Output:
4500
ValueError: Order A102: total '12,800' is not a whole number
Now the message tells you exactly which order to look at and what's wrong with it. from error keeps the original error too, so the full traceback shows both.
Why this matters for an FDE
When your code runs on a customer's real data, you often can't watch it happen. All you may get is an error message in a log, or a screenshot from someone on their team. A message that names the order and the bad value lets you fix the problem in minutes.
You'll use the same ideas when you build web APIs from week 3. For example, your code will raise an HTTPException from the FastAPI library when a record is missing, and FastAPI will turn it into an error response for the user.