Skip to content

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.

Live session

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.

errors.py
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:

errors.py
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.

errors.py
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:

errors.py
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:

errors.py
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:

errors.py
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:

errors.py
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.