In the previous lesson we learned to read the alert; in this one we learn to take charge of it. The tool is the try/except structure, and with it Papyrus is going to settle three concrete debts today: load_catalog() will stop dying when catalog.json doesn't exist (goodbye to the Path.exists() band-aid), the str→float toll on the CSV will stop "praying it brings numbers" and will survive corrupt rows, and the int(input()) that has dragged its ValueError along since module 1 — patched in module 2 with isdigit() — will at last receive its definitive validation. Along the way we'll cover the four pieces of the complete structure (try, except, else, finally), how to capture the exception object with as e, and a table of antipatterns headed by the worst of them all: the bare except:.

Contents

  1. The try/except structure: asking forgiveness, at last
  2. Catching specific types (and why never a bare except:)
  3. Multiple except clauses and capturing the object with as e
  4. The remaining pieces: else and finally
  5. Papyrus, robust edition: load_catalog() and the sales CSV
  6. Settling the module 1 debt: ask_int()

The try/except structure: asking forgiveness, at last

The minimal syntax has two blocks: what you attempt and what you do if it fails:

try:
    price = float(text)              # the attempt
except ValueError:
    print(f"'{text}' is not a valid price.")   # plan B
    price = 0.0

How execution flows, step by step:

  1. Python executes the try block line by line.
  2. If no exception is raised, the except block is skipped entirely and life goes on.
  3. If a line in the try raises an exception, execution abandons the try immediately (the remaining lines of the block don't run) and Python looks for an except whose type matches.
  4. Matching means: the exception is of that class or a subclass — the hierarchy from 07-01 in action. An except LookupError: catches both KeyError and IndexError.
  5. If no except matches, the exception keeps propagating upwards, as if the try weren't there.

Point 5 is important: try is not a magic shield. It only catches what you tell it to catch. And that's a virtue, as we're about to see.

Catching specific types (and why never a bare except:)

The beginner's temptation is "let nothing fail": wrap everything and catch everything. It's the most expensive mistake in the whole module, because it turns noisy errors into silent errors — and a silent error can cost Ana money without anyone finding out.

# ❌ THE ANTIPATTERN: never do this
try:
    catalog = load_catalog()
except:                    # catches EVERYTHING, even what it shouldn't
    catalog = {}           # ...and sweeps it under the rug

What does that bare except: hide? Everything. If there's a typo catlog inside load_catalog() (a NameError, your own bug), the program starts with an empty catalog and nobody will ever know why. If Ana presses Ctrl+C to cancel (KeyboardInterrupt, which inherits from BaseException and also falls into a bare except:), the program ignores her. The traceback — that free forensic report from 07-01 — goes straight in the trash.

Antipattern Why it's harmful Correct alternative
Bare except: Catches even KeyboardInterrupt and SystemExit; hides your own bugs except SpecificType:
Reflexive except Exception: Less serious (it lets Ctrl+C through) but still mixes bugs with expected failures Catch the type you know how to handle; Exception only at the program's outer boundary (07-05)
except ValueError: pass Silences the error without leaving any trace At minimum, report or count; ideally record it in the log (07-05)
Giant try around 30 lines Impossible to know which line failed or whether the except handles the right thing Minimal try: only the line(s) that can fail in that way
Catch and return a "normal" value (return -1) The error dresses up as data and blows up further away, where it's unreadable Return None with judgement (07-03) or let it propagate

The rule that sums up the table: catch only what you expect and know how to handle; let everything else blow up. An unhandled exception with its traceback is infinitely better than a program that lies.

Multiple except clauses and capturing the object with as e

A single try can fail in different ways that deserve different reactions. You chain several except clauses, and Python uses the first one that matches (which is why specific types go before generic ones):

try:
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
except FileNotFoundError:
    print("No previous catalog. Starting with an empty one.")
    data = []
except json.JSONDecodeError as e:
    print(f"The catalog is corrupt ({e}). Check the file or restore a backup.")
    raise SystemExit(1)

Two new things here:

  • as e stores the exception object in the variable e, available inside the block. Remember from 07-01 that the exception is an object: print(e) shows its message, and some types carry extra attributes (e.lineno in JSONDecodeError tells you which line of the JSON holds the damage). The variable only exists inside its except block.
  • json.JSONDecodeError is the answer to another question from the end of M6: "what if the backup is corrupt?". If someone hand-edited catalog.json badly — module 6 could only plead "don't botch the JSON" —, json.load() raises this exception, and now we know how to distinguish missing file (recoverable: empty catalog) from corrupt file (don't invent data: report and stop).

If several exceptions deserve the same reaction, group them in a tuple: except (ValueError, KeyError) as e:.

The remaining pieces: else and finally

The complete structure has four blocks, each with a precise role:

try:
    f = open(path, encoding="utf-8")     # can fail
except FileNotFoundError:
    print("File not found.")             # plan B
else:
    content = f.read()                   # only if the try WENT WELL
    f.close()
finally:
    print("End of read attempt.")        # ALWAYS, no matter what
Block Runs when... What it's for
try Always (it's the attempt) The operation(s) that can fail
except Only if the try raised a matching exception Plan B: recover, report, record
else Only if the try finished without an exception The work that depends on success, kept out of the try so its errors aren't caught by accident
finally Always: on success, on a handled exception, on an unhandled exception, and even with a return in the way Guaranteed cleanup: close, release, leave everything in order

Why bother with else instead of writing it all inside the try? Precision: if f.read() were inside the try and failed for some other reason, an overly broad except could catch it and misdiagnose. The try should shelter only the lines whose failure you know how to handle; else picks up the continuation of the happy path.

And finally? The guarantee should sound familiar: it's exactly what module 6's with does for you with files — close no matter what. In fact, with is built on top of this mechanism, and in module 8 we'll learn to build our own context managers. Meanwhile, the practical pairing is: with for files, finally for any other cleanup that must happen without fail (printing a closing summary, restoring a state).

flowchart TD
    A[try] -->|no exception| B[else]
    A -->|matching exception| C[except]
    A -->|NON-matching exception| D[propagates upwards]
    B --> E[finally]
    C --> E
    D --> E
    E -->|if it was propagating| F[keeps propagating]

Papyrus, robust edition: load_catalog() and the sales CSV

Time to pay module 6's debts. This is how load_catalog() looked back then, band-aid included:

# M6 version — with band-aid
def load_catalog():
    path = BASE / "data" / "catalog.json"
    if not path.exists():                      # LBYL: a snapshot of the past
        return {}
    with open(path, encoding="utf-8") as f:
        records = json.load(f)
    return {r["title"].strip().casefold(): Book(**r) for r in records}

And this is the EAFP version, which also distinguishes missing from corrupt:

# M7 version — EAFP
def load_catalog():
    path = BASE / "data" / "catalog.json"
    try:
        with open(path, encoding="utf-8") as f:
            records = json.load(f)
    except FileNotFoundError:
        print("Warning: catalog.json does not exist. Starting from an empty catalog.")
        return {}
    except json.JSONDecodeError as e:
        print(f"Error: catalog.json is corrupt ({e}).")
        print("Restore a copy from backups/ before continuing.")
        raise SystemExit(1)
    return {r["title"].strip().casefold(): Book(**r) for r in records}

Point by point:

  • The try covers only the opening and the parsing — the two operations that can fail in these ways. Building the dictionary stays outside: if Book(**r) failed (a negative price in the JSON would trigger the ValueError from M5's __post_init__), we want to see that traceback, not confuse it with a file problem.
  • FileNotFoundError is an expected and recoverable case: first startup on a new computer. Empty catalog + honest warning. No race condition: the open() is the check.
  • JSONDecodeError is expected but not silently recoverable: inventing an empty catalog when a corrupt one exists would make Ana overwrite her real data on the next save. We report with the error's detail and stop. Handling an exception well sometimes means stopping better, not carrying on at any cost.

Second debt: the str→float toll on sales.csv (06-02). If a row brings "free" as the amount, the M6 version dies mid-sum. The M7 version skips the corrupt row, counts the errors and carries on:

def close_till():
    path = BASE / "data" / "sales.csv"
    total, sales_ok, corrupt_rows = 0.0, 0, 0
    try:
        with open(path, encoding="utf-8", newline="") as f:
            for row in csv.DictReader(f):
                try:
                    total += float(row["amount"])   # the str→float toll
                except ValueError:
                    corrupt_rows += 1
                    print(f"  Row skipped, unreadable amount: {row!r}")
                else:
                    sales_ok += 1
    except FileNotFoundError:
        print("No sales recorded today.")
        return 0.0
    if corrupt_rows:
        print(f"Warning: {corrupt_rows} corrupt row(s) in sales.csv.")
    print(f"Till close: {sales_ok} sales, total {total:.2f} EUR")
    return total

Notice the nested, minimal try: the inner one shelters a single line (the conversion) and catches a single type (ValueError). A bad row no longer brings down the day's close — but it doesn't vanish either: it's counted and shown, because silencing it would be the pass antipattern from the table. The inner else adds the sale to the counter only when the conversion went well: exactly the role we assigned to else.

Settling the module 1 debt: ask_int()

The oldest debt in the course. In module 1, int(input()) blew up if Julia typed "three". In module 2 we patched it with while True + isdigit(), and even then we warned it was provisional. Why? Because isdigit() tries to predict what int() will accept, and it predicts badly: "-2".isdigit() is False (it rejects legitimate negatives), " 4 ".isdigit() is False (it rejects spaces that int() tolerates). It's LBYL with a validator that doesn't match the converter. The EAFP solution: let int() itself decide.

def ask_int(prompt):
    """Ask for an integer at the keyboard until the input is valid."""
    while True:
        text = input(prompt)
        try:
            return int(text)           # if it converts, we return and exit
        except ValueError:
            print(f"'{text}' is not a whole number. Try again.")


units = ask_int("How many copies of Hamlet? ")
  • The return inside the try breaks the loop as soon as there's a valid integer — no break needed.
  • If int() raises ValueError, the except reports (showing what was typed, an actionable message) and the while True retries.
  • The judge is int() itself: it accepts "-2" and " 4 ", rejects "three" and "4.5". Zero disagreements between validator and converter, because they are the same thing.

The same template works for ask_price() with float(): keep it, because it's one of the most reused functions you'll ever write.

Common Mistakes and Tips

  • The bare except:. Antipattern number one. It catches even Ana's Ctrl+C and buries your own bugs. Always a specific type.
  • The mile-long try. If the block has ten lines, you no longer know which one failed or whether your except is the right reaction. Shrink the try to the guilty line; use else for the continuation.
  • Silencing with pass. except ValueError: pass makes data disappear without a trace. In close_till() the corrupt rows are counted and shown; in 07-05 they'll go to the log.
  • Ordering the except clauses badly. Python uses the first one that matches: if you put except Exception: before except ValueError:, the second is dead code. Specific to general.
  • Forgetting that else exists. It's not mandatory, but shoving the happy path inside the try "because it fits" ends up catching exceptions you didn't want to catch.
  • Tip: before writing an except, ask yourself: "what am I going to do with this error?". If the answer is "nothing", you probably shouldn't catch it: let it propagate to someone who can actually do something.
  • Tip: notice that our warning messages always include the guilty data ('{text}', {row!r}). A warning without the data forces guessing; with the data, it's fixed in a minute.

Exercises

  1. ask_price(). Write the twin of ask_int(): ask for a price with float(), retry on ValueError and, in addition, reject negative prices with a different message ("a price can't be negative") without leaving the loop. Try it with "9,95" (decimal comma, very common across Europe): what happens and why?

  2. A bulletproof load_config(). Module 6 left config.json with {"store_name", "book_vat", "member_discount"}. Write load_config() so that it: returns the dictionary if all goes well; on FileNotFoundError returns the default configuration {"store_name": "Papyrus", "book_vat": 0.04, "member_discount": 0.05} with a warning; and on json.JSONDecodeError shows the error and raises SystemExit(1). Add a finally that always prints "Configuration: process finished." and verify it appears in all three scenarios.

  3. Hunt the antipattern. This code "works", but it commits at least three sins from the antipattern table. Identify them and rewrite it:

    def member_price(catalog, title):
        try:
            book = catalog[title.strip().casefold()]
            price = book.price * (1 - MEMBER_DISCOUNT)
            print("The member price of " + title + " is " + str(round(price, 2)))
            return price
        except:
            return -1
    

Solutions

  1. def ask_price(prompt):
        while True:
            text = input(prompt)
            try:
                price = float(text)
            except ValueError:
                print(f"'{text}' is not a number. Use a decimal point, e.g. 9.95")
                continue
            if price < 0:
                print("A price can't be negative. Try again.")
                continue
            return price
    

    With "9,95" a ValueError is raised: float() only understands the decimal point. That's why the except message suggests the correct format — an actionable message. (Friendly alternative: text.replace(",", ".") before converting.) Note the order: first the conversion (EAFP), then validation of the already-converted value (LBYL on data you already have in hand, legitimate as we saw in 07-01).

  2. DEFAULT_CONFIG = {"store_name": "Papyrus", "book_vat": 0.04, "member_discount": 0.05}
    
    def load_config():
        path = BASE / "data" / "config.json"
        try:
            with open(path, encoding="utf-8") as f:
                return json.load(f)
        except FileNotFoundError:
            print("Warning: no config.json; using default values.")
            return dict(DEFAULT_CONFIG)
        except json.JSONDecodeError as e:
            print(f"config.json is corrupt: {e}")
            raise SystemExit(1)
        finally:
            print("Configuration: process finished.")
    

    The finally prints in all three scenarios — even with the return statements in the way and even while SystemExit is propagating. That is its guarantee. Fine detail: we return dict(DEFAULT_CONFIG) (a copy) so nobody mutates the shared default dictionary — a memory of module 4's aliasing.

  3. Sins: (1) bare except: — it would catch even a NameError if MEMBER_DISCOUNT weren't defined, hiding a bug; (2) try too wide — only the catalog[...] access can fail in an expected way (KeyError); the print and the arithmetic shouldn't be sheltered; (3) returning -1 as an error disguised as a price — whoever receives that -1 might charge it to Julia. Rewrite:

    def member_price(catalog, title):
        try:
            book = catalog[title.strip().casefold()]
        except KeyError:
            print(f"'{title}' is not in the catalog.")
            return None
        return round(book.price * (1 - MEMBER_DISCOUNT), 2)
    

    And is None the best signal for "not found"? Or should this function raise its own exception? That exact question — when to return None and when to raise — opens the next lesson.

Conclusion

You now know how to ask forgiveness with style: a minimal try around the line that can fail, except with specific types (never bare), several except clauses ordered from specific to general, as e to make use of the exception object's message, else for the happy path that depends on success, and finally for unconditional cleanup. Papyrus has paid three debts: load_catalog() distinguishes "no catalog" (recoverable) from "corrupt catalog" (better to stop), close_till() survives unreadable rows by counting them instead of dying or silencing them, and ask_int() retires the impostor isdigit() by letting int() be its own judge. So far we've only reacted to exceptions Python raises on its own. The next lesson flips the roles: you'll learn to raise them yourself with raise — deciding when a situation deserves an exception and when a simple None, re-raising, chaining with from, and protecting your functions with guard clauses. The raise ValueError that Book's __post_init__ has been doing since module 5 will stop being an act of faith and become a design decision.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved