Skip to content
HopperHopper

Course navigation

Phase 3 · Real programsDay 15

Errors and exceptions

  • About 70 minutes
  • 9 sections
  • Runnable Python examples
  • Practice questions

Before you start

About 70 minutes

By the end you will be able to

  1. Read a traceback from the bottom up and find the line, the function, and the cause of a crash.
  2. Name the common exceptions and say what kind of mistake each one reports.
  3. Handle errors with try, except, else, and finally, catching only the exceptions you expect.
  4. Raise exceptions from your own functions, including a custom exception class, so callers can handle bad input.

What to remember from earlier

Make a day15 folder for today’s files, open it in your editor, and run each script from its terminal, as on earlier days.

Since Day 1 your programs have stopped with error messages: a NameError for a misspelled name, a ValueError when int() got a word, a ZeroDivisionError when a count was 0. Each time, the fix was to change the code. But some errors are not the programmer’s fault. A user types twelve where you asked for a number. A save file gets deleted, or half written when the laptop’s battery dies. A program cannot prevent those, so it must be ready for them. Days 3 and 5 promised that today you would learn how. By the end of today your programs will answer bad input with a clear message instead of a crash, and your to-do manager will survive a missing or broken save file.

Section 1 of 9

15.1 Reading a Traceback from the Bottom Up

Before you can handle an error, you need to read what Python tells you about it. On Day 1 every error came from one line in the main part of a file. Since Day 9 your programs call functions, and functions call other functions, so an error can happen several calls deep. The report then gets longer.

When a running program hits a problem it cannot get past, Python raises an exception: it stops what it was doing and creates an exception, a value that describes what went wrong. If nothing in the program deals with the exception, the program stops and prints a traceback, the report you met on Day 1. Its name is literal: it traces back through every function call that was in progress when the error happened.

Worked example: An error three calls deep

This program prints the average price of each day’s receipts. Tuesday has no receipts.

receipt_average.py
def average(prices):
    return sum(prices) / len(prices)


def report(day, prices):
    print(f"{day}: {average(prices):.2f}")


report("Monday", [4.5, 3.0, 6.0])
report("Tuesday", [])
report("Wednesday", [2.0, 5.0])
Monday: 4.50
Traceback (most recent call last):
  File ".../day15/receipt_average.py", line 10, in <module>
    report("Tuesday", [])
    ~~~~~~^^^^^^^^^^^^^^^
  File ".../day15/receipt_average.py", line 6, in report
    print(f"{day}: {average(prices):.2f}")
                    ~~~~~~~^^^^^^^^
  File ".../day15/receipt_average.py", line 2, in average
    return sum(prices) / len(prices)
           ~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero

The traceback lists the calls in the order they were made, with the most recent call last (that is what its first line says). Each call gets its own entry, called a frame: a File ..., line N, in name line saying where, then the line of code itself. Read it from the bottom up:

Read Lines What it tells you
1st ZeroDivisionError: division by zero What went wrong: the kind of exception and its message
2nd line 2, in average and the code under it Where it happened: the line that was running when it failed
3rd line 6, in report Who called that: report was in the middle of calling average
4th line 10, in <module> Who called that: the main part of the file was calling report
Last Traceback (most recent call last): Only the heading

Under each line of code, the marks show the part that failed. The ^ carets point at the exact operation (here the /), and the ~ tildes underline the rest of the expression around it. Monday: 4.50 printed first, because line 9 ran without trouble. Line 11 never ran: once an exception stops the program, nothing after it happens.

So the bottom frame says where the program crashed, and the frames above it say how it got there. You need both, because the line that crashed is not always the line with the bug, as the Watch Out below shows.

The same reading works when the error happens inside one of Python’s built-in functions. Here the text "four" reaches int() two calls deep:

split_bill.py
def parse_guests(text):
    return int(text)


def split_bill(total, guests_text):
    guests = parse_guests(guests_text)
    return total / guests


print(split_bill(60, "4"))
print(split_bill(60, "four"))
15.0
Traceback (most recent call last):
  File ".../day15/split_bill.py", line 11, in <module>
    print(split_bill(60, "four"))
          ~~~~~~~~~~^^^^^^^^^^^^
  File ".../day15/split_bill.py", line 6, in split_bill
    guests = parse_guests(guests_text)
  File ".../day15/split_bill.py", line 2, in parse_guests
    return int(text)
ValueError: invalid literal for int() with base 10: 'four'

The bottom line is the Day 2 ValueError. The bottom frame is parse_guests, line 2, where int(text) ran. One frame up, split_bill passed it guests_text, and one more frame up, line 11 is where "four" came from. Python does not always draw the marks; when they would just underline the whole expression on the line, as in return int(text), it leaves them out.

Key idea

Read a traceback from the bottom up: the last line says what went wrong, the bottom frame says where the program stopped, and each frame above says which call led there. The cause can be in any of the frames.

Watch out

Fixing the bottom line without asking how it got there. Faced with ZeroDivisionError on line 2 of receipt_average.py, it is tempting to change average. But average has no sensible answer for an empty list, and silently returning 0 would print Tuesday: 0.00, an average of receipts that never existed. The useful question is the one the upper frames answer: who sent an empty list, and what should happen then? Maybe report should print Tuesday: no receipts instead. Read every frame before you decide where the fix belongs.

Error messages change a little between versions of Python. If yours are worded differently or show no ~ and ^ marks, the last line still names the same exception.

Pause and try

Question 1 · Tracing

What does this program print? Give the output and the last line of the traceback, and name the function that was running when the program stopped.

trace_quiz.py
def lookup(prices, item):
    return prices[item]


def basket_total(prices, items):
    total = 0
    for item in items:
        total += lookup(prices, item)
    return total


prices = {"apple": 0.5, "bread": 2.25}
print(basket_total(prices, ["apple", "bread"]))
print(basket_total(prices, ["apple", "milk"]))
Check your reasoning
2.75
Traceback (most recent call last):
  ...
KeyError: 'milk'

The first basket adds 0.5 and 2.25, so line 13 prints 2.75. In the second basket, "apple" is found, but prices["milk"] on line 2 asks for a key the dictionary does not have, which is a KeyError (Day 8). The bottom frame is lookup, line 2; above it is basket_total, line 8, and above that line 14. The message of a KeyError is just the missing key.

Reread 15.1 Reading a Traceback from the Bottom Up if your answer differed.

Section 2 of 9

15.2 Common Exceptions

The last line of a traceback starts with the exception’s type, a name such as ValueError. Every exception has a type, and the type tells you what kind of problem happened before you read the rest of the message. A handful of types cover most beginner errors. You have met each of them on an earlier day:

>>> int("ten")
Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: 'ten'

>>> "Score: " + 90
Traceback (most recent call last):
  ...
TypeError: can only concatenate str (not "int") to str

>>> prices = {"apple": 0.5}
>>> prices["pear"]
Traceback (most recent call last):
  ...
KeyError: 'pear'

>>> scores = [90, 85]
>>> scores[2]
Traceback (most recent call last):
  ...
IndexError: list index out of range

>>> open("missing_notes.txt", encoding="utf-8")
Traceback (most recent call last):
  ...
FileNotFoundError: [Errno 2] No such file or directory: 'missing_notes.txt'

>>> 12 / 0
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero

In the REPL the traceback is shorter, because there is no file: only the last line matters. Each line asks for something impossible in a different way:

