python/volume-1/chapter-13ch 13 / 64 · 19 min

13When it breaks

In Chapter 12 we gave data behaviour — objects that carry their own methods. Here we meet the one object nobody builds on purpose: the exception, the thing Python constructs the instant it hits a wall it can't climb. Here's the plan. We'll watch one get born on the heap, throw it, and follow it as it climbs the call stack looking for someone to catch it. Then we slip a try/except net under exactly the line that might drop, and no wider. The whole way through, we keep asking the one question that matters — when something fails, what is Python really doing in the moment before your program dies? By the end you'll read a traceback like a flight log, catch only the errors you expect while letting real bugs surface, raise errors of your own, and write an input loop that no mistyped line — 3:20 where a plain number belonged — can ever crash.

An exception is an object, not a scream try / except: a net under the risky line else and finally: the two forgotten blocks Catch the error you expect — not all of them
scroll to begin

01An exception is an object, not a scream

Let's start with the thing itself. Ask Python to do something it genuinely can't — divide by zero, reach past the end of a list, look up a key that was never there — and watch what happens. It doesn't panic, and it doesn't keel over. It does something oddly calm: it builds a small object. That object lives on the heap, chapter 6's open region where every value you've ever made already sits, and its whole job is to describe what just went wrong. So read ZeroDivisionError, KeyError, ValueError not as noises the machine barks at you, but as what they really are: class names. Each one is a blueprint. A failure at runtime is a single instance of that blueprint, freshly built, with the details of the mishap tucked inside.

Then watch the object travel. Raising is Python's verb for launching it. A raised exception leaps up out of the current line, out of the current function, and up through every caller that was patiently waiting on a result — abandoning each stack frame mid-sentence as it climbs. (A stack frame is chapter 9's word for one paused function call: its local names, and the exact spot it was waiting to resume.) All the way up, it's looking for someone willing to handle it. If nobody volunteers, it runs out of frames at the very top, Python prints a traceback — the flight log of everywhere it passed through — and the program stops. That's the whole life cycle. An "error" isn't a special kind of catastrophe. It's just an ordinary object that nobody caught in time.

THE ONE IDEA TO CARRY FORWARD
An exception is a value, not an event. It is born — a heap object stamped with a type and an .args payload — it is thrown, it rises through the callers frame by frame, and either someone catches it or it reaches the top and takes the program down. Nothing here is magic — it is object creation and travel. Every other idea in this chapter is just watching that one sentence play out.
wild.pypython
# our playlist entry — Blinding Lights, 200 seconds
song = {"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200}
print(song["genre"])
# KeyError: 'genre'   ← not a message barked at you —
#                      an object of class KeyError, and nobody caught it

Because it is a genuine object, you can do object things to it — even build one by hand, without anything going wrong at all. Constructing an exception and raising one are two separate acts. The object exists quietly the moment you make it, and only raise sends it flying. Here it is sitting still, letting you read its insides:

dissect.pypython
e = KeyError("genre")        # a value, constructed — not raised, nothing has failed
print(type(e).__name__)      # KeyError      — the class it is an instance of
print(e.args)               # ('genre',)    — the payload it carries
print(str(e))                # 'genre'       — its human-readable message
print(isinstance(e, LookupError))   # True — a KeyError *is a kind of* LookupError

So a runtime error is a box with a name on the lid (its class), a small parcel inside (.args), and a sentence it will read aloud if asked (str(e)). It also has an identity — id(e) is its address on the heap — and it takes up room. On this machine's CPython 3.12, sys.getsizeof(e) reports that the object itself weighs 88 bytes. A scream has no size. This has a size.

One KeyError, dissectedfigure
what is inside the object what it is a kind of KeyError an instance, on the heap type(e).__name__ 'KeyError' e.args ('genre',) str(e) 'genre' id(e) → a heap address · getsizeof → 88 B object BaseException Exception LookupError KeyError each step up reads “is a” · KeyError.__mro__
Fig 01 — The same failure, two honest views. On the left, the object's contents — a class, a payload, a message. On the right, its ancestry: a KeyError is a LookupError is an Exception. That family tree is not decoration — it is exactly how a handler for a parent will scoop up a child. (You'll cash that in section 04: one except LookupError catches every KeyError and IndexError beneath it.)

Now watch the second half of the story: the travel. Picture the call stack from chapter 9 — main() called show_song(), which reached for d["genre"]. The instant that lookup fails, a KeyError is born in the deepest frame and starts climbing. At each frame it asks the same question — is anyone here ready to catch me? Finding no one, it abandons that frame and rises to the caller. Step it through:

Step the exception up the call stackinteractive
call stack · deepest at the bottom <module> main() no except main() return show_song(song) no except show_song() return d["genre"] no except traceback printed · exit 1 KeyError('genre') an object, climbing born — KeyError('genre') constructed in the deepest frame
step 0 / 3
Slide from 0 to 3. Each step, the object asks one frame for a handler, hears nothing, and rises. At step 3 it runs out of frames — uncaught — and only then does the program die. The dying is the last resort, never the exception's first move.
Fig 02 — Raising is a return that refuses to stop: the value unwinds one frame after another until a handler grabs it or the stack is empty. This whole climb is what a traceback records, oldest call first.
The traceback is the flight recorder
When the climb ends with no catcher, Python does not just say "it broke." It prints every frame the object passed through, in the order it was called — oldest at the top, the crash site at the bottom. It is a black box replaying the exact route the exception flew.
playlist.py · consoletraceback
Traceback (most recent call last):
  File "playlist.py", line 9, in <module>
    main()
  File "playlist.py", line 7, in main
    return show_song(song)
  File "playlist.py", line 4, in show_song
    return d["genre"]
KeyError: 'genre'   ← the class, then its message. read me first.
Read a traceback bottom-up
The last line names the exception's class and its message — start there, it tells you what went wrong. The frame just above it is where it blew up. The frames higher still are the call chain that led in, oldest first. Skimming from the top is how beginners drown; jumping to the bottom is how the fluent read.
Wait — if it is just a quiet object, why does the program die? Because dying is Python's default reaction to an exception that reaches the top with no handler — not something the exception itself does. The object never crashes anything; it only rises. The crash is what the interpreter falls back to when the object runs out of frames to offer itself to. Give it a handler anywhere along the climb and there is no crash at all — same object, entirely different ending.
↺ reframe
Think of raise as a return that refuses to stop at one level. A normal return hands a value back to the single caller directly above and halts. A raise hands a value upward too — but it keeps going, frame after frame, until something explicitly agrees to receive it. Both are just values moving up the stack. One is invited; the other barges through every door until one is locked open for it.

✗ The myth

An exception is the program crashing — a fatal blow, the moment everything falls apart, something you did wrong made visible.

✓ The reality

An exception is a value in motion. The crash is only what happens if it travels the whole stack and finds no one to catch it. Catch it early and it is not a disaster — it is a message you chose to read. The next section is entirely about being that catcher.
The deeper cut — why the family tree stops at BaseException, not Exception

Look again at Fig 01: KeyError climbs to LookupError, then Exception, then one rung higher to BaseException. That top rung is not padding — it is a deliberate fence. A handful of exceptions sit beside Exception rather than under it. KeyboardInterrupt (you pressed Ctrl-C) and SystemExit (the program asked to quit) inherit straight from BaseException, skipping Exception entirely. Python verifies it plainly:

