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
- The
try/exceptstructure: asking forgiveness, at last - Catching specific types (and why never a bare
except:) - Multiple
exceptclauses and capturing the object withas e - The remaining pieces:
elseandfinally - Papyrus, robust edition:
load_catalog()and the sales CSV - 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.0How execution flows, step by step:
- Python executes the
tryblock line by line. - If no exception is raised, the
exceptblock is skipped entirely and life goes on. - If a line in the
tryraises an exception, execution abandons thetryimmediately (the remaining lines of the block don't run) and Python looks for anexceptwhose type matches. - Matching means: the exception is of that class or a subclass — the hierarchy from 07-01 in action. An
except LookupError:catches bothKeyErrorandIndexError. - If no
exceptmatches, the exception keeps propagating upwards, as if thetryweren'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 rugWhat 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 estores the exception object in the variablee, 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.linenoinJSONDecodeErrortells you which line of the JSON holds the damage). The variable only exists inside itsexceptblock.json.JSONDecodeErroris the answer to another question from the end of M6: "what if the backup is corrupt?". If someone hand-editedcatalog.jsonbadly — 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
trycovers only the opening and the parsing — the two operations that can fail in these ways. Building the dictionary stays outside: ifBook(**r)failed (a negative price in the JSON would trigger theValueErrorfrom M5's__post_init__), we want to see that traceback, not confuse it with a file problem. FileNotFoundErroris an expected and recoverable case: first startup on a new computer. Empty catalog + honest warning. No race condition: theopen()is the check.JSONDecodeErroris 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 totalNotice 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
returninside thetrybreaks the loop as soon as there's a valid integer — nobreakneeded. - If
int()raisesValueError, theexceptreports (showing what was typed, an actionable message) and thewhile Trueretries. - 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 yourexceptis the right reaction. Shrink thetryto the guilty line; useelsefor the continuation. - Silencing with
pass.except ValueError: passmakes data disappear without a trace. Inclose_till()the corrupt rows are counted and shown; in 07-05 they'll go to the log. - Ordering the
exceptclauses badly. Python uses the first one that matches: if you putexcept Exception:beforeexcept ValueError:, the second is dead code. Specific to general. - Forgetting that
elseexists. It's not mandatory, but shoving the happy path inside thetry"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
-
ask_price(). Write the twin ofask_int(): ask for a price withfloat(), retry onValueErrorand, 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? -
A bulletproof
load_config(). Module 6 leftconfig.jsonwith{"store_name", "book_vat", "member_discount"}. Writeload_config()so that it: returns the dictionary if all goes well; onFileNotFoundErrorreturns the default configuration{"store_name": "Papyrus", "book_vat": 0.04, "member_discount": 0.05}with a warning; and onjson.JSONDecodeErrorshows the error and raisesSystemExit(1). Add afinallythat always prints"Configuration: process finished."and verify it appears in all three scenarios. -
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
-
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 priceWith
"9,95"aValueErroris raised:float()only understands the decimal point. That's why theexceptmessage 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). -
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
finallyprints in all three scenarios — even with thereturnstatements in the way and even whileSystemExitis propagating. That is its guarantee. Fine detail: we returndict(DEFAULT_CONFIG)(a copy) so nobody mutates the shared default dictionary — a memory of module 4's aliasing. -
Sins: (1) bare
except:— it would catch even aNameErrorifMEMBER_DISCOUNTweren't defined, hiding a bug; (2)trytoo wide — only thecatalog[...]access can fail in an expected way (KeyError); theprintand the arithmetic shouldn't be sheltered; (3) returning-1as an error disguised as a price — whoever receives that-1might 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
Nonethe best signal for "not found"? Or should this function raise its own exception? That exact question — when to returnNoneand when toraise— 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