Exception What it means Typical cause
ValueError The type is right but this value is not usable int("ten"): int takes strings, but not that one
TypeError The operation does not work on this type at all "Score: " + 90: text plus a number
KeyError A dictionary has no such key prices["pear"] when there is no "pear"
IndexError A list or string has no such position scores[2] in a list of 2 items (indexes 0 and 1)
FileNotFoundError No file exists at that path open("missing_notes.txt") with a misspelled name
ZeroDivisionError Dividing by zero, which has no answer 12 / 0, or total / count when count is 0

The difference between ValueError and TypeError trips people up, so compare them directly. int("ten") is a ValueError: int() happily accepts strings, so the type is fine, but the string "ten" has no whole-number meaning. "Score: " + 90 is a TypeError: + cannot join a string and an int no matter what the values are, so the problem is the type itself.

When you see FileNotFoundError, check the spelling of the path it names and which folder the program runs from (Day 14).

Key idea

The exception type names the kind of problem: ValueError for a bad value, TypeError for the wrong type, KeyError and IndexError for a missing key or position, FileNotFoundError for a missing file, ZeroDivisionError for dividing by zero.

Pause and try

Question 2 · Multiple choice

ages = {"Ana": 15, "Ben": 16}. Which line stops with a KeyError?

  1. ages["Ana"] + 1
  2. ages["ana"]
  3. ages["Ana"] + "1"
  4. ages.get("Cy")
Check your reasoning

B. Keys are compared exactly, and "ana" with a small a is not the key "Ana", so the lookup fails with KeyError: 'ana'.

  • A is wrong: ages["Ana"] is 15, and 15 + 1 is 16.
  • C is wrong: the lookup works, but 15 + "1" adds a number and a string, which is a TypeError, not a KeyError.
  • D is wrong: get() never raises a KeyError; for a missing key it gives back None (Day 8).

Reread 15.2 Common Exceptions if you chose differently.

Question 3 · Short answer

Name the exception each line raises, and say in a few words why: (a) int("3.5"); (b) "abc"[3]; (c) len(42).

Check your reasoning

Model answer:

  • (a) ValueError: int() takes strings, but "3.5" is not a whole number.
  • (b) IndexError: "abc" has indexes 0 to 2, so there is no position 3.
  • (c) TypeError: an int has no length, so len() does not work on that type at all.

A correct answer separates “right type, unusable value” (a) from “wrong type” (c). Reread 15.2 Common Exceptions if your reasoning differed.

Section 3 of 9

15.3 Catching an Exception with try and except

Here is a ticket program in the style of Day 5. It works well until a user types a word:

ticket_crash.py
age = int(input("Age: "))
print(f"Your ticket costs {5 if age < 12 else 9} dollars.")
print("Enjoy the film!")
Age: twelve
Traceback (most recent call last):
  File ".../day15/ticket_crash.py", line 1, in <module>
    age = int(input("Age: "))
ValueError: invalid literal for int() with base 10: 'twelve'

Checking the text with an if before calling int() is harder than it looks: there are many ways to write a number (12, -3, +7) and even more ways not to (15.9 comes back to this). What you want is to try the conversion, and if it fails, do something sensible instead of crashing.

That is the job of the try statement. Python runs the code in its try block. If an exception of the type you named happens there, Python does not stop the program; it jumps to your except block instead. Running an except block for an exception is called catching or handling it.

try:
    statements that might raise an exception
except ExceptionType:
    statements to run if that exception happens
  • try: starts the try block: the indented lines Python will attempt.
  • except ValueError: is an except clause. It names the exception type to catch, and its indented block is the handler.
  • After either block finishes, the program carries on with the first line after the whole statement.

Worked example: A ticket program that survives a word

ticket_try.py
try:
    age = int(input("Age: "))
    print(f"Your ticket costs {5 if age < 12 else 9} dollars.")
except ValueError:
    print("Please type your age as a number, such as 12.")
print("Enjoy the film!")
Age: twelve
Please type your age as a number, such as 12.
Enjoy the film!

Follow what Python does, step by step:

Step Line What happens
1 1 Enter the try block
2 2 input returns "twelve"; int("twelve") raises a ValueError
3 3 Skipped: the rest of the try block is abandoned the moment the exception happens
4 4 The exception is a ValueError, which matches this except clause
5 5 The handler prints its message; the exception is now handled
6 6 The program carries on normally

If the user types 12 instead, line 2 succeeds, line 3 prints Your ticket costs 9 dollars., the except clause is skipped completely, and line 6 runs. Either way the program reaches the end.

The key detail is step 3. An exception does not finish the try block; it leaves it at once. To see both paths in one run, this loop tries three strings:

convert_each.py
for text in ["12", "twelve", "7"]:
    print(f"Trying {text}")
    try:
        age = int(text)
        print(f"  Next year: {age + 1}")
    except ValueError:
        print("  Not a whole number")
print("All done")
Trying 12
  Next year: 13
Trying twelve
  Not a whole number
Trying 7
  Next year: 8
All done

For "twelve", line 4 raises, so line 5 never runs for it. The handler prints its message, and the loop goes on to "7" as usual. A handled exception affects only the try statement it happened in.

Key idea

try: runs its block; if a listed exception happens there, Python abandons the rest of the block and runs the matching except handler, then carries on after the statement. If nothing goes wrong, the handler is skipped.

Watch out

Using a variable the failed line never made. When int(text) fails, the assignment to age never happens:

skipped_assignment.py
text = "twelve"
try:
    age = int(text)
except ValueError:
    print("Not a number.")
print(f"Next year you will be {age + 1}.")
Not a number.
Traceback (most recent call last):
  File ".../day15/skipped_assignment.py", line 6, in <module>
    print(f"Next year you will be {age + 1}.")
                                   ^^^
NameError: name 'age' is not defined

The handler printed its message, the program carried on, and line 6 used age, which was never created. Catching the first error just moved the crash to the next line. Code that needs the result belongs where it only runs on success: inside the try block after the risky line (as in convert_each.py), or in an else block, which 15.5 shows. Another fix is a handler that leaves things usable, for example by setting a default value.

Pause and try

Question 4 · Tracing

What does this program print? Track total.

try_trace.py
total = 0
for text in ["5", "five", "2.5", "10"]:
    try:
        total += int(text)
        print(f"added {text}")
    except ValueError:
        print(f"skipped {text}")
print(f"total {total}")
Check your reasoning
added 5
skipped five
skipped 2.5
added 10
total 15
text int(text) total after Printed
"5" 5 5 added 5
"five" raises ValueError 5 skipped five
"2.5" raises ValueError 5 skipped 2.5
"10" 10 15 added 10

int("2.5") is a ValueError even though 2.5 is a number: int() only accepts whole-number text. When the conversion fails, total += ... does not happen, because the exception leaves the try block before the addition is done, so a skipped string never changes the total.

Reread 15.3 Catching an Exception with try and except if your output differed.

Section 4 of 9

15.4 Catching Specific Exceptions

One try block can fail in more than one way, and each way usually needs a different message. This program shares pizza slices: the text might not be a number, or the number of people might be 0.

Worked example: One except clause per kind of problem

share_pizza.py
def share(slices_text, people_text):
    try:
        slices = int(slices_text)
        people = int(people_text)
        print(f"{slices // people} slices each")
    except ValueError:
        print("Type whole numbers, such as 12 and 4.")
    except ZeroDivisionError:
        print("Nobody to share with: people must be at least 1.")


for slices_text, people_text in [("12", "4"), ("12", "four"), ("12", "0")]:
    share(slices_text, people_text)
3 slices each
Type whole numbers, such as 12 and 4.
Nobody to share with: people must be at least 1.

A try statement can have several except clauses. When an exception happens, Python checks them from top to bottom and runs the first one whose type matches. The others are skipped.

Call What happens in the try block Handler that runs
share("12", "4") Nothing goes wrong; prints 3 None
share("12", "four") Line 4 raises ValueError except ValueError:
share("12", "0") Line 5 raises ZeroDivisionError except ZeroDivisionError:

Line 12 uses tuple unpacking from Day 7: each pair in the list is split into slices_text and people_text.

When two exceptions deserve the same handling, list them in a tuple, and add as with a name to get hold of the exception itself:

share_pizza_as.py
def share(slices_text, people_text):
    try:
        slices = int(slices_text)
        people = int(people_text)
        print(f"{slices // people} slices each")
    except (ValueError, ZeroDivisionError) as error:
        print(f"Could not share: {error}")


for slices_text, people_text in [("12", "4"), ("12", "four"), ("12", "0")]:
    share(slices_text, people_text)
3 slices each
Could not share: invalid literal for int() with base 10: 'four'
Could not share: division by zero

except (ValueError, ZeroDivisionError) as error: catches either type and stores the exception in error. Putting error in an f-string (or calling str(error)) gives its message, the text after the colon on a traceback’s last line. For a KeyError, the message is the missing key in quotes, such as 'pear'.

What about an exception you did not list? It is not caught. It carries on exactly as if there were no try statement, and stops the program:

share_pizza_uncaught.py
def share(slices_text, people_text):
    try:
        slices = int(slices_text)
        people = int(people_text)
        print(f"{slices // people} slices each")
    except ValueError:
        print("Type whole numbers, such as 12 and 4.")


share("12", "4")
share("12", "0")
print("Done sharing")
3 slices each
Traceback (most recent call last):
  File ".../day15/share_pizza_uncaught.py", line 11, in <module>
    share("12", "0")
    ~~~~~^^^^^^^^^^^
  File ".../day15/share_pizza_uncaught.py", line 5, in share
    print(f"{slices // people} slices each")
             ~~~~~~~^^~~~~~~~
ZeroDivisionError: division by zero

This is a feature, not a gap. You catch the problems you expect and know how to answer. A problem you did not expect is probably a bug, and a bug should stop the program loudly, with a traceback that points straight at it.

Key idea

Name the exact exception types you expect: except ValueError:, or except (ValueError, ZeroDivisionError) as error: to treat several alike and read the message. Python runs the first matching clause; any other exception still stops the program.

Watch out

The bare except. Python lets you write except: with no type at all. It catches every exception, including the ones that mean your code is wrong. This function has a typo on line 5:

bare_except.py
def total_cost(price_text, quantity_text):
    try:
        price = float(price_text)
        quantity = int(quantity_text)
        return price * quantiy
    except:
        print("Please type numbers.")


print(total_cost("2.50", "4"))
Please type numbers.
None

The user typed perfectly good numbers, and the program blamed them. Line 5 raised a NameError for quantiy, the bare except caught it, and the real cause disappeared. The function then ended without a return, so it gave back None (Day 9). A bare except even catches Ctrl+C: Python reports Ctrl+C as an exception called KeyboardInterrupt, so a loop with a bare except inside can refuse to stop when you press it.

Name the exception you expect instead, and the typo shows itself:

bare_except_fixed.py
def total_cost(price_text, quantity_text):
    try:
        price = float(price_text)
        quantity = int(quantity_text)
        return price * quantiy
    except ValueError:
        print("Please type numbers.")


print(total_cost("2.50", "4"))
Traceback (most recent call last):
  File ".../day15/bare_except_fixed.py", line 10, in <module>
    print(total_cost("2.50", "4"))
          ~~~~~~~~~~^^^^^^^^^^^^^
  File ".../day15/bare_except_fixed.py", line 5, in total_cost
    return price * quantiy
                   ^^^^^^^
NameError: name 'quantiy' is not defined. Did you mean: 'quantity'?

Now the traceback points at the misspelled name, and fixing it to quantity makes the program print 10.0. Never write a bare except. Writing except Exception: is almost as bad: it still catches every ordinary error, typos included.

Pause and try

Question 5 · Multiple choice

What does this program print?

pick_except.py
stock = {"pens": 12, "pads": 0}

for item in ["pens", "pads", "rulers"]:
    try:
        per_box = 24 // stock[item]
        print(f"{item}: {per_box}")
    except ZeroDivisionError:
        print(f"{item}: out of stock")
    except KeyError:
        print(f"{item}: we do not sell these")
print("Report done")
  1. pens: 2, pads: out of stock, rulers: we do not sell these, Report done
  2. pens: 2, pads: out of stock, then a KeyError traceback
  3. pens: 2, pads: 0, rulers: we do not sell these, Report done
  4. pens: 2, then a ZeroDivisionError traceback
Check your reasoning

A.

pens: 2
pads: out of stock
rulers: we do not sell these
Report done

24 // 12 is 2. For "pads", the stock is 0, so 24 // 0 raises ZeroDivisionError, which the first clause catches. For "rulers", stock["rulers"] raises KeyError before any division happens; Python checks the clauses top to bottom, ZeroDivisionError does not match, KeyError does.

  • B is wrong: the second except clause catches the KeyError.
  • C is wrong: 24 // 0 does not give 0; it raises an exception, so line 6 is skipped.
  • D is wrong: the ZeroDivisionError is caught by the first clause, so the loop carries on.

Reread 15.4 Catching Specific Exceptions if you chose differently.

Section 5 of 9

15.5 else and finally

15.3 left a question open: where should the code go that uses a successful result? You could put it all in the try block, but then every line there is “protected”, and an unexpected ValueError from some later line would be caught by a handler written for the conversion. Good try blocks are small: only the line that can fail in the way you expect. A try statement has two more optional clauses for the rest.

  • An else clause runs only if the try block finished without an exception. Put the code that uses the result there.
  • A finally clause runs always, whether the try block succeeded, failed and was handled, or failed and was not handled. Put cleanup there: anything that must happen no matter what.

The full shape, in the only order Python accepts:

try:
    risky line
except SomeError:
    runs if SomeError happened
else:
    runs if nothing went wrong
finally:
    runs every time

Worked example: Every path through a try statement

else_finally.py
for text in ["88", "eighty"]:
    print(f"Reading {text}")
    try:
        score = int(text)
    except ValueError:
        print("  except: not a number")
    else:
        print(f"  else: doubled is {score * 2}")
    finally:
        print("  finally: checked")
Reading 88
  else: doubled is 176
  finally: checked
Reading eighty
  except: not a number
  finally: checked
text try block except else finally
"88" succeeds, score is 88 skipped runs runs
"eighty" raises ValueError runs skipped runs

except and else are opposites: exactly one of them runs (when the exception is one you listed). finally runs in both rows. The else block also fixes the Watch Out in 15.3: score is used only where it is certain to exist.

finally runs even when the exception is not caught. Here there is no except clause at all, which is allowed:

finally_uncaught.py
try:
    print("Saving...")
    share = 10 / 0
finally:
    print("Cleanup runs anyway")
print("Never printed")
Saving...
Cleanup runs anyway
Traceback (most recent call last):
  File ".../day15/finally_uncaught.py", line 3, in <module>
    share = 10 / 0
            ~~~^~~
ZeroDivisionError: division by zero

The division fails, Python runs the finally block on its way out, and then the program stops with the traceback. Never printed never prints. finally also runs when the try block is left by return or break. Here the function has already worked out its return value, but finally prints before the value is handed back:

finally_return.py
def first_letter(word):
    try:
        return word[0]
    finally:
        print(f"checked {word}")


print(first_letter("kiwi"))
checked kiwi
k

On Day 14, with open(...) closed the file for you even if something went wrong inside the with block. Now you can see what that means: with does the same job as a finally that closes the file, built in.

Key idea

