Skip to content
aviral gupta

// B5.5 · ~35 min · Beginner

Handling exceptions

After this lesson you can keep a program running when input or files are bad: catch the right exception, raise your own with a clear message, and read a traceback to find what failed where.

Lesson 5 of 5 in B5 Modules, files and errors

End of the module

You will be able to

  • Catch errors with try, except and else, with one except clause per error type
  • Raise an exception with raise and a clear message when a value is invalid
  • Read a traceback: find the exception type, its message and the line that failed
  1. Warm-up · Activity 1 of 7

    Warm-up from the last lesson: which of these values can json.dumps save? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: what does this print?

    result = "start"
    try:
        result = int("12a")
        result = "converted"
    except ValueError:
        result = "not a number"
    print(result)
  3. Practice · Activity 3 of 7

    Fill in the keyword that stops the function with this ValueError.

    def set_age(age):
        if age < 0:
            ____ ValueError("age must not be negative")
        return age
    ValueError(
  4. Practice · Activity 4 of 7

    One try, two except clauses. What does this print?

    def lookup(data, key):
        try:
            return 100 / data[key]
        except KeyError:
            return "missing"
        except ZeroDivisionError:
            return "zero"
    
    
    prices = {"tea": 0, "cake": 4}
    print(lookup(prices, "tea"), lookup(prices, "pie"), lookup(prices, "cake"))
  5. Practice · Activity 5 of 7

    Match each expression to the exception it raises.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The exception happens inside parse. What does this print?

    def parse(text):
        return int(text)
    
    
    log = []
    for text in ["7", "x", "3"]:
        try:
            value = parse(text)
        except ValueError:
            log.append("bad")
        else:
            log.append(value * 2)
    print(log)
  7. Apply · Activity 7 of 7

    Mini-task. Write a program that asks for an age with input() until the answer is a whole number. For a bad answer, print it with !r and the words is not a whole number, and ask again. Then print age: and the number. Try ten, an empty line, then 12.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Processing orders without crashing

Five orders go through a small shop. parse_quantity raises ValueError itself for a quantity that is not positive, int raises it for text that is not a number, and a missing item raises KeyError. One try statement handles all three, and else sells only when nothing went wrong.

main.py

def parse_quantity(text: str) -> int:
    quantity = int(text)
    if quantity <= 0:
        raise ValueError(f"quantity must be positive, got {quantity}")
    return quantity


stock = {"apples": 5, "pears": 2}
orders = [("apples", "2"), ("plums", "1"), ("pears", "two"), ("pears", "-1"), ("pears", "3")]

for item, text in orders:
    try:
        quantity = parse_quantity(text)
        available = stock[item]
    except KeyError:
        print(f"{item}: not sold here")
    except ValueError as err:
        print(f"{item}: bad quantity ({err})")
    else:
        if quantity > available:
            print(f"{item}: only {available} left")
        else:
            stock[item] = available - quantity
            print(f"{item}: sold {quantity}, {stock[item]} left")

Run it with

python main.py

Output

apples: sold 2, 3 left
plums: not sold here
pears: bad quantity (invalid literal for int() with base 10: 'two')
pears: bad quantity (quantity must be positive, got -1)
pears: only 2 left
  • One except ValueError catches both the error from int and the one parse_quantity raises itself.
  • err holds the exception, and putting it in an f-string shows its message.
  • The last order is not an exception: too few pears is ordinary logic in else.
  • The loop never stops early: every handled error lets it go on to the next order.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 3

Build 1: load the to-do list safely

Module build, step 1 of 3: a to-do manager that keeps its list in a JSON file. Write load_todos(path). It returns the list stored in the file. If the file does not exist yet, return an empty list. If the file is not valid JSON, raise ValueError with the message "<path> is not valid JSON", for example todos.json is not valid JSON.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Put the with open(...) block inside try. Opening a missing file raises FileNotFoundError.

  2. Hint 2

    json.load raises json.JSONDecodeError for text that is not JSON. Give it its own except clause.

  3. Hint 3

    In that clause: raise ValueError(f"{path} is not valid JSON").

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import json


def load_todos(path: str) -> list[dict]:
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        raise ValueError(f"{path} is not valid JSON")
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import json


def load_todos(path: str) -> list[dict]:
    with open(path, encoding="utf-8") as f:
        return json.load(f)

test_main.py

from main import load_todos


def test_missing_file():
    """A file that does not exist gives an empty list"""
    try:
        got = load_todos("no_such_file.json")
    except FileNotFoundError:
        assert False, "load_todos raised FileNotFoundError; catch it and return []"
    assert got == [], f"load_todos returned {got!r}, expected []"


def test_valid_file():
    """A valid file gives its list"""
    with open("t1.json", "w", encoding="utf-8") as f:
        f.write('[{"title": "Buy milk", "done": false}]')
    got = load_todos("t1.json")
    assert got == [{"title": "Buy milk", "done": False}], f"load_todos returned {got!r}"


def test_broken_file():
    """A broken file raises ValueError naming the file"""
    with open("t2.json", "w", encoding="utf-8") as f:
        f.write("[{not json")
    try:
        load_todos("t2.json")
    except ValueError as err:
        assert str(err) == "t2.json is not valid JSON", f"the message was {str(err)!r}, expected 't2.json is not valid JSON'"
    else:
        assert False, "load_todos returned normally for a broken file; raise ValueError"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 3

Build 2: add a to-do

Step 2 of 3. load_todos is done. Write add_todo(path, title): strip the spaces off title; if nothing is left, raise ValueError("title must not be empty") without touching the file. Otherwise load the list, append {"title": title, "done": False}, save the list back with json.dump and indent=2, and return it.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Check the title first, before loading anything: title = title.strip(), then if not title: raise ValueError("title must not be empty").

  2. Hint 2

    Saving is json.dump(todos, f, indent=2) inside with open(path, "w", encoding="utf-8") as f:.

  3. Hint 3

    Return the list after saving it, so the caller can print the new to-do.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import json


def load_todos(path: str) -> list[dict]:
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        raise ValueError(f"{path} is not valid JSON")


def add_todo(path: str, title: str) -> list[dict]:
    title = title.strip()
    if not title:
        raise ValueError("title must not be empty")
    todos = load_todos(path)
    todos.append({"title": title, "done": False})
    with open(path, "w", encoding="utf-8") as f:
        json.dump(todos, f, indent=2)
    return todos
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import json


def load_todos(path: str) -> list[dict]:
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        raise ValueError(f"{path} is not valid JSON")


def add_todo(path: str, title: str) -> list[dict]:
    todos = load_todos(path)
    todos.append({"title": title, "done": False})
    return todos

test_main.py

import json
import os

from main import add_todo


def fresh(path):
    if os.path.exists(path):
        os.remove(path)


def test_saved():
    """Two to-dos end up in the file, in order"""
    fresh("t1.json")
    add_todo("t1.json", "Buy milk")
    add_todo("t1.json", "Call Ada")
    with open("t1.json", encoding="utf-8") as f:
        got = json.load(f)
    want = [{"title": "Buy milk", "done": False}, {"title": "Call Ada", "done": False}]
    assert got == want, f"the file holds {got!r}"


def test_stripped():
    """The title is stored without surrounding spaces"""
    fresh("t2.json")
    got = add_todo("t2.json", "  Water plants  ")
    assert got == [{"title": "Water plants", "done": False}], f"add_todo returned {got!r}"


def test_empty_title():
    """An empty title raises ValueError and leaves the file alone"""
    fresh("t3.json")
    add_todo("t3.json", "Buy milk")
    try:
        add_todo("t3.json", "   ")
    except ValueError as err:
        assert str(err) == "title must not be empty", f"the message was {str(err)!r}"
    else:
        assert False, "add_todo accepted an empty title; raise ValueError"
    with open("t3.json", encoding="utf-8") as f:
        got = json.load(f)
    assert len(got) == 1, f"after the empty title the file holds {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 3

Build 3: the command loop

Step 3 of 3. The program reads commands until the input ends: add <title>, done <number> and list. Two things are missing. In complete_todo, raise ValueError(f"no to-do number {number}") when the number is not between 1 and the length of the list. In the loop, stop when input() raises EOFError, and catch ValueError from run(line) by printing error: and its message.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    In complete_todo, before todos[number - 1]: if not 1 <= number <= len(todos): raise ValueError(f"no to-do number {number}"). Without it, done 0 would tick off the last to-do.

  2. Hint 2

    input() raises EOFError when there is no more input. Wrap it: try: line = input() except EOFError: break.

  3. Hint 3

    Then a second try around run(line), with except ValueError as err: print("error:", err). int("two") raises ValueError too, so it is covered.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import json

PATH = "todos.json"


def load_todos(path: str) -> list[dict]:
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        raise ValueError(f"{path} is not valid JSON")


def save_todos(path: str, todos: list[dict]) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(todos, f, indent=2)


def add_todo(path: str, title: str) -> list[dict]:
    title = title.strip()
    if not title:
        raise ValueError("title must not be empty")
    todos = load_todos(path)
    todos.append({"title": title, "done": False})
    save_todos(path, todos)
    return todos


def complete_todo(path: str, number: int) -> dict:
    todos = load_todos(path)
    if not 1 <= number <= len(todos):
        raise ValueError(f"no to-do number {number}")
    todo = todos[number - 1]
    todo["done"] = True
    save_todos(path, todos)
    return todo


def show(path: str) -> None:
    todos = load_todos(path)
    if not todos:
        print("nothing to do")
    for number, todo in enumerate(todos, start=1):
        mark = "x" if todo["done"] else " "
        print(f"{number}. [{mark}] {todo['title']}")


def run(line: str) -> None:
    command, _, argument = line.partition(" ")
    if command == "add":
        todos = add_todo(PATH, argument)
        print("added:", todos[-1]["title"])
    elif command == "done":
        todo = complete_todo(PATH, int(argument))
        print("done:", todo["title"])
    elif command == "list":
        show(PATH)
    else:
        raise ValueError(f"unknown command: {command}")


if __name__ == "__main__":
    while True:
        try:
            line = input()
        except EOFError:
            break
        try:
            run(line)
        except ValueError as err:
            print("error:", err)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import json

PATH = "todos.json"


def load_todos(path: str) -> list[dict]:
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        raise ValueError(f"{path} is not valid JSON")


def save_todos(path: str, todos: list[dict]) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(todos, f, indent=2)


def add_todo(path: str, title: str) -> list[dict]:
    title = title.strip()
    if not title:
        raise ValueError("title must not be empty")
    todos = load_todos(path)
    todos.append({"title": title, "done": False})
    save_todos(path, todos)
    return todos


def complete_todo(path: str, number: int) -> dict:
    todos = load_todos(path)
    # Raise ValueError here when number is not between 1 and len(todos).
    todo = todos[number - 1]
    todo["done"] = True
    save_todos(path, todos)
    return todo


def show(path: str) -> None:
    todos = load_todos(path)
    if not todos:
        print("nothing to do")
    for number, todo in enumerate(todos, start=1):
        mark = "x" if todo["done"] else " "
        print(f"{number}. [{mark}] {todo['title']}")


def run(line: str) -> None:
    command, _, argument = line.partition(" ")
    if command == "add":
        todos = add_todo(PATH, argument)
        print("added:", todos[-1]["title"])
    elif command == "done":
        todo = complete_todo(PATH, int(argument))
        print("done:", todo["title"])
    elif command == "list":
        show(PATH)
    else:
        raise ValueError(f"unknown command: {command}")


if __name__ == "__main__":
    # Stop at EOFError; print "error:" and the message for a ValueError.
    while True:
        line = input()
        run(line)

test_main.py

import os

from learnrun import run_main


def fresh(*commands):
    if os.path.exists("todos.json"):
        os.remove("todos.json")
    return run_main("\n".join(commands) + "\n").strip().splitlines()


def test_add_and_list():
    """add and list show the to-dos, numbered"""
    got = fresh("add Buy milk", "add Call Ada", "list")
    want = ["added: Buy milk", "added: Call Ada", "1. [ ] Buy milk", "2. [ ] Call Ada"]
    assert got == want, f"the program printed {got!r}"


def test_done():
    """done 2 ticks off the second to-do"""
    got = fresh("add Buy milk", "add Call Ada", "done 2", "list")
    want = ["done: Call Ada", "1. [ ] Buy milk", "2. [x] Call Ada"]
    assert got[-3:] == want, f"the program printed {got!r}"


def test_bad_numbers():
    """done with a wrong number prints an error and the program goes on"""
    got = fresh("add Buy milk", "done 5", "done 0", "done two", "list")
    assert got[1:3] == ["error: no to-do number 5", "error: no to-do number 0"], f"the program printed {got!r}"
    assert got[3].startswith("error: "), f"for done two the program printed {got[3]!r}"
    assert got[4:] == ["1. [ ] Buy milk"], f"the program printed {got!r}"


def test_other_errors():
    """An empty title and an unknown command print errors too"""
    got = fresh("add   ", "fly away", "list")
    want = ["error: title must not be empty", "error: unknown command: fly", "nothing to do"]
    assert got == want, f"the program printed {got!r}"


def test_saved_between_runs():
    """The list is still there in the next run"""
    fresh("add Water plants")
    got = run_main("list\n").strip().splitlines()
    assert got == ["1. [ ] Water plants"], f"the second run printed {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Catching the wrong exception type

try:
    number = int("ten")
except TypeError:
    print("not a number")

What Python prints

ValueError: invalid literal for int() with base 10: 'ten'

Why, and the fix

An except clause catches only its own type, and int("ten") raises ValueError, not TypeError, so the error goes through. Read the last line of the traceback to learn the real type, then write except ValueError:.

Raising a string

age = -3
if age < 0:
    raise "age must not be negative"

What Python prints

TypeError: exceptions must derive from BaseException

Why, and the fix

raise needs an exception object, not a bare string. Put the message inside an exception type: raise ValueError("age must not be negative").

A try without except

try:
    value = int("5")
print(value)

What Python prints

SyntaxError: expected 'except' or 'finally' block

Why, and the fix

A try block must be followed by at least one except clause (or a finally clause, which comes later in the course). Python rejects the file before running any of it. Add the handler: except ValueError: and what to do then.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

try, except and else

Python runs the try block. If an exception happens there, the rest of the block is skipped and the first except clause whose type matches runs; at most one handler runs. If none matches, the exception travels on and stops the program. except ValueError as err: gives you the exception, and str(err) its message. The else block runs only when the try block raised nothing. Keep the try block small, with the risky line only, and put the rest in else, so you do not catch errors you did not expect.

raise your own exceptions

raise ValueError("age must not be negative") stops the function at once and hands the exception to whoever called it, however many calls up. A try around the call catches it there, just like a built-in error. Pick the type that fits: ValueError for a value of the right type but wrong content, TypeError for the wrong type, KeyError for a missing key. The message is for a person, so say what was wrong and, where it helps, what the value was.

Reading a traceback

An uncaught exception prints a traceback. Read it from the bottom. The last line is the exception type and its message, such as ValueError: invalid literal for int() with base 10: 'ten'. Just above are the file, the line number and the code of the line that failed. Lines further up show the calls that led there: "most recent call last" means the call closest to the error is at the bottom. Start with the last line, then go to that line in your file.

Sources

Last reviewed September 29, 2026