Module 6 ended with an uncomfortable list of "band-aids": Path.exists() before reading, "don't botch the JSON when you edit it", "pray the CSV brings numbers". Papyrus already has memory — the catalog, sales, members and backups live in data/ — but that memory is fragile: if catalog.json isn't where it should be, if a CSV row brings "free" in the price column, or if Julia types an impossible number, the program dies with a block of red text splashed across the screen. That block of text has a name (a traceback), that "dying" has a mechanism (an exception that nobody catches), and this whole module is about mastering both. In this first lesson we won't fix anything yet: we'll understand what an exception really is, learn to read a traceback the way you'd read an incident report, meet the built-in exceptions Papyrus has already suffered without knowing their names, and adopt the philosophy Python prefers: asking forgiveness rather than permission.

Contents

  1. What an exception is: the mechanism, not the disaster
  2. Anatomy of a traceback: read it bottom-up
  3. Syntax errors vs runtime exceptions
  4. The built-in exceptions Papyrus already knows (without knowing it)
  5. The exception hierarchy: Exception as the mother class
  6. EAFP vs LBYL: why Path.exists() was a band-aid

What an exception is: the mechanism, not the disaster

An exception is an object that Python creates when an operation cannot complete, and that interrupts the normal flow of the program to travel upwards looking for someone to take charge. Three key ideas:

  • It's an object. Just as Book("Hamlet", 9.95, 6) is an instance of the Book class, a ValueError("price cannot be negative") is an instance of the ValueError class. It carries a message inside and, as we'll see in 07-04, it can carry attributes of its own.
  • It propagates. When it occurs, execution abandons the current line, leaves the function, leaves the function that called it, and so on. This upward journey is called propagation, and it's what makes an error on line 3 of a helper function end up looking like a failure of the whole program.
  • If nobody catches it, the program terminates. Python prints the traceback and hands control back to the operating system. That's what Ana has been seeing so far: unhandled exceptions.

Note the nuance: the exception is not the problem. The exception is the structured alert that a problem occurred. The disaster is having nobody listening for the alert.

# book_record.py — the same innocent code from module 1
price_text = input("Book price: ")           # Julia types: free
price = float(price_text)                    # the exception is born here
print(f"With VAT: {price * 1.04:.2f} EUR")   # this line never runs

When float("free") can't produce a number, Python builds a ValueError object, abandons line 3 and, since nobody catches it, the program terminates. The print line never gets executed: propagation cuts the flow dead.

Anatomy of a traceback: read it bottom-up

This is what Python prints as the previous example dies:

Traceback (most recent call last):
  File "book_record.py", line 2, in <module>
    price = float(price_text)
ValueError: could not convert string to float: 'free'

And here is a real case with several chained functions, in the style of module 6's register.py:

Traceback (most recent call last):
  File "register.py", line 42, in <module>
    catalog = load_catalog()
  File "register.py", line 18, in load_catalog
    with open(BASE / "data" / "catalog.json", encoding="utf-8") as f:
FileNotFoundError: [Errno 2] No such file or directory: 'data/catalog.json'

The golden rule for reading it: start at the bottom.

  1. Last line: the exception type (FileNotFoundError) and its message. It's the what. Always read it first.
  2. Second-to-last entry (file + line + code): the exact place where the exception was born. It's the where.
  3. From there upwards: the chain of calls that led there (<module> called load_catalog, which ran the open). It's the how we got here. most recent call last means literally that: the most recent call is at the end.
Part of the traceback Question it answers In the example
Last line (Type: message) What failed? data/catalog.json doesn't exist
Last File ... line ... entry Where was it born? Line 18, inside load_catalog
Entries above Who called whom? The main program (line 42) called load_catalog

A traceback is not a punishment: it's the best free incident report in existence. Learning to read it calmly will save you hours; copying it in full (last line included) is also the right way to ask for help.

Syntax errors vs runtime exceptions

Not all red text is the same. There are two radically different families:

# SyntaxError: Python doesn't even start executing
if price > 0
    print("valid price")
  File "register.py", line 1
    if price > 0
                ^
SyntaxError: expected ':'
Syntax error (SyntaxError) Runtime exception
When it appears Before anything executes: when the file is compiled Mid-execution, when the problematic line is reached
Depends on the data No: it always fails, with any input Yes: float(text) works with "12.50" and blows up with "free"
Can it be handled with this module's mechanism? No: you fix the code and that's that Yes: it's exactly what try/except (07-02) is for
Papyrus example Forgetting the colon on an if The sales CSV brings a corrupt row

The practical consequence matters: a SyntaxError is always your bug and is fixed by editing; a runtime exception may be a bug or it may be the real world behaving like the real world (missing files, users typing anything at all). This module deals with the second family.

The built-in exceptions Papyrus already knows (without knowing it)

Python ships with dozens of predefined exceptions. These seven cover 90% of everyday work, and every one of them already has an episode in the Papyrus story:

Exception When it's raised Papyrus example
ValueError The type is right but the value isn't acceptable float("free") while reading sales.csv; Book's __post_init__ with price=-5 (M5)
TypeError The data type isn't what the operation expects "12.50" * 0.04 doesn't fail (it repeats text), but "12.50" + 0.04 does: you can't add str and float
KeyError Missing key in a dictionary catalog["faust "] — the trailing space breaks the key normalized with strip().casefold() (M4)
IndexError Index out of range in a list or tuple recent_sales[10] when there were only 3 sales today
FileNotFoundError Attempting to open a file that doesn't exist open(BASE / "data" / "catalog.json") on Ana's new laptop, where data/ doesn't exist yet (M6)
ZeroDivisionError Division by zero total_sales / number_of_sales in close_till() on a day with no sales
AttributeError The object doesn't have that attribute or method book.titel (a typo) instead of book.title; or calling .append() on the catalog dict

It's worth triggering them on purpose in the interactive interpreter: type int("Hamlet"), {}["faust"], [][0]... Seeing each one's name and message in a controlled environment means you'll recognize them instantly when they show up in a real traceback.

>>> catalog = {"hamlet": "Book(Hamlet, 9.95, 6)"}
>>> catalog["Hamlet "]         # we forgot to normalize the key
Traceback (most recent call last):
  ...
KeyError: 'Hamlet '

The KeyError message shows the key that was looked up with its quotes: that's where the treacherous space becomes visible. Another reason to read the last line with a magnifying glass.

The exception hierarchy: Exception as the mother class

Exceptions are classes, and like every Python class (we saw this in module 5) they can inherit. Almost all the ones you care about descend from Exception:

graph TD
    BE[BaseException] --> KI[KeyboardInterrupt<br/>Ctrl+C]
    BE --> EX[Exception]
    EX --> AE[ArithmeticError] --> ZD[ZeroDivisionError]
    EX --> LE[LookupError] --> KE[KeyError]
    LE --> IE[IndexError]
    EX --> VE[ValueError]
    EX --> TE[TypeError]
    EX --> ATE[AttributeError]
    EX --> OS[OSError] --> FNF[FileNotFoundError]