Keep the try block to the risky line. else runs when the try block raised nothing, so it is where the result gets used; finally runs on every path, even through an uncaught exception or a return.

Watch out

Clauses in the wrong order. The order is fixed: try, then every except, then else, then finally. Anything else is a syntax error, so nothing runs at all:

wrong_order.py
try:
    count = int("7")
finally:
    print("Finished")
except ValueError:
    print("Not a number")
  File ".../day15/wrong_order.py", line 5
    except ValueError:
    ^^^^^^
SyntaxError: invalid syntax

Once finally has appeared, the try statement is over, so Python sees an except with nothing to belong to. Move the finally block to the end. It also helps to remember what else means here. It is not the else of an if: it runs when the try block succeeded, not when something failed.

Pause and try

Question 6 · Tracing

What does this program print?

else_trace.py
line = ""
for text in ["4", "x", "0"]:
    try:
        number = int(text)
        result = 12 // number
    except ValueError:
        line += "V"
    except ZeroDivisionError:
        line += "Z"
    else:
        line += str(result)
    finally:
        line += "|"
print(line)
Check your reasoning
3|V|Z|
text What happens Added to line
"4" 12 // 4 is 3; no exception, so else runs 3, then a bar
"x" int("x") raises ValueError V, then a bar
"0" int("0") works, 12 // 0 raises ZeroDivisionError Z, then a bar

finally adds a bar on every pass, after whichever of except or else ran. str(result) turns the int 3 into text so it can be added to the string.

Reread 15.5 else and finally if your output differed.

Section 6 of 9

15.6 Exceptions Travel Up Through Functions

In 15.1 the traceback listed every function call that was in progress. Those same calls decide where an exception can be caught. When a function raises an exception and does not handle it, the function stops at once (no return happens), and the exception arrives at the line that called the function, as if that line had raised it. If that line is in a try block with a matching except clause, the exception is caught there. If not, it carries on up to the next caller, and so on. Passing up through the calls like this is called propagating. Only if it reaches the main part of the file with nothing to catch it does the program stop with a traceback.

Worked example: Caught two calls up

travel_up.py
def parse_quantity(text):
    print(f"  parsing {text}")
    quantity = int(text)
    print("  parsed")
    return quantity


def place_order(text):
    quantity = parse_quantity(text)
    print(f"  ordered {quantity}")


for text in ["3", "lots"]:
    print(f"Order: {text}")
    try:
        place_order(text)
    except ValueError:
        print("  cancelled: bad quantity")
print("Shop still open")
Order: 3
  parsing 3
  parsed
  ordered 3
Order: lots
  parsing lots
  cancelled: bad quantity
Shop still open

For "lots", follow the exception up:

  1. parse_quantity prints parsing lots, then int("lots") on line 3 raises a ValueError. parse_quantity has no try statement, so it stops: line 4 (parsed) and the return never run.
  2. The exception arrives at line 9 in place_order, which called parse_quantity. No try there either, so place_order stops too: ordered never prints.
  3. The exception arrives at line 16, which called place_order. That line is inside a try block, and the except clause matches, so the handler prints cancelled: bad quantity.
  4. The loop carries on, and the program ends normally with Shop still open.

This means you can catch an error far from where it happens, and usually you should. parse_quantity knows how to convert text, but it does not know what the program should do with a bad quantity: ask again? Skip the order? Show a message in a window? The code near the user knows. A good rule: handle an exception at the place that can do something useful about it. Low-level functions let it propagate, and the code that talks to the user catches it.

Key idea

An exception that a function does not handle stops that function and propagates to the line that called it, then to that line’s caller, until a matching except catches it. Catch it where the program can respond sensibly.

Pause and try

Question 7 · Tracing

What does this program print? Watch the finally in outer.

travel_trace.py
def inner(n):
    print("inner start")
    result = 10 // n
    print("inner end")
    return result


def outer(n):
    try:
        print("outer start")
        value = inner(n)
        print("outer got", value)
    finally:
        print("outer finally")


try:
    outer(0)
except ZeroDivisionError:
    print("caught at the top")
Check your reasoning
outer start
inner start
outer finally
caught at the top

outer(0) prints outer start and calls inner(0). inner prints inner start, then 10 // 0 raises ZeroDivisionError. inner has no handler, so it stops (inner end never prints) and the exception propagates into outer, at line 11. outer’s try statement has no except clause, so outer got is skipped, but its finally runs on the way out: outer finally. The exception keeps propagating to line 18, where the except clause catches it.

Reread 15.6 Exceptions Travel Up Through Functions and 15.5 else and finally if your output differed.

Section 7 of 9

15.7 Raising Your Own Errors with raise

So far Python has raised every exception. But your own functions also meet values they cannot use. A function that reads a rating from 1 to 5 gets "9". int("9") is happy, so Python raises nothing, but 9 is still wrong. What should the function do?

It could print a message, but then it decides how the problem is shown, which is the caller’s job (15.6). It could return None, but a caller that forgets to check will carry on with None and crash somewhere later, far from the cause. Python’s answer is that the function raises an exception itself, exactly like int() does. The raise statement creates an exception and raises it:

raise ValueError("a message that explains the problem")
  • raise is a keyword.
  • ValueError(...) creates a new exception of that type. The text in the parentheses becomes its message.
  • From that moment, everything works as in 15.6: the function stops, and the exception propagates to the caller.

Worked example: A rating that must be 1 to 5

rating.py
def parse_rating(text):
    rating = int(text)
    if not 1 <= rating <= 5:
        raise ValueError(f"rating must be 1 to 5, not {rating}")
    return rating


for text in ["4", "9", "great"]:
    try:
        rating = parse_rating(text)
    except ValueError as error:
        print(f"Skipped {text}: {error}")
    else:
        print(f"Saved rating {rating}")
Saved rating 4
Skipped 9: rating must be 1 to 5, not 9
Skipped great: invalid literal for int() with base 10: 'great'
  • "4": int gives 4, the check on line 3 passes, line 5 returns 4, and the else clause saves it.
  • "9": int gives 9, but not 1 <= 9 <= 5 is True (the Day 4 chained comparison), so line 4 raises a ValueError with a message built by an f-string. The return on line 5 never runs.
  • "great": int("great") raises its own ValueError on line 2.

The caller handles both with one except clause, because both are “a value that cannot be a rating”. ValueError is the right type to raise for exactly that: the type was fine (a string, then an int), but the value was not usable. Raise a TypeError instead when a function gets the wrong type altogether.

If nobody catches a raised exception, it ends the program with a traceback like any other, and the message is the one you wrote:

rating_uncaught.py
def parse_rating(text):
    rating = int(text)
    if not 1 <= rating <= 5:
        raise ValueError(f"rating must be 1 to 5, not {rating}")
    return rating


print(parse_rating("4"))
print(parse_rating("9"))
print("Not reached")
4
Traceback (most recent call last):
  File ".../day15/rating_uncaught.py", line 9, in <module>
    print(parse_rating("9"))
          ~~~~~~~~~~~~^^^^^
  File ".../day15/rating_uncaught.py", line 4, in parse_rating
    raise ValueError(f"rating must be 1 to 5, not {rating}")
ValueError: rating must be 1 to 5, not 9

That is why a good message says what was expected and what arrived. Whoever reads this traceback knows what went wrong without opening the code.

Key idea

raise ValueError("message") stops your function and hands the problem to its callers, exactly as Python’s own errors do. Raise when your function gets input it cannot use, and let the caller decide what to do about it.

Watch out

return instead of raise. Writing return ValueError(...) creates the exception but does not raise it. It is returned like any other value:

return_not_raise.py
def parse_rating(text):
    rating = int(text)
    if not 1 <= rating <= 5:
        return ValueError(f"rating must be 1 to 5, not {rating}")
    return rating


for text in ["4", "9"]:
    try:
        rating = parse_rating(text)
    except ValueError as error:
        print(f"Skipped {text}: {error}")
    else:
        print(f"Saved rating {rating}")
Saved rating 4
Saved rating rating must be 1 to 5, not 9

No exception happened, so the except clause was skipped and the else clause “saved” an exception object as if it were a rating. The f-string showed its message. Only raise sets off the jump to a handler.

Pause and try

Question 8 · Short answer

A classmate’s function checks a percentage and, when it is above 100, does print("Too big!") and then return None. Give two reasons why raising a ValueError is better.

Check your reasoning

Model answer:

  1. The caller decides how to respond. A function that prints has already chosen the message and where it appears. With raise, the caller can ask again, skip the value, or show its own message.
  2. The problem cannot be silently ignored. A caller that forgets to check for None carries on and crashes later, far from the cause, or gives wrong results. An uncaught ValueError stops the program right away with a traceback whose message says what went wrong.

A correct answer mentions that the handling moves to the caller and that an exception, unlike a returned None, cannot be overlooked by accident.

Reread 15.7 Raising Your Own Errors with raise if your reasoning differed.

Section 8 of 9

15.8 Defining a Custom Exception

In the to-do manager, a user can type two (not a number) or 7 (a number, but no such task). Both could be ValueError, but then the handler cannot tell them apart and cannot give a precise message. Worse, a ValueError from some other line, maybe a real bug, would be caught by the same handler.

You can make your own exception type with a name that says exactly what went wrong. A custom exception takes two lines. Copy this pattern and change only the name:

class TaskNumberError(Exception):
    pass

Tomorrow, Day 16, explains class properly. For today, here is what each piece does:

  • class TaskNumberError creates a new type named TaskNumberError. By convention, the names of exception types end in Error and are written in CapWords (each word starts with a capital letter, no underscores), like the built-in ones.
  • (Exception) makes the new type a kind of exception, so it can be raised and caught. Leave it out and Python refuses to raise it.
  • pass is a statement that does nothing. A block cannot be empty, and this one has nothing to say, so pass fills it.

Put the two lines near the top of the file, below the imports, so the type exists before any code raises or catches it. After that, TaskNumberError works like ValueError: you raise it with a message and catch it by name.

Worked example: Telling two problems apart

task_number.py
class TaskNumberError(Exception):
    pass


def check_task_number(tasks, number):
    if not 1 <= number <= len(tasks):
        raise TaskNumberError(f"there is no task {number}; choose 1 to {len(tasks)}")


tasks = [{"title": "Buy milk", "done": False}, {"title": "Walk dog", "done": True}]
for typed in ["2", "7", "two"]:
    try:
        number = int(typed)
        check_task_number(tasks, number)
    except ValueError:
        print(f"{typed}: please type a number")
    except TaskNumberError as error:
        print(f"{typed}: {error}")
    else:
        print(f"{typed}: {tasks[number - 1]['title']}")
2: Walk dog
7: there is no task 7; choose 1 to 2
two: please type a number
typed What happens Clause that runs
"2" int gives 2; the check passes else: prints task 2’s title
"7" int gives 7; line 7 raises TaskNumberError except TaskNumberError
"two" int("two") raises ValueError before any check except ValueError

Each kind of problem has its own type, so each gets its own handler and message, and a ValueError can never be mistaken for a bad task number.

Uncaught, your exception produces a normal traceback, ending with your type’s name and message:

task_number_uncaught.py
class TaskNumberError(Exception):
    pass


def check_task_number(tasks, number):
    if not 1 <= number <= len(tasks):
        raise TaskNumberError(f"there is no task {number}; choose 1 to {len(tasks)}")


tasks = [{"title": "Buy milk", "done": False}]
check_task_number(tasks, 1)
print("Task 1 is fine")
check_task_number(tasks, 3)
Task 1 is fine
Traceback (most recent call last):
  File ".../day15/task_number_uncaught.py", line 13, in <module>
    check_task_number(tasks, 3)
    ~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File ".../day15/task_number_uncaught.py", line 7, in check_task_number
    raise TaskNumberError(f"there is no task {number}; choose 1 to {len(tasks)}")
TaskNumberError: there is no task 3; choose 1 to 1

Key idea

class SomethingError(Exception): followed by an indented pass defines a custom exception type. Raise and catch it by name, so one specific problem gets its own handler and is never confused with a built-in error.

Watch out

Leaving out (Exception). Without it, the new type is not an exception:

no_exception_base.py
class TaskNumberError:
    pass


raise TaskNumberError("there is no task 3")
Traceback (most recent call last):
  File ".../day15/no_exception_base.py", line 5, in <module>
    raise TaskNumberError("there is no task 3")
          ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
TypeError: TaskNumberError() takes no arguments

The message is confusing because Python fails while creating the value, before it even tries to raise it: a plain class does not accept a message. Even without the message, raise would refuse it with a TypeError saying exceptions must derive from BaseException. Copy the pattern exactly, with (Exception).

Pause and try

Question 9 · Tracing

What does this program print? Each seat goes through two functions before any handler.

seat_check.py
class SeatError(Exception):
    pass


def check_seat(row, seat):
    if seat > 10:
        raise SeatError(f"row {row} has only 10 seats")
    return f"{row}{seat}"


def book(row, seat_text):
    seat = int(seat_text)
    label = check_seat(row, seat)
    print(f"booked {label}")


for row, seat_text in [("A", "4"), ("B", "12"), ("C", "two")]:
    try:
        book(row, seat_text)
    except SeatError as error:
        print(f"sorry, {error}")
    except ValueError:
        print("seat must be a number")
print("done")
Check your reasoning
booked A4
sorry, row B has only 10 seats
seat must be a number
done
  • ("A", "4"): int gives 4, check_seat returns "A4", and book prints it.
  • ("B", "12"): 12 is over 10, so check_seat raises SeatError. It propagates through book (which stops before printing) to the loop, where except SeatError catches it and prints its message.
  • ("C", "two"): int("two") raises ValueError in book, before check_seat is ever called; the second clause catches it.

Reread 15.8 Defining a Custom Exception and 15.6 Exceptions Travel Up Through Functions if your output differed.

Section 9 of 9

15.9 Ask Forgiveness, Not Permission

You now have two ways to deal with something that might fail. You can check first, and only act if the check passes. Or you can just act, and handle the exception if it fails. Both appear in Python code, and they have names:

  • LBYL, “look before you leap”: test the conditions with if, then act.
  • EAFP, “easier to ask forgiveness than permission”: act inside a try, and handle the failure in except.

Worked example: The same lookup, two styles

lookup_styles.py
stock = {"pens": 12, "pads": 5}

# Look before you leap: check first, then act.
item = "rulers"
if item in stock:
    print(f"{item}: {stock[item]}")
else:
    print(f"{item}: not stocked")

# Easier to ask forgiveness than permission: act, and handle the failure.
try:
    print(f"{item}: {stock[item]}")
except KeyError:
    print(f"{item}: not stocked")
rulers: not stocked
rulers: not stocked

Both halves give the same result. The LBYL half asks item in stock first (Day 8). The EAFP half looks the item up and catches the KeyError if it is missing.

For a dictionary, both styles are fine. But in other places, checking first is harder than it looks. Think about checking text before int(). These all work:

>>> int("-7")
-7
>>> int(" 42 ")
42
>>> int("+3")
3
>>> int("4.0")
Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: '4.0'