fence.pypython
print(issubclass(KeyError, Exception))            # True
print(issubclass(KeyboardInterrupt, Exception))  # False
print(issubclass(KeyboardInterrupt, BaseException))  # True

The reason is exact. When you eventually write a catch-all, you will catch Exception. Because of that split, it quietly does not swallow your Ctrl-C or a requested shutdown — those two need to punch through even the greediest handler and stop the program. So the family tree encodes a promise: ordinary failures are catchable, but the user's demand to quit is not something a stray except should ever eat. Keep this in your pocket for the next section.

So an exception is a value looking for a catcher — and the whole time, we have watched no one volunteer. What does volunteering look like? Time to string a net under the risky line: try / except. →

Wait — the error is heavier than the thing it complains about. The song's length, the integer 200, weighs 28 bytes on CPython 3.12; the KeyError raised about a missing field weighs 88 — over three times the value it is about. An empty Exception() is 88 too. A scream has no size; this has mass, an address on the heap, and a receipt (sys.getsizeof) that prints it.
Wait — one built-in exception prints its message in quotes, and only one. str(ValueError("genre")) is genre, bare — but str(KeyError("genre")) is 'genre', with the quotes. KeyError is the single built-in that overrides its own __str__ to show the repr() of the key, so that a missing empty-string key still prints as a visible '' instead of a blank line. A tiny courtesy, welded inside the object.
SEVENTY NAMES, ONE BLOODLINE
Python's builtins defines 70 exception types — ValueError, KeyError, OSError, all of them — and every single one descends from a single root, BaseException, whose only parent is plain object. An error is not a separate kind of thing in Python; it is an object on the very same tree as your dict and your string, just a few rungs below object itself.
Wait — the traceback is not text the interpreter writes at the end — it is a linked list the exception builds as it climbs. Each frame the object passes through hangs off the last by a __traceback__ / tb_next pointer, so a failure three calls deep leaves a chain of four frame-links, threaded oldest-first. What the console prints is just that chain read aloud — the actual flight recorder, still attached to the object you catch.
Trace3 step the machine — idea · code · memory move together
An exception is an object — built, then handed off
raise ValueError("bad") is two acts welded together. First it constructs a ValueError object — a normal heap value with a type, an args tuple, and an empty __traceback__. Only then does raise launch that finished object up the stack. Watch each field get filled, then the object travel.
type ValueErrorargs ('bad',)not a scream
the exception object — built field by fieldheap objectread
not built — just source text
ValueErrorheap 0x—
type
args
__traceback__
str(e) →
In plain words
Under the hood
READALU · the operation
Program · wild.py
variables · type
Memory · the heap & the stack
registers — the object's running state
e ValueError · one instance, on the heap
what's happening
beat 1 / 1

02try / except: a net under the risky line

Some lines are safe: 2 + 2 cannot fail. Other lines are a leap over a gap — song["genre"] when there is no genre key, int("Titanium") when the text isn't a number, a file read when the disk is gone. Left alone, a leap that misses ends the program: Python raises an exception, prints a traceback, and stops. try / except is how you string a net under the risky line. try: means attempt this block, I know it might fail. except SomeError: is the net. If that kind of exception is raised anywhere inside the try block, control jumps straight into the except block — the rest of the try is abandoned, mid-flight — and the program keeps running. If nothing fails, the net is never touched, and the except block is skipped entirely.

Here is the whole idea on one small fact from our playlist. The song lives in a dict, and we reach for a field that was never stored.