Ideas worth retaining now (we'll put them to work in 07-02 and 07-04):

  • Exception is the mother of "normal" errors: the ones a program can reasonably handle.
  • BaseException sits above it and includes things you should almost never catch, such as KeyboardInterrupt (the Ctrl+C Ana uses to stop the program on purpose). That's why "catch everything" is a bad idea: you could even prevent the user from cancelling.
  • The intermediate categories group things: KeyError and IndexError are both LookupError ("I looked something up and it wasn't there"); FileNotFoundError is an OSError (a problem with the operating system). Handling the category handles all its children — the same polymorphism from module 5, applied to errors.
  • In 07-04 we will extend this tree with Papyrus's own exceptions, hanging off Exception exactly like ValueError does.

EAFP vs LBYL: why Path.exists() was a band-aid

Module 6 called it a band-aid over and over. Now we can explain why with the proper vocabulary. There are two philosophies for dealing with operations that can fail:

  • LBYLLook Before You Leap: check the preconditions before acting. if path.exists(): open(path). That's what we did in module 6.
  • EAFPEasier to Ask Forgiveness than Permission: act directly and handle the exception if something goes wrong. This is Python's idiomatic style.
Criterion LBYL (if path.exists():) EAFP (try and handle)
Race condition Time passes between the exists() and the open(): another process (the nightly backup!) can delete or move the file in between. The check guarantees nothing The open() is itself the check: it either works or reports, with no gap in between
Double work The system looks up the file twice: once for exists(), again for open() A single operation
Coverage exists() only covers "doesn't exist". What if it exists but is corrupt, or lacks read permission, or is a directory? You'd need an if per case The exception covers any failure, even the ones you didn't anticipate
Readability The happy path is buried under checks The happy path reads cleanly; error handling stands apart

The race condition deserves a concrete example: Papyrus's nightly backup (make_backup(), M6) renames and moves files in data/. If register.py checks catalog_json.exists()True, and a tenth of a second later the backup process moves that file, the subsequent open() blows up despite the check. LBYL gives a false sense of security: you verified a snapshot of the past, not the present.

Careful: EAFP does not mean "never validate". Validating input data (is this price Julia typed negative?) is still correct, and we'll refine it in 07-03. What EAFP discourages is trying to predict the state of the outside world (files, network, other processes) with prior checks, when the attempt itself already tells you the truth.

What we still don't know how to do is "ask forgiveness": handle the exception when it arrives. That's exactly the topic of the next lesson.

Common Mistakes and Tips

  • Reading the traceback top-down and panicking by the second line. The other way around: last line first (type and message), then the point of origin, then the call chain. Bottom-up, always.
  • Confusing a SyntaxError with a handleable exception. If the error appears before anything runs, it's syntax: you fix the code, you don't "handle" it.
  • Thinking KeyError: 'Hamlet ' and KeyError: 'hamlet' are the same error. The message shows the exact key in quotes: spaces and capitalization included. In Papyrus, it almost always means somebody skipped the strip().casefold() normalization.
  • Believing that Path.exists() "fixes" the missing-file problem. It only covers one failure among many and leaves a time window open. It's a band-aid — module 6 said so, and now you know why.
  • Tip: when a traceback baffles you, reproduce the exception by hand in the interpreter with minimal data (float("free"), {}["x"]). Isolating the failure from the big program is half the diagnosis.
  • Tip: keep the tracebacks you run into over the next few days. In 07-05 you'll learn to send them automatically to a log file instead of losing them when you close the terminal.

Exercises

  1. Traceback detective. Without running anything, predict which exception (exact type) each snippet raises and why: (a) int("12.50"); (b) catalog = {} followed by catalog["hamlet"].price; (c) sales = [] followed by sales[0]; (d) "Faust" + 21.00. Then check in the interpreter and compare the real message with your prediction.

  2. Traceback autopsy. Given this traceback, answer: what failed?, on what line and in what function was it born?, what was the complete call chain?, what piece of data triggered the failure?

    Traceback (most recent call last):
      File "register.py", line 31, in <module>
        total = close_till()
      File "register.py", line 22, in close_till
        amounts.append(float(row["amount"]))
    ValueError: could not convert string to float: 'twelve fifty'
    
  3. Classify the philosophies. For each Papyrus situation, decide whether LBYL or EAFP fits better and justify it in one sentence: (a) opening data/config.json at startup; (b) checking that units is greater than 0 before selling; (c) accessing catalog[key] with a key Julia typed; (d) checking that data/ exists before creating it with mkdir.

Solutions

  1. (a) ValueError: the type (str) is acceptable for int(), but the value "12.50" doesn't represent an integer — int() doesn't truncate decimal strings. (b) KeyError: 'hamlet': the dictionary is empty; note that the failure happens at the [...] access, before ever reaching .price. (c) IndexError: empty list, there is no position 0. (d) TypeError: you can't concatenate str with float; the real message says can only concatenate str (not "float") to str.
  2. What: a ValueError because float() received 'twelve fifty', which can't be converted to a number. Where: line 22, inside close_till. Chain: the main program (<module>, line 31) called close_till(), which on its line 22 tried to convert the amount field of a CSV row. Guilty data: somebody wrote the amount in words in sales.csv — exactly the "pray the CSV brings numbers" scenario from module 6.
  3. (a) EAFP: it's outside-world state; attempting the open and handling FileNotFoundError avoids the race condition and covers more failures than exists(). (b) LBYL (input validation): it's data you already have in hand; checking beforehand is cheap, with no time window — in 07-03 we'll see that the correct reaction to that check is raise. (c) EAFP: try with direct access (or .get(), which we saw in M4) is more Pythonic than if key in catalog followed by the access — two lookups instead of one. (d) Trick question: neither — path.mkdir(exist_ok=True) (M6) removes the problem at the root; the best check is the one you don't need.

Conclusion

You now have the map of the territory: an exception is an object that interrupts the flow and propagates upwards until somebody catches it or the program dies; the traceback is its forensic report and is read bottom-up; syntax errors are a different species (you fix them, you don't handle them); the built-in exceptions form a class hierarchy with Exception as the mother, and Python prefers EAFP — try it and ask forgiveness — over the LBYL of Path.exists(), which leaves race conditions open and only covers the failures you knew to anticipate. For now, though, we're still where Ana is: we see the punch coming and don't know how to take it. The next lesson introduces the central tool of the whole module, try/except, and with it we'll settle two historic debts: load_catalog() will stop depending on catalog.json existing, and the int(input()) that has been dragging its ValueError along since module 1 will finally get proper validation.

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