A minus sign, spaces around the number, and a plus sign are all accepted, but "4.0" is not. An if that tried to copy int()’s rules would be long and still miss a case. The Day 12 to-do manager stripped the spaces off and then checked all(ch in "0123456789" for ch in text): two steps to copy part of int()’s rules, and it still refuses +3, which int() accepts. EAFP asks the expert: let int() decide, and catch the ValueError.

Files make the strongest case for EAFP. On Day 14 the to-do manager checked SAVE_FILE.exists() before loading. But a file can be deleted between the check and the open, and a file that exists can still be unreadable, or not valid JSON. The check answers one question once; it cannot promise that the next line will work. Trying to open the file and catching FileNotFoundError covers the real moment of opening.

Style Looks like Good for
LBYL if key in prices: then prices[key] Simple, cheap, complete checks; the “failure” is a normal case (get() too)
EAFP try: int(text) / except ValueError: Checks that are hard to write exactly, and anything outside your program, such as files

Python programmers lean toward EAFP, because it puts the normal path first and lets the code that does the work decide what counts as failure. Neither is wrong; choose the one whose check is honest and complete.

Key idea

LBYL checks with if before acting; EAFP acts in a try and handles the exception. Prefer EAFP when a correct check would be complicated or could be out of date by the time you act, as with converting text and opening files.

Pause and try

Question 10 · Short answer

A program loads settings.json. Version 1 does if path.exists(): and then opens and reads the file with json.load. Version 2 opens and reads it in a try with except FileNotFoundError: and except json.JSONDecodeError:. Give two situations that crash version 1 but not version 2.

Check your reasoning

Model answer:

  1. The file is deleted (or renamed) after the exists() check but before the open, so the open raises FileNotFoundError, which version 1 does not catch.
  2. The file exists but is not valid JSON, for example half-written when the computer lost power, so json.load raises JSONDecodeError. exists() only says the file is there, not that its contents are usable.

A correct answer points out that the check in version 1 does not cover everything that can go wrong at the moment of reading, while version 2 handles the failures where they happen.

Reread 15.9 Ask Forgiveness, Not Permission if your reasoning differed.

Wrap-up · 1 of 7

Putting it together

A teacher keeps scores in a text file, one name,score per line. Some lines have mistakes. The program reports the average of the good lines and explains each bad one, and it copes when the file is missing.

class_scores.txt:

Ana,88
Ben,ninety
Cy
Dee,105
Eli,72
score_report.py
from pathlib import Path


class ScoreRangeError(Exception):
    pass


def read_lines(path):
    with open(path, encoding="utf-8") as file:
        return [line.strip() for line in file]


def parse_line(line):
    name, score_text = line.split(",")
    score = int(score_text)
    if not 0 <= score <= 100:
        raise ScoreRangeError(f"{score} is outside 0 to 100")
    return name, score


def read_scores(path):
    try:
        lines = read_lines(path)
    except FileNotFoundError:
        print(f"No file named {path}: nothing to report.")
        return []
    scores = []
    for number, line in enumerate(lines, 1):
        try:
            name, score = parse_line(line)
        except (ValueError, ScoreRangeError) as error:
            print(f"Line {number} skipped ({line}): {error}")
        else:
            scores.append(score)
    return scores


for path in [Path("class_scores.txt"), Path("missing_scores.txt")]:
    scores = read_scores(path)
    if scores:
        print(f"{len(scores)} scores, average {sum(scores) / len(scores):.1f}")
Line 2 skipped (Ben,ninety): invalid literal for int() with base 10: 'ninety'
Line 3 skipped (Cy): not enough values to unpack (expected 2, got 1)
Line 4 skipped (Dee,105): 105 is outside 0 to 100
2 scores, average 80.0
No file named missing_scores.txt: nothing to report.
Line Inside parse_line Caught by read_scores as Idea
Ana,88 Splits, converts, passes the range check else: score kept 15.5
Ben,ninety int("ninety") raises ValueError ValueError 15.3, 15.4
Cy split(",") gives one item; unpacking it into two names raises ValueError ValueError 15.2
Dee,105 Line 17 raises ScoreRangeError ScoreRangeError 15.7, 15.8
Eli,72 All checks pass else: score kept 15.5
  • read_lines does not handle anything. When missing_scores.txt does not exist, its open raises FileNotFoundError, which propagates (15.6) to read_scores, where the except clause prints a message and returns an empty list. The final if scores: then skips the average, so there is no ZeroDivisionError either.
  • The try block around read_lines holds one line, and the try block in the loop holds one line. Each catches only the exceptions it expects, named exactly (15.4), and none is bare.
  • The unpacking line shows that you do not need to predict every way a line can be broken: int() and unpacking raise ValueError for you, the program checks only what Python cannot (the range), and one handler covers them all (EAFP, 15.9).
  • The average is (88 + 72) / 2 = 80.0.
Wrap-up · 2 of 7

Vocabulary

Term Definition Example
exception A value Python creates to describe a problem while a program runs ValueError("...")
raise (an exception) Signal a problem: stop normal work and start looking for a handler int("ten") raises one
traceback The report of the calls in progress when an uncaught exception stopped the program Traceback (most recent call last):
frame One call in a traceback: where it was and the line running line 2, in average
exception type The kind of exception, named on the traceback’s last line KeyError
message The text after the type, explaining the problem division by zero
try statement / try block Code Python attempts, with handlers ready if it fails try:
except clause / handler Names an exception type to catch / the block that runs when it is caught except ValueError:
catch / handle Run a handler for an exception, so the program carries on the ticket program
bare except An except clause with no type; catches everything, so never use it except:
else clause (try) Runs only if the try block raised nothing else: print(score * 2)
finally clause Runs on every path out of the try statement finally: print("checked")
propagate An unhandled exception passing up to the caller, then its caller from parse_quantity up to the loop
raise statement Raises an exception from your own code raise ValueError("too big")
custom exception Your own exception type, made with class Name(Exception): TaskNumberError
pass A statement that does nothing, for a block with nothing to say pass
LBYL “Look before you leap”: check with if, then act if key in prices:
EAFP “Easier to ask forgiveness than permission”: try, then handle the failure try: prices[key]
Wrap-up · 3 of 7

Summary

  • An uncaught exception stops the program with a traceback. Read it from the bottom up: the last line gives the type and message, the bottom frame shows where it happened, and the frames above show which calls led there; the cause may be in any of them.
  • The common types each name a kind of problem: ValueError, TypeError, KeyError, IndexError, FileNotFoundError, and ZeroDivisionError.
  • A try statement runs its block, jumps to the first matching except handler if a listed exception happens, runs else if nothing did, and runs finally every time. Catch only the types you expect, never with a bare except.
  • An unhandled exception propagates from a function to its caller, so you can catch it where the program can respond. Your functions can raise exceptions too, including custom types made with class Name(Exception): pass.
  • EAFP (try, then handle) is often clearer and safer than LBYL (check first), especially for converting text and opening files.
Wrap-up · 4 of 7

Practice

Do these in your day15 folder. Type each program yourself, run it, and only then open the answer.

Question 11 · Short answer

Ask until valid. Write a function read_int(prompt) that keeps asking with input(prompt) until the user types a whole number, then returns it as an int. After each bad answer, print a short message. Use it to ask for an age and a number of tickets.

Check your reasoning

One possible solution:

read_int.py
def read_int(prompt):
    """Ask until the user types a whole number, then return it as an int."""
    while True:
        text = input(prompt)
        try:
            return int(text)
        except ValueError:
            print("Please type a whole number, such as 12.")


age = read_int("Age: ")
tickets = read_int("Tickets: ")
print(f"{tickets} tickets for age {age}")