catch.pypython
song = {"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200}

try:
    dur = song["genre"]          # no 'genre' key -> raises KeyError
    print("never reached")       # skipped -- control already jumped
except KeyError as e:
    print("missing field:", e)   # missing field: 'genre'

print("still playing")           # the program survived the fall

Read the jump carefully, because it's the part that surprises people. song["genre"] raises, and print("never reached") never runs — control has already left the block. An exception is not a return value you inspect on the next line. It's a teleport out of wherever you are, in the middle of an expression. as e catches the thrown object and binds it to a name, so inside the net you can read what went wrong: here e prints as 'genre', the key that was missing. Then life resumes below the block, at still playing.

THE ONE IDEA TO CARRY FORWARD
try/except does not prevent the failure — the risky line still breaks. It decides what happens next: instead of the program dying with a traceback, the fall is caught and the program walks on. You are not fixing the leap; you are deciding to survive a missed one.
One try, one except — the four moving partsfigure
anatomy of a catch — four parts, each doing one job try: dur = song["genre"] play(dur) except KeyError as e: print("missing:", e) the try body — the code that might raise; guarded start to finish except KeyError — the net, armed for one error type only as e — binds the caught error object, so you can read its message
Fig 1 — The try body is the guarded stretch; except KeyError is a net tuned to catch exactly one kind of fall; as e hands you the error itself. Change any one part and you change what survives.

Now the sharp edge that the diagram hides: the net catches by type, and only the type you named. except KeyError is not a catch-all for "anything went wrong" — it's a net woven for KeyError and nothing else. If a different exception is raised inside the try, this net doesn't even see it. The exception sails straight past and keeps propagating, and if nothing above catches it, the program still dies. Drag the control below to fire three different lines through the same except KeyError and watch which ones the net holds — and which one falls right through it.

Fire a line through the net — success, caught, or escapedinteractive
try: x = song["genre"] 💥 print(x) except KeyError as e: print("caught:", e) print("still playing") no error — skips the net jump → no matching except WHAT HAPPENS KeyError: 'genre' KeyError matches the net caught · survives
#1
Three lines, one unchanged except KeyError. Slide from a key that exists (straight through), to a missing key (caught), to a value that won't parse as a number — a ValueError, which this net was never woven to hold.
Fig 2 — The net is type-specific. No error skips it; a KeyError is caught and the program lives; a ValueError slips past except KeyError untouched and the program stops. The handler you didn't write is the failure you don't survive.
Wait — what if two different things can go wrong on the same line — a missing key and a bad parse? You have three honest choices, and no magic: name them together in one net, except (KeyError, ValueError) as e:; stack two separate nets, an except KeyError: above an except ValueError: (Python tries them top-to-bottom and stops at the first that fits); or catch a common ancestor. That last one is the trapdoor the next callout is about.
↺ reframe
Stop reading a try block top-to-bottom as if every line is guaranteed to run. Read it as a single sentence: "attempt all of this — the first thing that breaks ends the attempt." The except isn't line 5 that runs after line 4 finishes; it's a parachute that only opens if you fall, and stays folded if you don't.

✗ The myth

try/except is just an if-check for errors — you run the line, then afterwards you look to see whether it worked.

✓ The reality

The net is armed before the block runs, and the check is the interpreter's, not yours. The instant any line raises a matching error, control teleports out mid-expression — you never get to the "afterwards." That's why the line after a failing statement is skipped, not merely ignored.

DON'T CATCH EVERYTHING
It's tempting to write a bare except: (or except BaseException:) so nothing can crash you. Don't. That net also swallows Ctrl-C (KeyboardInterrupt) and the interpreter's own shutdown signal (SystemExit), so your program becomes hard to stop and quietly hides bugs you never meant to hide. Catch the narrowest type that names the failure you actually expect. except Exception: is the widest you should normally reach for — and it deliberately does not catch Ctrl-C.
The deeper cut — what "matching" really means, and the name that vanishes

A net doesn't just catch its exact type — it catches that type and every subclass of it. Every exception sits in a family tree, and KeyError's ancestry is KeyError → LookupError → Exception → BaseException. So except LookupError: catches your KeyError (and also IndexError, its sibling); except Exception: catches almost anything you'd call a bug; and except BaseException: catches literally everything, Ctrl-C included. Here's the rule that makes the whole system predictable: catch as low in the tree as you can. The lower you catch, the more precisely you've said which failure you were prepared for, and the fewer surprises you accidentally silence.

One genuinely startling detail about as e: that name is deleted the moment the except block ends. Reference e on the line below and you get a NameError: name 'e' is not defined — the binding is gone, not merely out of scope. Python does this on purpose. A caught exception secretly holds its own traceback, which holds the stack frame it was raised in, which holds e — a reference loop that would pin all that memory alive. Clearing the name at block-end snaps the loop. If you truly need the error afterwards, copy it into another variable inside the block (err = e) and read err later. Keep in mind that this is a labelled simplification: the tidy story is "e holds the error," but the fuller story is "e holds it, only until the net closes."

The net caught the fall — but two quiet questions still hang there. What should run only when nothing broke, kept out of the guarded block on purpose? And what must run no matter what — the cleanup that fires whether you sailed through, got caught, or fled the scene entirely? Python has two more blocks for exactly this, and almost everyone forgets they exist: else and finally. →

Wait — does wrapping a line in try slow it down? On CPython 3.11 and up, not by one instruction. Disassemble the guarded and unguarded versions and the happy path runs the identical bytecode — the try adds no setup op at all, only a jump table the interpreter consults solely if something actually raises. The net is strung for free; you pay the price only in the instant you fall.
THE HANDLER IS NOT INSIDE ITS OWN NET
An exception raised inside an except block is not caught by the sibling except clauses beside it — it escapes the whole try. We checked: a handler that catches a KeyError and then trips an int("boom") did not get scooped up by the except ValueError written right below it; the new error flew straight out to the outer try. The nets guard the try body, never each other.
ORDER IS LOAD-BEARING
Python reads except clauses top-to-bottom and stops at the first match — so a broad net placed above a narrow one makes the narrow one dead code. Put except Exception: before except KeyError: and the KeyError branch can never run; Python won't warn you, it just quietly never reaches it. Always stack the specific handlers first, the general ones last.
Trace3 step the machine — idea · code · memory move together
The jump to except — how a raised line bends control flow
A try block runs top to bottom — until a line raises. That line aborts on the spot, the rest of the try is abandoned, and control jumps to the matching except. Handle it, and normal flow resumes below. (Name the wrong type and there is no jump — the exception skips this except and keeps climbing the stack.) Press Next and watch control move.
1 try1 exceptjump on raise
control flow · the jump on errorenter
jump resume try: dur = song["genre"] print("never reached") except KeyError as e: print("missing:", e) print("still playing") 💥 ↑ abandoned — skipped
entering try — two ways out
normal pathjump on raiseabandoned
In plain words
Under the hood
ENTERcontrol · the operation
Program · catch.py
variables · type
Memory · control & the thrown object
registers — the running state
song dict · the keys that exist
"title""seconds""genre"
exception the thrown object
what's happening
beat 1 / 1

03else and finally: the two forgotten blocks

The try/except you just met is only half of a bigger machine. The full statement has four blocks, and each one answers a different question about how your risky code turned out. try: the part that might blow up. except: what to do if it does. else: the follow-on work that should run only when the risky line survived — deliberately kept out of try, so that if it fails your except can't mistake a real bug for the failure you were catching. finally: what to do no matter what — success, failure, an error nobody caught, even an early return sneaking out the side door. Most code never uses the last two, and it is quietly worse for it.

Why does the "always" block matter? Because some jobs must happen on every way out. Close the file you opened. Flush the memory-map back to disk. Hand the network buffer back. finally is the slot for that deterministic teardown. The cleanup fires the instant control leaves the block, instead of drifting until the garbage collector eventually notices (chapter 6). It's the same guarantee with automates for you (chapter 11); here you're watching the gears turn by hand.

THE ONE IDEA TO CARRY FORWARD
Four blocks, four rules, and they never argue: try runs the risk, except runs on failure, else runs on success, finally runs always. The only unconditional block is finally — it runs on every exit, full stop. The rest are conditional: when try either succeeds or raises an exception a matching except catches, exactly one of except/else runs. But on an uncaught exception, or an early return/break out of try, neither runs — only finally fires, and the exception (or the exit) keeps going. "Exactly one of except/else" is the two-route shortcut; "finally always" is the law.
When each block runs — the whole truth tablefigure
the same code, four different outcomes — who runs? outcome try except else finally no error · error, caught · error, not caught · · return inside try · · row 3: after finally, the error keeps travelling upward · row 4: the return waits for finally, then leaves
Fig 1 — The finally column is all checks — that's the entire point of it. Notice except and else never both fire in one row: they split the two ways try can end.

Here are the four blocks doing real work — turning a song's printed length into minutes, and refusing to crash on a typo. It's our playlist again. The text "200" is the running time of "Blinding Lights", and int("200") is the line that might trip.

parse_length.pypython
# turn a printed length into minutes — and never crash on bad text
raw = "200"                       # "Blinding Lights", read from a file
try:
    seconds = int(raw)             # the risky line — text could be "20O"
except ValueError:
    print("not a number")         # runs ONLY on failure
else:
    print(seconds // 60, "min")     # runs ONLY on success  ->  3 min
finally:
    print("done — either way")       # runs ALWAYS, on every exit path

The natural question: why bother with else — why not just drop that seconds // 60 line at the bottom of the try? Because of what try means. Code inside try is code whose failures you are volunteering to catch. If the division lived there and it somehow raised, your except ValueError net would be sitting right underneath, ready to mislabel a genuine bug as "not a number." else says something sharper: this part depends on success, but its failures are a different story — don't let my error-handler swallow them. Move the slider below and watch control re-route through the four blocks.

Flip the outcome — watch control flow re-routeinteractive
control flow through the four blocks try: seconds = int(raw) ran except ValueError: skip else: # succeeded ran finally: # cleanup always try -> else -> finally (prints 3 min)
success · no exception
Three outcomes: success (int("200")), a caught error (int("20O") raises ValueError), and an uncaught one (a KeyError your except ValueError never matches). Watch finally fire on all three — even when the exception is on its way out of the whole statement.
Fig 2 — except and else take turns; finally is on every route, including the one where the error escapes uncaught and keeps climbing.
↺ reframe
Stop reading try as "the code that might fail." Read it as the smallest code whose failure you are willing to catch. Everything that merely depends on that code succeeding belongs in else, out from under the net. A three-line try with a two-line else is almost always more honest than a five-line try — it draws a precise circle around the one thing you expect to go wrong.
Wait — if finally "always" runs, what happens when I return from inside try? Does the function leave, or does finally still get its turn? Both — in that order. Python computes the return value, pauses on the doorstep, runs finally to completion, and only then hands the value back to your caller. The early return doesn't skip cleanup; it waits for it.

✗ The myth

finally is just a tidy place to park cleanup — the same as writing those lines right after the try block. Cosmetic indentation, nothing more.

✓ The reality

finally runs on paths a plain trailing line can't even see: an uncaught exception mid-flight to the caller, an early return, a break leaving a loop. A line after the try is simply skipped on all of those. Only finally promises "before we leave, by any exit."

THE finally-CLOSE TRAP
finally: f.close() looks bulletproof — until open() itself fails. Then the name f was never bound, and your cleanup line raises a brand-new NameError that buries the real error underneath it. We confirmed exactly this: the FileNotFoundError vanishes and you get name 'f' is not defined. The fix is with open(...) as f: (chapter 11), which only owns the cleanup of a file that actually opened.
The deeper cut — the two ways "finally always runs" quietly bites back

"finally always runs" is a labelled simplification, and it's true — but there are two sharp edges hidden in that word always. This is the place to file them down.

Edge one: a return inside finally wins, and swallows exceptions whole. Because finally is the last thing to run before control leaves, a return (or break, or continue) placed there overrides whatever try was about to do — including an exception it was about to raise. We checked it: the function below is on its way to hand back 200, but finally returns -1, so every caller gets -1. Worse, put that same return under a try that raises and the exception simply disappears — no traceback, no error, nothing. Never return from a finally.

finally_return.pypython
def length_of(raw):
    try:
        return int(raw)      # about to hand back 200...
    finally:
        return -1            # ...but this WINS — caller always gets -1

Edge two: an error raised in else is not caught by the same statement's except. That's the whole reason else exists. It runs after the guarded region, so its failures sail straight past the net you wrote for try. In our test, a RuntimeError raised in else skipped right over the except ValueError beside it. That's exactly the precision you wanted: the net catches what you aimed it at, and nothing you didn't. (And a grammar note — else can't stand alone: a try with an else and no except is a SyntaxError, "expected 'except' or 'finally' block." finally, by contrast, is happy on its own in a bare try/finally.)

The net now catches, cleans up after itself, and stays out of the way. But a net woven wide enough to catch everything also snags the bugs you needed to watch fall through — catch the error you expect, not all of them →

Wait — finally fires even while the program is quitting. sys.exit() isn't a magic stop button — it raises SystemExit, which travels up the stack exactly like any error, running every finally on the way out. We watched a finally print its cleanup line as a SystemExit(3) flew past it and out. Shutting down is just one more exception in flight — and cleanup still gets its turn.
THE ONE ESCAPE FROM "ALWAYS"
"finally always runs" has exactly one built-in loophole: os._exit(). It terminates the process immediately — no unwinding, no handlers, no cleanup. We ran it in a subprocess: the try printed, and the finally never did. (A hard crash or a pulled plug does the same.) Every ordinary exit path — return, break, an uncaught exception, even sys.exit() — still honours finally; only the emergency-eject skips it.
↺ you already write finally
Every with block is finally wearing a bow. with open(...) as f: guarantees the file is closed on every way out of the block — a normal end, an early return, or an exception tearing through — which is precisely the promise finally makes, written once and named for the resource it guards. You have been leaning on this exact machinery since the first file you ever opened; this section just lifts the lid and shows the gears turning by hand.
Trace3 step the machine — idea · code · memory move together
Which blocks run — success takes one route, failure the other
One try/except/else/finally, two runs. On success: try → else → finally. On failure: try → except → finally. On these two routes exactly one of except/else fires — but the only block that always runs is finally (on an uncaught error it fires alone, then the error keeps climbing). Press Next and watch the four blocks light up.
4 blocks2 routesfinally always
the four blocks — who runs this route?setup
input "200"success or failure?
tryseconds = int(raw)
exceptValueError → catch
elseprint(seconds // 60)
finallyprint("done")
stdout
In plain words
Under the hood
READALU · the decision
Program · parse_length.py
Memory · the control path
registers — this run
route taken
what's happening
beat 1 / 1

04Catch the error you expect — not all of them

You wrap a risky line in try/except to survive bad input — sensible. But watch what a lazy net does. An unqualified except: (or an except Exception: reached for out of habit) catches every failure the block can raise — not just the malformed track you were bracing for, but the variable name you misspelled two lines up. Type trck when you meant track and Python raises a NameError. The same wide net scoops it up, prints your polite "bad track", and sails on. The input was perfect. Your code is broken. And nothing on the screen says so. That is the most expensive bug there is — the invisible one: a program that works and lies at the same time.

tracks.pypython
track = ("Blinding Lights", "The Weeknd", 200)

try:
    title, artist, secs = trck   # typo — you meant `track`
except:                          # bare net — swallows the NameError too
    print("bad track")           # a lie: the track was fine

# right: name the failure you expect — let bugs crash where you can see them
try:
    title, artist = track        # 3 values, 2 names: ValueError
except ValueError as e:          # the one failure you planned for
    print("malformed track:", e)
THE ONE IDEA TO CARRY FORWARD
A try/except is a net with a labelled hole. It should catch exactly the failure you named and let everything else — especially your own bugs — fall straight through and crash. The narrower the type, the more honest the code.

Two failures that deserve the same response? Hand except a tuple — except (ZeroDivisionError, ValueError) as e: — one block, both caught. Want different responses? Stack separate except blocks. Python reads them top to bottom and runs the first whose type matches, then stops looking. So the game is entirely about how wide you draw each net — and there's a real cost to drawing it too wide.

A specific net lets bugs through; a bare wall hides themfigure
except ValueError: Value caught — handled Name passes through — crashes loudly (good) bare except: — a solid wall Value Name Ctrl-C every one absorbed — your typo is now invisible
Fig 04a — A specific except handles the failure you planned for and lets genuine bugs crash where you can see them. A bare except catches all of them alike — including the ones you never meant to touch.
Widen the net and watch what you start swallowinginteractive
except ValueError: held by the net the net slips past → ValueError bad input Value handled ✓ IndexError past the end Index unhandled — add it NameError your typo · a bug Name crashes loudly ✓ KeyboardInterrupt you press Ctrl-C Ctrl-C you can quit ✓ Tight net — only the failure you named is handled. Every bug and your Ctrl-C stay visible.
0 · except ValueError
Drag from a tight net to the bare wall. Notice the exact step where your typo's NameError stops crashing and goes quiet (except Exception), and the step where even Ctrl-C gets trapped (except:). Wider is not safer — wider is blinder.
Fig 04b — Every exception is a member of a family tree, and an except catches the class you name and everything beneath it. Name a narrow class and only the expected failure is held; name a broad one and you start swallowing your own mistakes.
↺ a crash is a gift
Most people read a crash as proof they failed. Flip it: a crash is an exact address for the bug, handed to you free — file, line, and the name of what went wrong. Robust code isn't code that never crashes; it's code that crashes loudly on bugs and recovers gracefully only from the failures it planned for. Silencing everything with a bare except doesn't make software robust — it makes it unfixable.

✗ The myth

"Robust code is code that never crashes — so wrap the whole thing in try/except and swallow whatever comes out."

✓ The reality

Robustness is selective. Handle the failures you can name and let the rest fly. A catch-all doesn't add safety; it deletes your ability to ever locate the bug — you traded a five-second traceback for a week of "it just does nothing sometimes."

But being specific means knowing the names. Each built-in exception marks one exact category of impossibility. Once you can read the map, the last line of a traceback stops being noise and becomes an address that tells you precisely what to fix:

names.pypython
playlist = [("Blinding Lights", "The Weeknd", 200),
            ("Titanium", "David Guetta ft. Sia", 245)]
song = {"title": "Blinding Lights", "seconds": 200}

int("Titanium")          # ValueError        right type, impossible value
playlist[5]              # IndexError        position doesn't exist
song["genre"]            # KeyError          key isn't in the dict
"Blinding Lights" + 200  # TypeError         these types don't combine
200 / 0                  # ZeroDivisionError no such quotient exists
song.play()              # AttributeError    a dict has no .play
open("missing.m3u")      # FileNotFoundError the file isn't there
title, artist = playlist[0]  # ValueError    a 3-tuple into 2 names

Two of these get confused constantly. ValueError means the type was fine but the content was impossible — int("Titanium") hands int() a string exactly like int("200") does, but no integer lives inside the letters. TypeError means the operation makes no sense for these types at all — "Blinding Lights" + 200 can't even be attempted, because str and int have no shared notion of +. Type is the shape; value is what's inside the shape. One is the wrong box entirely; the other is the right box with the wrong thing in it.

Every beginner mistake has a namefigure
the mistake the exception int("Titanium") ValueError playlist[5] IndexError song["genre"] KeyError "Blinding Lights" + 200 TypeError 200 / 0 ZeroDivisionError song.play() AttributeError
Fig 04c — Match the last line of a traceback to this map and you know what to fix. The type is the diagnosis — that's the whole reason to catch it by name.
The unpacking trick
user, domain = email.split("@") is free validation. A string without exactly one @ splits into the wrong number of pieces, so the unpack raises ValueError all by itself — one try/except and the email's shape is checked with no if in sight. We ran it live: "weeknd@example.com" unpacks cleanly, "no-at-sign" gives not enough values to unpack, and "a@b@c" gives too many values to unpack.
Wait — if except Exception is already so broad, why is a bare except: even worse? Because Exception is only the branch that holds ordinary errors. A bare except catches things that aren't errors at all — KeyboardInterrupt (you hitting Ctrl-C) and SystemExit (the program trying to shut down) sit above Exception in the family tree, so except Exception politely lets them through while a bare net traps them. That's why a bare except in a loop can leave a program you literally cannot quit.
The deeper cut — "an except catches everything" is a labelled simplification

When we said a net "catches everything beneath the class you name," that was a labelled simplification. Here's the exact truth. Exceptions form a family tree. At the root sits BaseException. Just below it is Exception, the trunk that holds every everyday error — ValueError, TypeError, NameError, and the rest. But three siblings hang off BaseException outside that trunk: KeyboardInterrupt, SystemExit, and GeneratorExit. On this machine, issubclass(KeyboardInterrupt, Exception) is literally False.

An except catches the class you name and every descendant of it — no more, no less. So except LookupError catches both IndexError and KeyError, because both are children of LookupError; except ArithmeticError sweeps up ZeroDivisionError. except Exception reaches the whole everyday trunk but stops at those three siblings. A bare except: is secretly except BaseException: — the entire tree, Ctrl-C and all. That is the precise difference between "wide" and "reckless," and it's why you climb the tree only as far as the failures you can actually name.

You can catch the error you expect. But sometimes the impossible thing is your data, not your code — and Python won't complain until much later. Can you throw the error yourself, on purpose, the instant the rule is broken? →

Wait — except ValueError can catch a text-decoding failure you never mentioned. Read a track's title tag straight off disk and raw.decode("utf-8") turns the file's bytes back into text — but a corrupt byte in the middle is a valid bit-pattern that spells no valid character, so the decode raises UnicodeDecodeError, and that is a subclass of ValueError (as is every UnicodeError). The net you strung for a bad int() silently holds those mangled bytes too. It is chapter 0's thread come back around — the byte is a real pattern, only its reading as a character is impossible — and catching a class always catches its children, sometimes children you forgot were down there. Name the narrowest type that fits, or inherit surprises.
ONE NET FOR EVERY FILE FAILURE
except OSError: is the single clause that holds nearly everything a filesystem can throw at you. FileNotFoundError, PermissionError, IsADirectoryError, even a broken network pipe (BrokenPipeError) are all children of OSError — verified, all four report True. So you don't enumerate a dozen file mishaps; you catch their shared parent, exactly as high as the failures you can actually recover from.
SIX RUNGS DEEP
The family tree runs deeper than most people picture. The deepest built-in chain is six classes tall: BrokenPipeError → ConnectionError → OSError → Exception → BaseException → object. Catch at any rung and you sweep up everything beneath it — that's the power, and the danger, in one clause. The higher you reach, the more failures you hold; the lower you reach, the more precisely you've named the one you were actually ready for.
Trace3 step the machine — idea · code · memory move together
A net catches a class — and every child beneath it
Fire three exceptions at three widening nets. except ValueError holds one class; except Exception holds the whole everyday trunk yet still lets Ctrl‑C escape on purpose; a bare except is secretly except BaseException — it swallows the Ctrl‑C you never meant to touch. Watch where each raise lands.
3 raises3 nets1 Ctrl-C trap
The exception family tree — the net is what your handler swallowsno net yettree
BaseException the root of all Exception everyday errors KeyboardInterrupt Ctrl-C · outside Exception ValueError bad input IndexError past the end
raise ValueError
raise IndexError
press Ctrl-C
In plain words
Under the hood
MATCHALU · the subclass test
Program · three_nets.py
the tree, in colour
outside the net
caught by the net
trapped Ctrl-C
the rule
an except catches the class you name plus every subclass under it — no more, no less.
Memory · what the net holds
registers — the running state
the caught set every class your handler will swallow
what's happening
beat 1 / 1

05raise: signalling your own errors

Here is a value your machine will accept without a flinch: a song that lasts minus fifty seconds. Write {"Titanium": -50} and Python stores it as calmly as it stores 200. A negative integer is a perfectly ordinary integer, and Python has no opinion about what seconds mean. The impossibility isn't in the language. It's in your world, the one where a track has a real running time and a playlist is supposed to make sense. So far you've only caught failures the interpreter handed you — a bad int(), a missing key. This section flips the arrow. raise lets you throw the exception, at the exact instant nonsense tries to walk in the door, instead of letting it settle quietly into your data and detonate ten functions later when something finally divides by it.

THE ONE IDEA TO CARRY FORWARD
An exception isn't something that only happens to you. It's a signal you can send. raise builds an exception object and launches it up the call stack on the very same flight path as any built-in error — so the whole try / except machinery you already know works on failures you invent, exactly as it works on Python's own.
Two kinds of impossible — some failures the machine itself refuses. Ask the ALU for total // count when count is 0 and there is no bit-pattern it can hand back, so Python raises ZeroDivisionError before the next line runs — that one you didn't invent; it's a row of switches with nothing to divide into. A running time of -50 is the other kind: the bits are perfectly valid, only the meaning is broken, and nothing but your code knows it. raise lets you hand your own world the same flat refusal the hardware gives its own.

Watch it fire. First, why invent a type at all — why not just raise ValueError("bad length")? Because a caller's except ValueError would then scoop up our complaint and every unrelated int() mishap in the same breath, unable to tell the trouble we raised from Python's. So we give ourselves a private failure type in one line — subclass Exception, done. It's a class only our code ever raises, so callers can catch exactly our trouble and swallow no one else's. Then we guard the door:

playlist.pypython
class PlaylistError(Exception):
    """Something is wrong with this playlist."""

def add_song(shelf, title, seconds):
    if seconds <= 0:
        raise ValueError(f"impossible length: {seconds}s")
    if any(t == title for t, s in shelf):   # any(): did at least one row match?
        raise PlaylistError(f"{title!r} is already on the shelf")
    shelf.append((title, seconds))

shelf = []
add_song(shelf, "Blinding Lights", 200)   # fine — lands on the shelf
add_song(shelf, "Titanium", -50)          # stops here → ValueError: impossible length: -50s

raise ValueError(...) does two things in one breath. It constructs the exception object — passing your message straight to the constructor — and it throws it, abandoning the rest of add_song mid-stride, unwinding frame by frame until some except catches it or the program dies with a traceback. The message you passed isn't stored by hand anywhere; it rides along inside the object. Catch it and str(e) hands it back, while e.args is the raw tuple you constructed it with:

catching.pypython
try:
    add_song(shelf, "Titanium", -50)
except ValueError as e:
    print(str(e))     # impossible length: -50s
    print(e.args)     # ('impossible length: -50s',)

And because PlaylistError is your own class, a caller can write except PlaylistError and net precisely the "this playlist is broken" case — never accidentally catching a ValueError that meant something else entirely. One class line bought you a whole named category of failure. But to catch classes well, you have to see the family they belong to.

The exception family tree — and why except a parent catches its childrenfigure
Exception — the branch you catch & subclass outside Exception — a bare except: can swallow these by mistake BaseException Exception SystemExit KeyboardInterrupt ValueError LookupError PlaylistError IndexError KeyError
Fig 08 — Every exception is a node on one tree rooted at BaseException. Catching a class catches all of its descendants, so except LookupError nets both IndexError and KeyError, and except Exception nets your PlaylistError too. The two red boxes live outside the Exception branch on purpose — that's what lets Ctrl-C still kill a program even inside a broad handler.
↺ reframe
A function normally answers a question by returning a value. raise is the other exit: instead of returning an answer, the function throws a labelled object that says "I refuse, and here's why." An exception isn't an error message printed to the void — it's a value with a type, travelling upward, looking for someone willing to catch it. Deciding which type to throw is you choosing how loudly, and to whom, your code says no.

The point of raising at the door is that the door is where the truth is still local. Drag the slider below and feed add_song a length. Watch the border check either wave the value through onto the shelf, or reject it on the spot — and notice what the shelf looks like afterwards.

Feed the border a length — watch it pass or raiseinteractive
-50 s candidate length add_song() the border check if seconds <= 0: raise ValueError impossible length: -50s shelf untouched — never sees it seconds = -50 ≤ 0 → raise. the shelf stays clean. fail at the border, not three functions later when something computes -50 // 60
-50
Slide up through 0 and the border flips from reject to accept. Park it on 200 ("Blinding Lights") or 245 ("Titanium") — both sail onto the shelf. Anything <= 0 is turned away at the door, and the shelf never learns it existed.
Fig 08b — The value only reaches the shelf when it survives the border. A raise is a door slamming: the function stops, the bad value is refused, and every structure downstream stays honest because it was never handed nonsense in the first place.
Two habits worth locking in
First: raise the most specific type that fits. A bad value of the right type is a ValueError; the wrong type entirely is a TypeError; something uniquely yours is a custom subclass. Precision here is what lets a caller catch exactly the failure they can handle and let the rest fly. Second: put the facts in the message. raise ValueError(f"impossible length: {seconds}s") is a future debugging session that solves itself; raise ValueError("bad") is one that doesn't.

✗ The myth

"raise means my program crashes." So people avoid it, and let bad data slide instead.

✓ The reality

Raising only crashes if nobody catches it — exactly like a built-in error. It's a signal, not a death sentence. A well-placed raise lower down and a matching except higher up is how robust programs refuse bad input and keep running.

Wait — should I check for trouble before the risky line the way add_song does, or just attempt the operation and catch the fallout if it blows up? Both are real strategies with real names, and Python has a strong opinion about which to reach for first. That's the next section.
The deeper cut — the whole tree, bare re-raise, and chaining causes

Everything sits on one inheritance tree rooted at BaseException. Exception is the branch you catch and subclass. Deliberately outside it live KeyboardInterrupt (your Ctrl-C) and SystemExit (raised by sys.exit()). That placement is a feature: except Exception — already too broad for most code — still won't eat a Ctrl-C, so a runaway loop stays killable. A truly bare except: catches even those, which is why it's a genuine footgun. And because catching a class catches its children, except LookupError nets both IndexError and KeyError in one clause — they're siblings under LookupError.

A subtle rule: whatever you throw must be an exception. raise ValueError (a class) and raise ValueError("msg") (an instance) both work — Python instantiates the bare class for you — but raise "just a string" fails with TypeError: exceptions must derive from BaseException. And a bare raise with nothing active anywhere raises RuntimeError: No active exception to reraise.

That bare raise is the second trick. Inside an except block, raise with no argument re-throws the exception you're currently handling — with its original traceback intact. It's the idiom for "note this, maybe log it, then let it keep flying," without lying about where it started:

reraise.pypython
try:
    add_song(shelf, title, seconds)
except PlaylistError:
    log.warning("rejected a bad row")
    raise          # re-throw the same object, same traceback

The third trick is chaining. When one failure causes another, raise NewError(...) from e records the original as the cause (it lands in e.__cause__), and the traceback reads "The above exception was the direct cause of the following exception" — the honest story. Omit from while handling another exception and Python still links them implicitly (in __context__), but the banner becomes the muddier "During handling of the above exception, another exception occurred." Reach for from e whenever you translate a low-level failure into a domain one:

load.pypython
def load_length(field):
    try:
        return int(field)
    except ValueError as e:
        raise PlaylistError(f"bad length field: {field!r}") from e

One honest label: calling an exception "a thrown value" is a simplification. Underneath, the object carries more than a message — a class (for matching), an .args tuple, a live __traceback__ of the frames it fell through, and those __cause__ / __context__ links. That's why a traceback can tell a two-part story instead of just printing one line.

So which is it — inspect the value first and only then act, or just try the operation and be ready to catch it? Python has a favourite, and a proverb to match: ask forgiveness, not permission. →

Wait — you've been raising exceptions without the word raise — every assert is one in disguise. assert seconds > 0, "impossible length" is essentially if not (seconds > 0): raise AssertionError("impossible length") — same object, same flight up the stack. There's a twist: running Python with the -O flag strips every assert out of the program entirely, which is exactly why asserts are for catching your own bugs, never for validating real user input.
from None ERASES THE BORING CAUSE
When you translate a low-level failure into your own — catch a ValueError from int(), re-raise a PlaylistError — you can chain the cause with from e, or suppress it with from None. We checked: raise PlaylistError(...) from None sets __cause__ back to None and flips __suppress_context__ to True, so the traceback shows only your clean domain error, not the plumbing underneath. Chain when the cause helps; suppress when it's noise.
ONE LINE BUYS A WHOLE CATEGORY
class PlaylistError(Exception): pass is a complete, working exception type — that one line inherits .args, a working str(), and a live __traceback__ for free, all from Exception. In return, callers can write except PlaylistError: and net precisely your trouble, never accidentally swallowing a ValueError that meant something else. A named failure is the cheapest, most valuable class you'll ever write.
Wait — a freshly built exception knows nothing about where it came from. Construct KeyError("genre") and its __traceback__ is None — no frames, no flight log, just a quiet object. Only raise fills it in: the moment it flies, Python starts writing the black box. Construction and flight are genuinely two separate acts — which is exactly why the interpreter can hand you the error object, still cool and traceback-less, and why the one that crashed your program carries a full flight recorder.
Trace3 step the machine — idea · code · memory move together
One raise, three frames — watch the stack unwind to the handler
A raise deep inside add_song doesn't just stop that function. The exception is thrown and flies up the call stack, popping each frame that has no matching except, until <module>'s except ValueError catches it. Press Next and watch the frames pop away one by one.
3 framesraises ValueErrorcaught at <module>
The call stack — raise unwinds it, frame by framestack · LIFOdeep
<module>has except
try: build_playlist() · except ValueError as e
build_playlist()no handler
shelf = [] · add_song(shelf, "Titanium", -50)
add_song()the raise site
if seconds <= 0: raise ValueError(…)
ValueError impossible length: -50s not raised yet
In plain words
Under the hood
OPALU · the operation
Program · playlist.py
scope · locals
Memory · the exception in flight
registers — the unwind state
exception object the value travelling up the stack
type
args
str(e)
__traceback__None
what's happening
beat 1 / 1
Trace3 step the machine — idea · code · memory move together
The crash path — a raise that nobody catches
Same three frames, one thing removed: no try anywhere. The exception climbs past add_song, past build_playlist, past <module> — and runs out of stack still uncaught. Now Python's default handler prints the traceback (the itinerary of the climb, innermost last) and exits non-zero. Press Next.
3 frames0 handlersexit 1
The stack empties — then the default handler prints & exitsstack · LIFOdeep
<module>no try
build_playlist() # the top frame
build_playlist()no try
add_song(shelf, "Titanium", -50)
add_song()the raise site
if seconds <= 0: raise ValueError(…)
ValueError impossible length: -50s not raised yet
Traceback (most recent call last):
File "playlist.py", line 10, in <module>build_playlist()outermost · printed first
File "playlist.py", line 8, in build_playlistadd_song(shelf, "Titanium", -50)
File "playlist.py", line 3, in add_songraise ValueError(…)innermost · printed last
ValueError: impossible length: -50s
process exited · status code 1 — line 11 print("done") never ran
In plain words
Under the hood
OPALU · the operation
Program · playlist.py
scope · locals
Memory · the exception in flight
registers — the unwind state
exception object its __traceback__ grows as it climbs
type
args
str(e)
__traceback__None
what's happening
beat 1 / 1

06Ask forgiveness, not permission

There are two ways to touch a thing that might not be there. The first is to look before you leap — test every precondition, then act: if "genre" in song: before you ever reach for song["genre"]. Programmers call it LBYL. The second is to just try the thing and be ready to catch the fall if it fails — EAFP, "easier to ask forgiveness than permission." Python's whole culture leans hard toward the second. That isn't a fashion; it's earned, and it rests on two facts about how the world actually behaves.

First, the happy path pays almost nothing. A try block that doesn't raise is nearly free, and you touch the key once — LBYL touches it twice, once to check and again to fetch. Second, LBYL has a crack in it. Between the moment you check and the moment you act, the world is free to change. Your code confirms playlist.txt exists. A heartbeat later — before open() runs — a sync app or the user deletes it. The check passed. The action crashes anyway. EAFP has no such crack, because it never checks separately: attempting the action is the test, one motion, with no gap for reality to slip through.

THE ONE IDEA TO CARRY FORWARD
A separate check can go stale before you act on it. EAFP deletes the gap by folding the check into the action itself — you don't ask whether it will work, you do it and handle the truth you meet. Cheaper on the common path, and honest about a world that won't hold still.
Two philosophies, and the gap between themfigure
LBYL — look, then leap "genre" in song? the gap — the world can change song["genre"] EAFP — act, then catch try: song["genre"] · no gap except KeyError: recover
Fig 06 — LBYL splits into check → gap → act, and everything that goes wrong hides in that dashed red gap. EAFP does the thing once and catches whatever reality it actually finds — there is no gap to hide in.
Wait — if the try is almost free, why isn't LBYL just as cheap? Because "check, then fetch" asks the dictionary the same question twice — is the key here? then hand me the key — while EAFP asks once and lets the failure, if any, do the talking. On the path where the key is there (the path you're on 99% of the time), the try wins outright: one lookup, no branch, nothing raised.
The cost ledger — free until it firesinteractive
40 ms EAFP cost for a 1,000,000-call loop EAFP try / except 40 ms LBYL check, then act 60 ms EAFP wins — 1.5× cheaper Errors rarer than 1-in-33 — forgiveness is basically free. This is why Python says: just try it. One lookup ≈ a glance at a card · one raise ≈ ~33× that work: unwind the stack, build a traceback.
1.0%
Slide the failure rate. On the common path the EAFP bar hugs the floor — one lookup, nothing raised. Push failures past ~3% and every miss pays for a full raise, so the bar overtakes LBYL and turns red. That 3% is the whole reason they're called exceptions.
Fig 06c — A raise costs roughly 33 ordinary lookups — order of magnitude — so the two strategies trade places at about a 1-in-33 failure rate. Keep errors as rare as the name promises and forgiveness is the cheaper motion; let them turn routine and the check you skipped was the bargain all along.

The crack in LBYL isn't hypothetical — you can watch it open. Below, a file lives on disk, an LBYL program checks for it, and in the sliver of time before it acts, another process deletes it. Step through and see the check curdle into a stale yes the instant the world moves underneath it — while the EAFP program, which never asked, simply meets the missing file and catches it.

Race the world — watch the check go staleinteractive
playlist.txt · present LBYL — look before you leap os.path.exists · the gap world may change open( ).read( ) act on the yes idle EAFP — easier to ask forgiveness try: open("playlist.txt") except FileNotFoundError: recover idle t0 — the file is here. Neither program has moved yet.
step 0 / 4
Slide right. Notice the LBYL check never re-runs — once it says yes, it keeps saying yes even after the file is gone. That frozen answer is the whole bug. EAFP has no frozen answer to be wrong.
Fig 06b — The failure lives entirely in the gap between check and act. LBYL walks into it holding a promise the world already broke; EAFP never made the promise, so it has nothing to be betrayed by.

This is why the most-loved idiom in beginner Python is pure EAFP — the input loop that no bad string can crash. You don't inspect the string for stray colons and letters. You hand it to int() and let it object if it must. The one line that reads a number becomes a fortress against every malformed string. Though, exactly as section 01 promised, not against the two escape hatches that sit outside ValueError: Ctrl-C (KeyboardInterrupt) and a closed input stream (EOFError) still punch straight through and end the program, by design:

read_length.pypython
# a length-reader no bad string can crash
while True:
    try:
        secs = int(input("Length in seconds: "))
        break                 # reached only if int() did not raise
    except ValueError:
        print("Digits only — 200, not 3:20. Try again.")

print("Added a", secs // 60, "min track.")

Type 200 and you've added "Blinding Lights." Type 3:20 and int() raises ValueError, the except catches it, and the loop swings back for another try — the program never dies, and the user is simply asked again. (A tidy detail: int(" 245 ") is 245 — it forgives the spaces for you.) There was never a clean, complete "is this a valid integer?" test to write by hand. int() already is that test, and running it is how you ask.

↺ exceptions ARE control flow
The myth says exceptions are for emergencies and using them for ordinary logic is sloppy. But you have been leaning on an exception in every loop you've ever written. A for loop ends by asking the iterator for the next item until the iterator raises StopIteration — and the loop quietly catches it. Ordinary iteration is EAFP wearing a for. Exceptions aren't the fire alarm; in Python they're part of the plumbing.
under_the_for.pypython
# playlist = ["Blinding Lights", "Titanium"]
it = iter(playlist)
next(it)      # 'Blinding Lights'
next(it)      # 'Titanium'
next(it)      # StopIteration  ← the exception every for-loop catches to stop
EAFP HAS A PRICE — KNOW WHEN IT'S DUE
"Nearly free" is only half the truth, so here's the other half. Building and raising an exception is real work; catching one you expected costs several times more than a plain if that simply fails. On this machine, over a million dictionary lookups: when the key is present, the try ran faster than check-then-fetch (one lookup, not two); when the key is always absent, the raising version ran roughly slower than the LBYL miss. So the rule is about odds: EAFP wins when failure is the rare case, which it usually is. If you expect to miss most of the time — a key that's often absent — reach for song.get("genre", default) or an if. Same result, no exception tax.
WHEN YOU RECOVER, LEAVE A TRAIL
An error you catch in production and quietly recover from is an error you'll never learn about — unless you write it down. Inside the except, log it: logging.error("bad track length: %s", e). The program keeps running for the user; the record keeps the truth for you. Recovered-and-forgotten is how a small bug survives for months.
EAFP IS NOT "SILENCE IT"
except ValueError: pass — catch the error and do literally nothing — is how corrupt data ships smiling. "Ask forgiveness" means you have a recovery: re-prompt, substitute a sane default, log it and re-raise. If you can't name what the except will actually do, you have no business catching the error. And catch narrowly — name the exception you expect. A bare except: even swallows Ctrl-C, turning a program you can't quit into the bug.

✗ The myth

"try/except is a crutch. Real programmers validate everything up front and never let an exception near normal logic."

✓ The reality

In Python, exceptions are normal logic — every for loop rides on StopIteration, and try/except ValueError around int(input()) is the textbook idiom, not a hack. Save LBYL for checks that are cheap, race-free, and genuinely clearer to read.

The deeper cut — what "no gap" really buys you, and where LBYL is still right

Careful with the word people reach for here: they often say the try is "atomic," meaning uninterruptible. That's a labelled simplification, and it's worth refining. A try block is not magically indivisible — other threads run, signals fire, the interpreter switches tasks mid-block like anywhere else. The precise thing EAFP removes is the separate, earlier check whose answer can rot before you use it — the classic time-of-check to time-of-use gap. There's no window between "the file exists" and "open the file," because there is no separate "the file exists" step at all. You just open it and deal with reality.

This also sharpens when the race even matters: only when the resource is shared — a file another process can delete, a socket, a row two threads touch. For a plain dictionary in a single-threaded script, nothing can sneak in between your if and your lookup, so there's no race to fear — and yet EAFP still wins there, on cost and clarity, for the reasons above. So keep LBYL exactly where it shines: a check that's cheap, cannot go stale, and reads more plainly than a try — like guarding a division with if divisor:. Everywhere the world is shared or the check is redundant, ask forgiveness.

Your playlist app now survives fat fingers, missing files, and a world that refuses to hold still — and with that, every tool in the course is on the bench. Next, the whole journey folds onto a single page: a cheat sheet of every value, structure and idiom you've earned, a glossary of the words that now mean something to you, and the road on. →

Wait — what makes the loop survive every bad string is that int() throws the same ValueError for every flavour of garbage text. '3:20', 'abc', '', ' ', '3.5', '1e3', '0x10' — we ran them all, and each one raised ValueError, nothing else. So a single except ValueError holds every malformed string a keyboard can type. What it pointedly does not hold — Ctrl-C (KeyboardInterrupt) and a closed input stream (EOFError) — sits outside ValueError on purpose, so both still punch through and end the program by design.
↺ the exception is the steering
Look at where break sits — after int(), inside the try. It is reached only when nothing raised. So the loop isn't steered by an if; it's steered by whether one line throws. A bad parse raises and swings back to re-prompt; a clean parse falls through to break and leaves. The exception here isn't the fire alarm — it's the turn signal, the ordinary control flow that decides which way the loop goes.
WHEN MISSES ARE THE NORM, DON'T RAISE
EAFP earns its keep because failure is usually rare. Flip that — a key that's absent most of the time — and building and unwinding an exception on every miss becomes the bottleneck. The escape hatch is built in: song.get("genre") returns None instead of raising, and song.get("genre", "unknown") hands back your own default. Same single lookup, no object constructed, no stack unwound — reach for it exactly when you expect to miss.
Trace3 step the machine — idea · code · memory move together
Ask forgiveness — the input loop that no bad string can crash
while True has no test to fail; it just runs until break. Inside, try hands the typed text to int() — and the try is the test. Bad input raises, the except catches it and the loop swings back; good input parses and reaches break. Watch two attempts — one fumble, one success, zero crashes.
2 attempts1 ValueError caughtbreak on success
The crash-proof loop — one motion, the try is the testwhile Trueenter
raises no error ATTEMPT 1 TYPED while True: try: n = int(input()) int(input()) ready except ValueError: print("try again") ↑ swing back to top n set → break leave the loop n = 200 · added
In plain words
Under the hood
LOOPALU · the operation
Program · read_length.py
variables · type
Memory · the loop state
registers — the running state
int() verdict the parse, this attempt
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 13, in working code

Every program hits a wall eventually — a bad string, an empty dict, a zero in the denominator. This chapter is about meeting failure on purpose: catching it, sorting it, cleaning up after it, and raising your own when the rules are broken.

Catch the fall
A try block runs hopefully; when a line raises, control leaps to a matching except instead of crashing.
Sort the failures
Different breakages deserve different responses. Multiple arms, a parent type that catches a whole family, and an else for the happy path.
Always clean up
finally and with both promise the same thing: some code runs no matter how the block ends — success, exception, or early return.
Raise your own alarm
Sometimes the values are wrong and no built-in complains. Then you raise — perhaps a custom type, perhaps re-raising after a note.
Leap, or look first
Two philosophies for risky operations — try it and catch (EAFP), or check the precondition up front (LBYL) — plus assert for the things that must never be false.
end of chapter 13 · six sections
iolinked.com
Written by Ajai Raj