This run types twelve, 12.5, and 12 for the age, then just presses Enter, then types 3 for the tickets:

Age: twelve
Please type a whole number, such as 12.
Age: 12.5
Please type a whole number, such as 12.
Age: 12
Tickets:
Please type a whole number, such as 12.
Tickets: 3
3 tickets for age 12

while True: repeats until something leaves the loop (Day 5). Here that is return int(text): when the conversion works, the function returns at once, which ends both the loop and the function. When it fails, the ValueError skips the return, the handler prints, and the loop asks again. Pressing Enter gives "", and int("") is a ValueError too. The try block holds only the conversion, and the handler names ValueError, so a typo elsewhere would still show up as an error.

Reread 15.3 Catching an Exception with try and except if your result differed.

Question 12 · Short answer

A save file that can go wrong. On Day 14 the to-do manager saved its tasks to tasks.json and loaded them at the start, checking SAVE_FILE.exists() first. Change load_tasks(path) so that a missing file means “no tasks yet” and a corrupted file (one that is not valid JSON) prints a warning and starts with no tasks, then let main call it without the exists() check. While you are there, make the task-number prompt use try and int().

Check your reasoning

First see what a corrupted file does to Day 14’s version. This corrupted.json was cut off in the middle of a save:

[
  {
    "title": "Return library books",
    "done": tr
todo_app/crash_load.py
import json

with open("corrupted.json", encoding="utf-8") as file:
    tasks = json.load(file)
print(tasks)
Traceback (most recent call last):
  File ".../day15/todo_app/crash_load.py", line 4, in <module>
    tasks = json.load(file)
  File ".../json/__init__.py", line 298, in load
  ...
  File ".../json/decoder.py", line 363, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 4 column 13 (char 55)

(The frames inside the json module are shortened here; their files and line numbers depend on your Python version.) The bottom frames are inside Python’s own json module, which is not where to fix anything. Reading up, the first frame in your file is line 4, the json.load call. The exception type is JSONDecodeError; the json module also makes it available by the shorter name json.JSONDecodeError, which is what you write in an except clause.

Here is the new load_tasks, with the rest of the module unchanged from Day 14:

todo_app/tasks.py
"""Task functions for the to-do manager. main.py imports them."""

import json


def show_menu():
    """Print the menu of choices."""
    print()
    print("1) Add  2) List  3) Complete  4) Delete  5) Quit")


def add_task(tasks, title):
    """Add a new, unfinished task to the end of tasks."""
    tasks.append({"title": title, "done": False})


def list_tasks(tasks):
    """Print every task with its number and a check box."""
    if not tasks:
        print("No tasks yet.")
        return
    for number, task in enumerate(tasks, 1):
        mark = "x" if task["done"] else " "
        title = task["title"]
        print(f"{number}. [{mark}] {title}")


def complete_task(tasks, number):
    """Mark task number (counting from 1) as done.

    Return the task, or None if no task has that number.
    """
    if not 1 <= number <= len(tasks):
        return None
    task = tasks[number - 1]
    task["done"] = True
    return task


def delete_task(tasks, number):
    """Remove task number (counting from 1) from tasks.

    Return the removed task, or None if no task has that number.
    """
    if not 1 <= number <= len(tasks):
        return None
    return tasks.pop(number - 1)


def save_tasks(tasks, path):
    """Write tasks to the JSON file at path, replacing what was there."""
    with path.open("w", encoding="utf-8") as file:
        json.dump(tasks, file, indent=2)


def load_tasks(path):
    """Return the tasks saved at path, or [] if there are none to load.

    A missing file means this is the first run. A file that is not valid
    JSON gets a warning, and the program starts with no tasks.
    """
    try:
        with path.open(encoding="utf-8") as file:
            return json.load(file)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        print(f"Warning: {path.name} is not valid JSON, so no tasks loaded.")
        return []


if __name__ == "__main__":
    test_tasks = []
    add_task(test_tasks, "Test the module")
    print(complete_task(test_tasks, 1))
    print(complete_task(test_tasks, 2))
    list_tasks(test_tasks)

And main.py, with load_tasks called directly and the task number read with try:

todo_app/main.py
"""The to-do manager's menu loop. Run this file to start the program."""

from pathlib import Path

from tasks import (
    add_task,
    complete_task,
    delete_task,
    list_tasks,
    load_tasks,
    save_tasks,
    show_menu,
)

SAVE_FILE = Path(__file__).parent / "tasks.json"


def main():
    """Run the menu loop until the user chooses Quit."""
    tasks = load_tasks(SAVE_FILE)
    print("To-do list manager")
    print(f"Loaded {len(tasks)} tasks from {SAVE_FILE.name}")
    while True:
        show_menu()
        choice = input("Choose 1-5: ").strip()
        if choice == "1":
            title = input("Task title: ").strip()
            if title:
                add_task(tasks, title)
                save_tasks(tasks, SAVE_FILE)
                print(f"Added: {title}")
            else:
                print("A task needs a title.")
        elif choice == "2":
            list_tasks(tasks)
        elif choice in ("3", "4"):
            try:
                number = int(input("Task number: "))
            except ValueError:
                print("Please type a task number, such as 1.")
                continue
            if choice == "3":
                task = complete_task(tasks, number)
                action = "Completed"
            else:
                task = delete_task(tasks, number)
                action = "Deleted"
            if task is None:
                print(f"There is no task {number}.")
            else:
                save_tasks(tasks, SAVE_FILE)
                title = task["title"]
                print(f"{action}: {title}")
        elif choice == "5":
            print("Goodbye!")
            break
        else:
            print("Please choose a number from 1 to 5.")


if __name__ == "__main__":
    main()

A run that lists, types two as a task number, completes task 2, and asks for task 9:

To-do list manager
Loaded 2 tasks from tasks.json

1) Add  2) List  3) Complete  4) Delete  5) Quit
Choose 1-5: 2
1. [x] Return library books
2. [ ] Practice piano

1) Add  2) List  3) Complete  4) Delete  5) Quit
Choose 1-5: 3
Task number: two
Please type a task number, such as 1.

1) Add  2) List  3) Complete  4) Delete  5) Quit
Choose 1-5: 3
Task number: 2
Completed: Practice piano

1) Add  2) List  3) Complete  4) Delete  5) Quit
Choose 1-5: 4
Task number: 9
There is no task 9.

1) Add  2) List  3) Complete  4) Delete  5) Quit
Choose 1-5: 5
Goodbye!

To test the two failure cases without breaking your real save file, load other paths:

todo_app/check_load.py
from pathlib import Path

from tasks import list_tasks, load_tasks

for name in ["no_such_file.json", "corrupted.json", "tasks.json"]:
    print(f"Loading {name}:")
    list_tasks(load_tasks(Path(name)))
Loading no_such_file.json:
No tasks yet.
Loading corrupted.json:
Warning: corrupted.json is not valid JSON, so no tasks loaded.
No tasks yet.
Loading tasks.json:
1. [x] Return library books
2. [ ] Practice piano

How it works: the with line and json.load are the risky lines, so they are the whole try block. A missing file is normal on the very first run, so its handler returns [] quietly. A corrupted file is not normal, so its handler warns the user before returning []. This is EAFP (15.9): no exists() check that could be out of date. Notice one cost: the next save overwrites the corrupted file, so whatever was in it is lost; the warning at least tells the user. In main, int() now decides what counts as a number (it ignores spaces around the digits by itself, so no strip() is needed), and continue goes back to the menu after a bad answer.

Reread 15.4 Catching Specific Exceptions and 15.9 Ask Forgiveness, Not Permission if your result differed.

Question 13 · Short answer

Raise and handle. Write a function parse_price(text) that turns text such as "2.50" into a float, and raises ValueError with a clear message if the price is 0 or less. Then write a caller that adds up a list of price strings, skipping (with a message) any that are not usable.

Check your reasoning

One possible solution:

parse_price.py
def parse_price(text):
    """Turn text like "2.50" into a float, or raise ValueError if it is not a usable price."""
    price = float(text)
    if price <= 0:
        raise ValueError(f"a price must be more than 0, not {text}")
    return price


def total_prices(texts):
    total = 0
    for text in texts:
        try:
            price = parse_price(text)
        except ValueError as error:
            print(f"Skipped: {error}")
        else:
            total += price
    return total


print(f"Total: {total_prices(['2.50', 'free', '-1', '4.25']):.2f}")
Skipped: could not convert string to float: 'free'
Skipped: a price must be more than 0, not -1
Total: 6.75

parse_price lets float("free") raise its own ValueError and raises one itself for -1, so the caller sees one type of exception for “not a usable price”. total_prices catches it with as error and prints the message, and its else clause adds only prices that parsed. 2.50 + 4.25 = 6.75. The function never prints; the caller decides what to do with bad input.

Reread 15.7 Raising Your Own Errors with raise if your result differed.

Wrap-up · 5 of 7

Review questions

Question 14 · Tracing

What does this program print? finally and return are both in play.

safe_divide.py
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None
    finally:
        print(f"checked {a} / {b}")


print(safe_divide(6, 3))
print(safe_divide(1, 0))
Check your reasoning
checked 6 / 3
2.0
checked 1 / 0
None

For safe_divide(6, 3), the try block works out 2.0 and starts to return it, but finally runs first, so checked 6 / 3 prints before line 10 prints the returned 2.0. For safe_divide(1, 0), the division raises, the handler returns None, and again finally prints first.

Reread 15.5 else and finally if your output differed.

Question 15 · Debugging

This program should print no price for any item missing from the dictionary. It prints bread: 2.25 and then crashes. Explain the bug and fix it.

wrong_catch.py
prices = {"apple": 0.5, "bread": 2.25}

for item in ["bread", "milk"]:
    try:
        print(f"{item}: {prices[item]:.2f}")
    except ValueError:
        print(f"{item}: no price")
Check your reasoning

The traceback ends with:

KeyError: 'milk'

Looking up a missing key raises a KeyError, not a ValueError, so the except clause does not match and the exception stops the program. Catch the type that actually happens:

wrong_catch_fixed.py
prices = {"apple": 0.5, "bread": 2.25}

for item in ["bread", "milk"]:
    try:
        print(f"{item}: {prices[item]:.2f}")
    except KeyError:
        print(f"{item}: no price")
bread: 2.25
milk: no price

The traceback’s last line tells you which type to catch. (Since in and get() work for dictionaries, an LBYL version would be fine here too.)

Reread 15.2 Common Exceptions and 15.4 Catching Specific Exceptions if you missed it.

Question 16 · Tracing

What does this program print?

review_flow.py
def check_quantity(text):
    quantity = int(text)
    if quantity < 1:
        raise ValueError("must order at least 1")
    return quantity


def order(text):
    try:
        quantity = check_quantity(text)
    except ValueError as error:
        print(f"{text}: {error}")
    else:
        print(f"{text}: ordered {quantity}")
    finally:
        print("--")


for text in ["2", "0", "x"]:
    order(text)
Check your reasoning
2: ordered 2
--
0: must order at least 1
--
x: invalid literal for int() with base 10: 'x'
--

"2" passes both checks, so else prints. For "0", int works but line 4 raises the function’s own ValueError, which propagates to order and is caught; error holds the message from line 4. For "x", int("x") raises first, with Python’s message. In every case finally prints -- last.

Reread 15.7 Raising Your Own Errors with raise if your output differed.

Question 17 · Multiple choice

What is the third line this program prints?

review_tuple.py
scores = {"Ana": [90, 85], "Ben": [70]}

for name, index in [("Ana", 1), ("Ben", 1), ("Cy", 0)]:
    try:
        print(scores[name][index])
    except (KeyError, IndexError) as error:
        print(f"problem: {error}")
  1. problem: list index out of range
  2. problem: 'Cy'
  3. problem: Cy
  4. A KeyError traceback
Check your reasoning

B. The output is:

85
problem: list index out of range
problem: 'Cy'

scores["Cy"] raises a KeyError, which the tuple (KeyError, IndexError) catches. A KeyError’s message is the missing key with quotes, so the f-string shows 'Cy'.

  • A is the second line, from scores["Ben"][1]: Ben’s list has only index 0.
  • C is wrong: a KeyError’s message keeps the quotes around a string key.
  • D is wrong: KeyError is in the tuple, so it is caught.

Reread 15.4 Catching Specific Exceptions if you chose differently.

Question 18 · Short answer

In the corrupted-file traceback from Practice question 12, the bottom frames are in Python’s json module, in files you did not write. Explain how to read such a traceback to decide what to change in your own program.

Check your reasoning

Model answer: start at the last line for what went wrong: a JSONDecodeError, meaning the file’s contents are not valid JSON. Then walk up the frames until you reach one in your own file. That frame, crash_load.py line 4 with json.load(file), is where your code handed bad data to the library, and it is where you can act: wrap that call in a try and catch json.JSONDecodeError. Frames inside Python’s own modules show how the library noticed the problem; they are almost never where the fix goes.

A correct answer reads the last line for the type, finds the lowest frame in your own code, and fixes or handles the problem there rather than in the library.

Reread 15.1 Reading a Traceback from the Bottom Up if your reasoning differed.

Question 19 · Short answer

Day 12’s to-do manager checked a task number with all(ch in "0123456789" for ch in text) before int(text), then checked 1 <= number <= len(tasks). Which of these two checks would you replace with try and except ValueError, which would you keep as an if (or move into a function that raises TaskNumberError), and why?

Check your reasoning

Model answer: replace the digits check with EAFP. It is a partial copy of rules int() already has (it refuses +3, which int() accepts), and getting such a check exactly right is hard, while int() already knows the rules and raises ValueError when they are broken. Keep the range check: 1 <= number <= len(tasks) is simple, complete, and nothing in Python would raise for it, because tasks[number - 1] with number 0 gives tasks[-1], the last task, instead of an error. Moving that check into a function that raises TaskNumberError lets the menu loop handle “not a number” and “no such task” with two separate except clauses.

A correct answer says that EAFP suits the conversion because the real rules live in int(), and that the range must be checked by the program because no exception would report it reliably.

Reread 15.9 Ask Forgiveness, Not Permission and 15.8 Defining a Custom Exception if your reasoning differed.

Wrap-up · 6 of 7

Go further

The official Python Tutorial covers today’s topics, with a few more advanced features such as exception chaining and exception groups, in Errors and Exceptions. Its section on user-defined exceptions uses classes, which you meet tomorrow on Day 16.

Wrap-up · 7 of 7

Reflection

Check each statement. If the answer is “not yet,” reread the linked subsection.

  • I can read a traceback from the bottom up and find where and why a program stopped. Reread
  • I can say what each common exception type means. Reread
  • I can catch an exception with try and except and say which lines are skipped. Reread
  • I can catch specific exception types, read the message with as, and explain why a bare except is a mistake. Reread
  • I can use else and finally and predict when each runs. Reread
  • I can follow an exception up through function calls to the handler that catches it. Reread
  • I can raise a ValueError with a clear message from my own function. Reread
  • I can define, raise, and catch a custom exception. Reread
  • I can choose between checking first and trying first, and explain the choice. Reread