13When it breaks
In Chapter 12 we gave data behaviour: objects that carry their own methods. In this one we meet the one object nobody builds on purpose: the exception. Python constructs it 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 hunting 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 thing that actually 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. You'll 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 can ever crash — not even 3:20 where a plain number belonged.
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. Watch what it does. 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. 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, up through every caller that was patiently waiting on a result. As it climbs it is abandoning each stack frame mid-sentence. (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.) It hunts 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.
.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.# 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 itBecause it is a genuine object, you can do object things to it. You can 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:
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* LookupErrorSo 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 the object itself weighs 88 bytes. A scream has no size. This has a size.
KeyError, dissectedfigureKeyError 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:
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.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 deliberately 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:
print(issubclass(KeyError, Exception)) # True
print(issubclass(KeyboardInterrupt, Exception)) # False
print(issubclass(KeyboardInterrupt, BaseException)) # TrueThe reason is exact. When you eventually write a catch-all, you will catch Exception. That split means 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. The family tree, in other words, 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. →
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.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.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.__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.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.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; we reach for a field that was never stored.
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 fallRead 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.
try thrown around the whole loop is a net under the whole job. The bad entry gets handled, and the loop shakes it off and carries on — so one unparseable string costs you exactly one song, and everything after it still lands in the total.total = 0
try:
for s in ["200", "3:20", "245"]: # Blinding Lights, a slip, Titanium
total += int(s)
except ValueError:
print("skipped a bad one")
print("total:", total)skipped a bad one total: 200
except picks up after the whole try block and never back inside it, so the loop — which was in the try — was abandoned mid-stride and Titanium was never even reached; move the try inside the loop, around the single line that can fail, and one bad string costs exactly one song.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.try: opens the guarded stretch; except names the class of failure you are ready for. Anything else keeps flying.as e gives the thrown object a name for the length of the block. Inside it you can read str(e), e.args and type(e).__name__.except: is except BaseException: wearing a disguise. It swallows your Ctrl-C and your typos with equal enthusiasm.you type
song = {"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200}
# Type 1 - the bare minimum: one net, one named type
try:
print(song["genre"])
except KeyError:
print("no genre stored")
# Type 2 - `as e` binds the thrown object so you can read it
try:
print(song["genre"])
except KeyError as e:
print("missing key:", e, "| class:", type(e).__name__, "| args:", e.args)
# Type 3 - one net, two types: hand except a tuple
for raw in ("200", "3:20"):
try:
print(raw, "->", int(raw) // 60, "min")
except (ValueError, TypeError) as e:
print(raw, "-> rejected:", e)
# Type 4 - two nets, two responses. Order is law: specific first.
def length_of(box, key):
try:
return int(box[key]) // 60
except KeyError:
return "no such field"
except ValueError:
return "field is not a number"
print(length_of(song, "genre"))
print(length_of({"seconds": "3:20"}, "seconds"))
print(length_of(song, "seconds"), "min")
# what the bare net would swallow, and Exception would not
print("Ctrl-C is an Exception? ", issubclass(KeyboardInterrupt, Exception))
print("Ctrl-C is a BaseException?", issubclass(KeyboardInterrupt, BaseException))you see
no genre stored
missing key: 'genre' | class: KeyError | args: ('genre',)
200 -> 3 min
3:20 -> rejected: invalid literal for int() with base 10: '3:20'
no such field
field is not a number
3 min
Ctrl-C is an Exception? False
Ctrl-C is a BaseException? True- The
try:line ends in a colon and its body is indented. Both are compulsory, and both are checked before a single line runs. except KeyErrorcatchesKeyErrorand every subclass of it. AValueErrorraised on the very same line sails straight past.- The name in
as eis deleted when the block ends. Need the error below? Copy it out inside the block witherr = e. - A broad clause above a narrow one makes the narrow one dead code. Python never warns you; it simply never reaches it.
except Exception:is the widest you should normally write.issubclass(KeyboardInterrupt, Exception)isFalse, and that is deliberate.
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. Watch which ones the net holds, and which one falls right through it.
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.except KeyError untouched and the program stops. The handler you didn't write is the failure you don't survive.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.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.
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. 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 fashion. 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. This is one of those "off or on is a useful lie" moments. The tidy story is "e holds the error." The true story is "e holds it, but 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. →
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.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.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.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. It is 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 most code 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 — cleanup that 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.
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.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.else.except and else runs on any completed attempt.try, then except, then else, then finally. An else with no except above it is a SyntaxError.you type
def parse(raw):
print(" try :", repr(raw))
try:
n = int(raw)
except ValueError:
print(" except : not a number")
return -1
else:
print(" else : parsed", n)
return n
finally:
print(" finally : always - even on the way out through return")
print("returned", parse("200"))
print("returned", parse("20O"))
# finally also fires while an exception is still climbing
def strict(raw):
try:
return int(raw)
finally:
print(" finally : fired with the ValueError still in flight")
try:
strict("20O")
except ValueError as e:
print("caught upstairs:", e)
# else runs OUTSIDE the net, so its own failures are not caught here
def demo():
try:
seconds = int("200")
except ValueError:
print(" except : never runs")
else:
print(" else : parsed", seconds, "- now dividing by zero")
return seconds / 0
finally:
print(" finally : still runs")
try:
demo()
except ZeroDivisionError as e:
print("the else block's error sailed past the except beside it:", e)you see
try : '200'
else : parsed 200
finally : always - even on the way out through return
returned 200
try : '20O'
except : not a number
finally : always - even on the way out through return
returned -1
finally : fired with the ValueError still in flight
caught upstairs: invalid literal for int() with base 10: '20O'
else : parsed 200 - now dividing by zero
finally : still runs
the else block's error sailed past the except beside it: division by zeroelsecannot stand alone: expected 'except' or 'finally' block. A baretry/finally, though, is perfectly ordinary Python.- A
returninsidefinallyoverrides everything — including an exception in flight, which then disappears with no traceback. Never do it. - An error raised in
elseis not caught by theexceptsitting right beside it. That is the entire reasonelseexists. finallyruns before the value leaves. Python computes the return, pauses on the doorstep, runs the cleanup, then hands the value back.- Only
os._exit()(and a pulled plug) skipsfinally. Evensys.exit()honours it, because it raisesSystemExitand that has to unwind.
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.
# 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 pathThe 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 flow re-route through the four blocks.
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.except and else take turns; finally is on every route, including the one where the error escapes uncaught and keeps climbing.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.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."
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, and this is the place to file them down.
Edge one: a return inside finally wins, and swallows exceptions whole. finally is the last thing to run before control leaves. So 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, a silent lie. Never return from a finally.
def length_of(raw):
try:
return int(raw) # about to hand back 200...
finally:
return -1 # ...but this WINS — caller always gets -1Edge 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. 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 →
with takes an object that knows how to tidy up after itself — a context manager — and guarantees the tidying happens on every exit.finally: f.close() breaks the day open() itself fails: f was never bound, so your cleanup raises a NameError on top of the real error.with catches nothing. An exception inside the block still flies out at full speed — it just leaves a closed file behind it.dst shuts before src, exactly like nested blocks.__enter__/__exit__ are Volume 2, chapter 15.you type
from pathlib import Path
Path("night.m3u").write_text("Blinding Lights\nTitanium\n", encoding="utf-8")
# Type 1 - the shape: open it, use it, and it is closed for you
with open("night.m3u", encoding="utf-8") as f:
first = f.readline().strip()
print("first line :", first)
print("f.closed :", f.closed)
# Type 2 - it still closes when the block blows up mid-read
try:
with open("night.m3u", encoding="utf-8") as f2:
raise ValueError("something went wrong mid-read")
except ValueError as e:
print("caught :", e)
print("f2.closed :", f2.closed)
# Type 3 - the same promise, written by hand
f3 = open("night.m3u", encoding="utf-8")
try:
print("by hand :", f3.readline().strip())
finally:
f3.close()
print("f3.closed :", f3.closed)
# the name survives the block; only the file is shut
try:
f.read()
except ValueError as e:
print("reuse :", e)
# the trap `with` exists to remove: cleanup for a file that never opened
try:
f4 = open("missing.m3u", encoding="utf-8")
try:
print(f4.read())
finally:
f4.close()
except FileNotFoundError as e:
print("honest :", type(e).__name__, "- the real error, not a NameError")
Path("night.m3u").unlink()you see
first line : Blinding Lights
f.closed : True
caught : something went wrong mid-read
f2.closed : True
by hand : Blinding Lights
f3.closed : True
reuse : I/O operation on closed file.
honest : FileNotFoundError - the real error, not a NameError- The name
fsurvives the block; only the file is shut.f.closedreadsTrue, and reading it again raisesValueError: I/O operation on closed file. - A missing file raises
FileNotFoundErrorbeforewithowns anything, so there is nothing to close and nothing to hide the real error. - Always pass
encoding="utf-8". Without it Python picks a platform default, and the same file reads differently on someone else's machine. - Every
withblock is afinallywith a bow on it. If you can name the resource, usewith; hand-roll thetry/finallyonly when nothing else fits.
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.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.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.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.
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)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, like 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.
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.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.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.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. It becomes an address that tells you precisely what to fix:
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 namesTwo 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.
Traceback (most recent call last):
File "tally.py", line 11, in <module>
print(minutes("200"))
^^^^^^^^^^^^^^
File "tally.py", line 5, in minutes
print("dropped so far:", skipped)
^^^^^^^
UnboundLocalError: cannot access local variable 'skipped' where it is not associated with a value"200" parses cleanly, and the handler never ran at all. A module-level skipped = 0 counts the rows we drop; the try reads it, and the except below bumps it with skipped += 1. Now follow the caret — the line that died is the read, up in the try, and its cause is that assignment two lines beneath it, in a handler that never executed. Python settles local-versus-global once, when it compiles the function — chapter 9's rule, arriving somewhere you did not expect it — by scanning the whole body for assignments, so a single skipped += 1 anywhere makes skipped a local everywhere, and the read above it has nothing to read yet. Notice which net missed it, too: UnboundLocalError is a child of NameError, not of ValueError, so it sailed straight past the except sitting right beside it.global skipped at the top of the function — or better, return the tally and let the caller keep it. File "parse.py", line 3
except ValueError, e:
^^^^^^^^^^^^^
SyntaxError: multiple exception types must be parenthesizedTraceback header above this one, and that absence is the whole clue: nothing ran. Python refused at compile time, before line 1 ever executed. except ValueError, e: is Python 2 grammar, still scattered across old answers; Python 3 reads that comma as the opening of a tuple of types and then asks for the brackets that would make it one.except ValueError as e: names the object, except (ValueError, TypeError): names two types.bad length
Traceback (most recent call last):
File "minutes.py", line 8, in <module>
print(minutes(None))
^^^^^^^^^^^^^
File "minutes.py", line 3, in minutes
return int(raw) // 60
^^^^^^^^
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'bad length printed — which is what makes this baffling: the handler plainly works. But except ValueError or TypeError: never named two types. It is an ordinary boolean expression, and Python evaluates it the moment an exception arrives looking for a net: ValueError is a truthy object, so or hands back ValueError and never even looks at TypeError. The only class Python ever matched against was ValueError, and the TypeError was never in the net.except (ValueError, TypeError):.# read_the_traceback.py -- one crash, three frames deep. Read it from the bottom up.
def to_seconds(raw): # frame 3 - the crash site
# int("4:05") has no integer inside it
return int(raw)
def make_track(title, raw): # frame 2
return {"title": title, "seconds": to_seconds(raw)}
def load_playlist(rows): # frame 1
tracks = []
for title, raw in rows:
tracks.append(make_track(title, raw))
return tracks
rows = [("Blinding Lights", "200"), ("Titanium", "4:05")] # row 2 is the bad one
# 1 - catch it, and read the flight recorder yourself, link by link
try:
load_playlist(rows)
except ValueError as e:
print("class :", type(e).__name__)
print("message :", e)
frames = []
tb = e.__traceback__ # a linked list, oldest frame first
while tb is not None:
frames.append(tb)
tb = tb.tb_next
for tb in frames:
print(f" frame : {tb.tb_frame.f_code.co_name:<14} line {tb.tb_lineno}")
print(" locals at the crash site:", dict(frames[-1].tb_frame.f_locals))
# 2 - now let it fly uncaught, and read what Python prints
load_playlist(rows)
print("never reached")
class : ValueError
message : invalid literal for int() with base 10: '4:05'
frame : <module> line 20
frame : load_playlist line 13
frame : make_track line 8
frame : to_seconds line 5
locals at the crash site: {'raw': '4:05'}
Traceback (most recent call last):
File "read_the_traceback.py", line 34, in <module>
load_playlist(rows)
File "read_the_traceback.py", line 13, in load_playlist
tracks.append(make_track(title, raw))
^^^^^^^^^^^^^^^^^^^^^^
File "read_the_traceback.py", line 8, in make_track
return {"title": title, "seconds": to_seconds(raw)}
^^^^^^^^^^^^^^^
File "read_the_traceback.py", line 5, in to_seconds
return int(raw)
^^^^^^^^
ValueError: invalid literal for int() with base 10: '4:05'
read_the_traceback.py and run it. This is the drill that turns a wall of red into an address. Start at the last line and work up. ValueError: invalid literal for int() with base 10: '4:05' is the diagnosis: the type was fine, the content was impossible. One line above it is the crash site — line 5, in to_seconds, with the offending expression underlined by carets. Keep climbing and you get the route that led there: make_track called it, load_playlist called that, <module> started the whole thing on line 34. Oldest call at the top, crash at the bottom, always. Now the writer's half, which most tutorials never show you. That printed traceback is not text Python composes at the end. It is a linked list the exception builds as it climbs, hanging off the object in e.__traceback__, one link per frame, threaded oldest-first through tb_next. Part 1 walks that chain by hand and prints the same four frames the crash does. Then it reaches into the deepest link and reads the live locals of the frame that died: {'raw': '4:05'}. There is the culprit, by name, with its value — something the printed traceback never tells you. Two things to try. Add a third bad row and watch the crash move to whichever one fails first. Then swap except ValueError for except Exception as e and print traceback.format_exc() after import traceback — the same flight log, formatted exactly as the console would. (One honest note about the output: each File line is trimmed to the bare filename here. Your terminal prints the full path to it.)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.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. And 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." 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? →
def lookup(name): try: return f"{shlf[name]} seconds" except: return "not on the shelf". The shelf dict is called shelf; the lookup says shlf. Run it on "titanium", a song that is on the shelf, and it politely reports not on the shelf. Nothing crashes. Nothing is logged. The answer is simply a lie. Your job has three parts. First, name the two failures that bare except: is currently swallowing — one is the typo, the other is the thing a user presses when they want out. Prove the second one with issubclass(KeyboardInterrupt, Exception) before you touch any code. Second, narrow the net so it catches only the failure it was written for: a title that genuinely is not on the shelf. Do not fix the typo yet — run it again and let the NameError surface, so you can see the bug the wide net was hiding. Third, fix the typo, and check that a KeyboardInterrupt raised inside the guarded block now flies straight through your handler. One hint and no more: the class you name in an except decides everything, and the class you want here is the one a missing dict key raises.show the solution
# fix_the_net.py -- one bare except, two failures it should never have touched
shelf = {"blinding lights": 200, "titanium": 245}
# ---------- the program as found ----------
def lookup_broken(name):
try:
return f"{shlf[name]} seconds" # typo: shlf, not shelf -> NameError
except: # bare net: swallows the typo AND Ctrl-C
return "not on the shelf"
print("broken :", lookup_broken("titanium"), " <- a lie, the song IS on the shelf")
# ---------- the repair: name the one failure you expect ----------
def lookup(name):
try:
return f"{shlf[name]} seconds" # same typo, still there
except KeyError: # the only failure we planned for
return "not on the shelf"
try:
lookup("titanium")
except NameError as e:
print("fixed : NameError surfaced ->", e)
# ---------- with the typo repaired, the net does exactly its job ----------
def lookup_fixed(name):
try:
return f"{shelf[name]} seconds"
except KeyError:
return "not on the shelf"
print("fixed :", lookup_fixed("titanium"))
print("fixed :", lookup_fixed("levels"))
# ---------- and Ctrl-C is no longer eaten ----------
def play(name):
try:
if name == "^C":
raise KeyboardInterrupt # stands in for you pressing Ctrl-C
return f"playing {name}"
except KeyError:
return "not on the shelf"
try:
play("^C")
except KeyboardInterrupt:
print("fixed : Ctrl-C flew straight through - the program is quittable again")
print("proof : issubclass(KeyboardInterrupt, Exception) ->",
issubclass(KeyboardInterrupt, Exception))
# ------------- what it prints -------------
broken : not on the shelf <- a lie, the song IS on the shelf
fixed : NameError surfaced -> name 'shlf' is not defined
fixed : 245 seconds
fixed : not on the shelf
fixed : Ctrl-C flew straight through - the program is quittable again
proof : issubclass(KeyboardInterrupt, Exception) -> Falseexcept 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.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.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.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.
raise launches it up the stack. The rest of the function never runs.e.args, and comes back out of str(e). Put the offending value in it — f"impossible length: {seconds}s" debugs itself later.raise inside a handler re-throws the exception you are currently holding, with its original traceback. Note it, log it, then let it keep flying.from e records the cause in __cause__, and the traceback reads the direct cause of the following exception. from None hides plumbing the caller cannot act on.ValueError. Wrong type entirely: TypeError. Something only your world knows is broken: a class of your own.you type
class PlaylistError(Exception):
"""Anything wrong with a playlist."""
def add(shelf, title, seconds):
if not isinstance(seconds, int):
raise TypeError(f"seconds must be int, got {type(seconds).__name__}")
if seconds <= 0:
raise ValueError(f"impossible length: {seconds}s")
shelf.append((title, seconds))
# Type 1 - build and throw in one breath
shelf = []
add(shelf, "Blinding Lights", 200)
for title, secs in [("Titanium", -50), ("Levels", "3:23")]:
try:
add(shelf, title, secs)
except (ValueError, TypeError) as e:
print(f"{type(e).__name__}: {e}")
print("shelf :", shelf)
# Type 2 - bare raise: note it, then let the SAME object keep flying
def add_logged(shelf, title, seconds):
try:
add(shelf, title, seconds)
except ValueError:
print("log : rejected", title)
raise
try:
add_logged(shelf, "Titanium", -50)
except ValueError as e:
print("caller still sees:", e)
# Type 3 - raise ... from e: translate the failure, keep the cause on record
def load_length(field):
try:
return int(field)
except ValueError as e:
raise PlaylistError(f"bad length field: {field!r}") from e
try:
load_length("4:05")
except PlaylistError as e:
print("domain error :", e)
print("caused by :", repr(e.__cause__))
# raise a class, not an instance - Python builds it for you
try:
raise PlaylistError
except PlaylistError as e:
print("bare class :", repr(e), "| args:", e.args)
# and the one thing you may not throw
try:
raise "the playlist is broken"
except TypeError as e:
print("not throwable:", e)you see
ValueError: impossible length: -50s
TypeError: seconds must be int, got str
shelf : [('Blinding Lights', 200)]
log : rejected Titanium
caller still sees: impossible length: -50s
domain error : bad length field: '4:05'
caused by : ValueError("invalid literal for int() with base 10: '4:05'")
bare class : PlaylistError() | args: ()
not throwable: exceptions must derive from BaseExceptionraiseonly crashes the program if nobody catches it. It is a signal, not a death sentence — exactly like a built-in error.- Raise as early as the truth is still local. A guard at the door beats a mystery ten functions downstream, where the bad value finally detonates.
- A bare
raisewith nothing in flight raisesRuntimeError: No active exception to reraise. It only works inside a handler. - Whatever you throw must derive from
BaseException.raise "broken"gives youTypeError: exceptions must derive from BaseException. - Every
assert x, "msg"is araise AssertionErrorin disguise — andpython -Ostrips them all out. Never validate user input withassert.
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.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:
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: -50sraise 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:
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. It never accidentally catches 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.
Exception and you inherit .args, a working str() and a live __traceback__ for free. The docstring is the whole body.__init__, hand the message to super().__init__(), then store your fields — chapter 12, unchanged.Error, and give each module one base class. except PlaylistError then means anything this module refuses to do.ValueError is honest and free.you type
# Type 1 - one line, and you own a whole named category of failure
class PlaylistError(Exception):
"""Base for every failure this module raises."""
# Type 2 - children, so a caller picks how wide a net to cast
class SongNotFoundError(PlaylistError):
"""No song by that title on the shelf."""
# Type 3 - a child carrying fields of its own (chapter 12's __init__ and super())
class PlaylistFullError(PlaylistError):
def __init__(self, name, limit):
super().__init__(f"{name!r} is full at {limit} songs")
self.name = name
self.limit = limit
shelf = {"Blinding Lights": 200, "Titanium": 245}
def find(title):
if title not in shelf:
raise SongNotFoundError(f"{title!r} is not on the shelf")
return shelf[title]
def add(title):
if len(shelf) >= 2:
raise PlaylistFullError("Night drive", 2)
shelf[title] = 0
# it inherits everything an exception needs, for free
e = SongNotFoundError("Levels")
print("class :", type(e).__name__)
print("args :", e.args, "| str(e):", str(e))
print("is a PlaylistError?", isinstance(e, PlaylistError), "| an Exception?", isinstance(e, Exception))
print("tree :", " -> ".join(c.__name__ for c in type(e).__mro__))
# a caller catches YOURS narrowly, or the whole family in one clause
for job in (lambda: find("Levels"), lambda: add("Levels")):
try:
job()
except SongNotFoundError as err:
print("narrow :", err)
except PlaylistError as err:
print("family :", type(err).__name__, "-", err)
# the extra fields ride along inside the object
try:
add("Faded")
except PlaylistFullError as err:
print("fields :", err.name, "|", err.limit)
# and it never catches a ValueError that meant something else
try:
int("4:05")
except PlaylistError:
print("never printed")
except ValueError as err:
print("not ours:", err)you see
class : SongNotFoundError
args : ('Levels',) | str(e): Levels
is a PlaylistError? True | an Exception? True
tree : SongNotFoundError -> PlaylistError -> Exception -> BaseException -> object
narrow : 'Levels' is not on the shelf
family : PlaylistFullError - 'Night drive' is full at 2 songs
fields : Night drive | 2
not ours: invalid literal for int() with base 10: '4:05'- Your class joins the same tree as
KeyError:SongNotFoundError → PlaylistError → Exception → BaseException → object. Nothing about it is special. - Inherit from
Exception, never fromBaseException. BelowBaseExceptionis whereKeyboardInterruptlives, and you do not want that company. - A class with only a docstring needs no
pass. The docstring is the body — and it is the sentence a reader sees when they meet your error. - Raising
PlaylistErrorwhere aValueErrorwould do is noise. One base plus two or three children covers almost every real program. - The message still lives in
args, sostr(e)works with no effort from you. Extra fields you add are for the handler to act on, not to read aloud.
except a parent catches its childrenfigureBaseException. 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.Z9), and tonight's queue is already full. Design the exception classes for it. Before you type anything, decide on paper: how many classes, and which one is the parent? Then write them, and give each child an __init__ that stores the facts a handler would need to act — the amount paid and the price, the code that failed, the limit that was hit. Now write the jukebox function that raises them, and a caller that handles the three cases differently: refund the coins, ask for another code, apologise for the queue. Two constraints make this an exercise rather than a typing drill. The caller must not contain a single if that inspects the error — the class does the deciding. And there must be one clause a future colleague can write that catches everything your jukebox refuses, without catching anything Python raises. Run it on [("A1", 50), ("Z9", 50), ("A2", 20), ("B1", 50), ("B1", 50)] with a queue limit of two, and check you get one of each outcome.show the solution
# jukebox_errors.py -- three failures, one family
class JukeboxError(Exception):
"""Base: anything this jukebox refuses to do."""
class CoinRejectedError(JukeboxError):
"""The money is wrong."""
def __init__(self, paid, price):
super().__init__(f"paid {paid}c, the price is {price}c")
self.paid, self.price = paid, price
class SelectionError(JukeboxError):
"""The code punched in maps to no song."""
def __init__(self, code):
super().__init__(f"no song at code {code!r}")
self.code = code
class QueueFullError(JukeboxError):
"""No room left in tonight's queue."""
def __init__(self, limit):
super().__init__(f"queue is full at {limit} songs")
self.limit = limit
CATALOGUE = {"A1": "Blinding Lights", "A2": "Titanium", "B1": "Levels"}
PRICE, LIMIT = 50, 2
queue = []
def select(code, paid):
if paid < PRICE:
raise CoinRejectedError(paid, PRICE)
if code not in CATALOGUE:
raise SelectionError(code)
if len(queue) >= LIMIT:
raise QueueFullError(LIMIT)
queue.append(CATALOGUE[code])
return CATALOGUE[code]
for code, paid in [("A1", 50), ("Z9", 50), ("A2", 20), ("B1", 50), ("B1", 50)]:
try:
song = select(code, paid)
except CoinRejectedError as e:
print(f"refund {e}")
except SelectionError as e:
print(f"try again {e}")
except JukeboxError as e: # the family net holds the rest
print(f"sorry {type(e).__name__}: {e}")
else:
print(f"queued {song}")
print("queue :", queue)
print("tree :", " -> ".join(c.__name__ for c in QueueFullError.__mro__[:4]))
print("one net :", [c.__name__ for c in (CoinRejectedError, SelectionError, QueueFullError)
if issubclass(c, JukeboxError)])
# ------------- what it prints -------------
queued Blinding Lights
try again no song at code 'Z9'
refund paid 20c, the price is 50c
queued Levels
sorry QueueFullError: queue is full at 2 songs
queue : ['Blinding Lights', 'Levels']
tree : QueueFullError -> JukeboxError -> Exception -> BaseException
one net : ['CoinRejectedError', 'SelectionError', 'QueueFullError']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. Then notice what the shelf looks like afterwards.
raise▸ drag me<= 0 is turned away at the door, and the shelf never learns it existed.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.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.
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 is already too broad for most code, yet it 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, because 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:
try:
add_song(shelf, title, seconds)
except PlaylistError:
log.warning("rejected a bad row")
raise # re-throw the same object, same tracebackThe 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:
def load_length(field):
try:
return int(field)
except ValueError as e:
raise PlaylistError(f"bad length field: {field!r}") from eOne honest label: calling an exception "a thrown value" is a useful simplification. Underneath, the object carries more than a message. It holds 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. →
check_track(row): it returns the row untouched if it is a usable track, and raises otherwise. A usable track is a dict with title, artist and seconds; a title that is a non-empty string; and a seconds that is a whole number between 1 and 3600. The whole exercise lives in the messages. Each one must name the field, show the offending value, and pick the right class: TypeError when the shape is wrong, ValueError when the shape is fine but the content is impossible, KeyError when a field is missing outright. "bad row" is not a message. "seconds out of range 1-3600: -212" is. Then run it over these seven and print one line each, accepted or rejected: a good track; one with no seconds; a title of three spaces; "3:23" as the seconds; True as the seconds; -212 as the seconds; and a plain list instead of a dict. Two hints, then you are on your own. True is genuinely an int in Python — isinstance(True, int) is True — so a bare type check waves it through. And when you print the KeyError, notice it arrives wearing quotes the others do not; that is the one built-in that shows the repr() of its payload.show the solution
# guard.py -- one door, and nothing malformed gets past it
REQUIRED = ("title", "artist", "seconds")
def check_track(row):
"""Return the row unchanged if it is a usable track. Otherwise raise, loudly."""
if not isinstance(row, dict):
raise TypeError(f"track must be a dict, got {type(row).__name__}")
missing = [field for field in REQUIRED if field not in row]
if missing:
raise KeyError(f"track is missing {', '.join(missing)}")
if not isinstance(row["title"], str) or not row["title"].strip():
raise ValueError(f"title must be a non-empty string, got {row['title']!r}")
if isinstance(row["seconds"], bool) or not isinstance(row["seconds"], int):
raise TypeError(f"seconds must be int, got {type(row['seconds']).__name__}")
if not 1 <= row["seconds"] <= 3600:
raise ValueError(f"seconds out of range 1-3600: {row['seconds']}")
return row
rows = [
{"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200},
{"title": "Titanium", "artist": "David Guetta"},
{"title": " ", "artist": "Avicii", "seconds": 203},
{"title": "Levels", "artist": "Avicii", "seconds": "3:23"},
{"title": "One Dance", "artist": "Drake", "seconds": True},
{"title": "Faded", "artist": "Alan Walker", "seconds": -212},
["Uptown Funk", "Mark Ronson", 270],
]
shelf = []
for row in rows:
try:
shelf.append(check_track(row))
except (TypeError, ValueError, KeyError) as e:
print(f"reject {type(e).__name__:<10} {e}")
else:
print(f"accept {row['title']}")
print("shelf :", [t["title"] for t in shelf])
# ------------- what it prints -------------
accept Blinding Lights
reject KeyError 'track is missing seconds'
reject ValueError title must be a non-empty string, got ' '
reject TypeError seconds must be int, got str
reject TypeError seconds must be int, got bool
reject ValueError seconds out of range 1-3600: -212
reject TypeError track must be a dict, got list
shelf : ['Blinding Lights']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.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.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.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.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.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. Write 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.
dict.get(key, default) is a single lookup with no exception built at all. Reach for it when the key is often missing."-50".isdigit() is False, and so is " 245 ".isdigit(), yet int() parses both.try wins outright, one lookup against two. With the key always absent, building and unwinding an exception every time loses badly — read the two numbers.you type
song = {"title": "Blinding Lights", "seconds": 200}
# Type 1 - LBYL: look before you leap. Ask first, then act.
if "genre" in song:
genre = song["genre"]
else:
genre = "unknown"
print("LBYL :", genre)
# Type 2 - EAFP: do it, and handle the truth you actually meet.
try:
genre = song["genre"]
except KeyError:
genre = "unknown"
print("EAFP :", genre)
# Type 3 - the built-in shortcut, for this one case
print(".get :", song.get("genre", "unknown"))
# on a conversion, the hand-written check is quietly wrong
for raw in ("245", "-50", " 245 "):
lbyl = int(raw) if raw.isdigit() else None
try:
eafp = int(raw)
except ValueError:
eafp = None
print(f"{raw!r:>9} isdigit->{raw.isdigit()!s:<5} LBYL={lbyl!s:<5} EAFP={eafp}")
# and the price, measured here: 1,000,000 lookups each
import timeit
setup = 'song = {"title": "Blinding Lights", "seconds": 200}'
N = 1_000_000
hit_lbyl = timeit.timeit('song["title"] if "title" in song else None', setup=setup, number=N)
hit_eafp = timeit.timeit('try:\n song["title"]\nexcept KeyError:\n pass', setup=setup, number=N)
miss_lbyl = timeit.timeit('song["genre"] if "genre" in song else None', setup=setup, number=N)
miss_eafp = timeit.timeit('try:\n song["genre"]\nexcept KeyError:\n pass', setup=setup, number=N)
print(f"key present : LBYL {hit_lbyl:.3f}s EAFP {hit_eafp:.3f}s")
print(f"key absent : LBYL {miss_lbyl:.3f}s EAFP {miss_eafp:.3f}s")you see
LBYL : unknown
EAFP : unknown
.get : unknown
'245' isdigit->True LBYL=245 EAFP=245
'-50' isdigit->False LBYL=None EAFP=-50
' 245 ' isdigit->False LBYL=None EAFP=245
key present : LBYL 0.027s EAFP 0.017s
key absent : LBYL 0.020s EAFP 0.114s- EAFP is not
except ValueError: pass. If you cannot name what the handler will actually do, you have no business catching the error. - The gap in LBYL only bites when something else can move: a file another process deletes, a socket, a row two threads touch. For a plain dict in one thread there is no race.
- A
trythat does not raise costs nothing measurable on CPython 3.11 and up. You pay only in the instant you actually fall. - Flip the odds and flip the idiom. If you expect to miss most of the time,
.get()or anifis cheaper than building an exception on every call. - You already rely on this: every
forloop ends when the iterator raisesStopIteration, and the loop quietly catches it. Iteration is EAFP wearing afor.
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.raise, so the bar overtakes LBYL and turns red. That 3% is the whole reason they're called exceptions.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. Meanwhile the EAFP program, which never asked, simply meets the missing file and catches it.
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:
# 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.
# robust_input.py -- one function that refuses to hand back nonsense
def get_int(prompt, low=None, high=None):
"""Ask until the answer is a whole number inside [low, high]. Never raises on bad text."""
while True:
raw = input(prompt)
try:
value = int(raw)
except ValueError:
print(f" {raw!r} is not a whole number. Digits only - 200, not 3:20.")
continue
if low is not None and value < low:
print(f" {value} is below the minimum of {low}.")
continue
if high is not None and value > high:
print(f" {value} is above the maximum of {high}.")
continue
return value
def get_int_or(prompt, default, low=None, high=None):
"""Same, but a closed input stream falls back instead of crashing."""
try:
return get_int(prompt, low, high)
except EOFError:
print(f" (input closed - falling back to {default})")
return default
# --- a staged keyboard, so this run is reproducible on your machine too ---
typed = iter(["3:20", "-90", "9000", "200"])
def input(prompt=""): # shadows the builtin for this demo only
try:
raw = next(typed)
except StopIteration:
print(prompt) # the prompt shows, then the stream closes
raise EOFError # exactly what Ctrl-Z (or Ctrl-D) raises
print(prompt + raw) # echo it, as a terminal would
return raw
seconds = get_int("Length in seconds: ", low=1, high=3600)
print("Added a", seconds // 60, "min track.")
encore = get_int_or("How many encores? ", default=0, low=0, high=5)
print("Encores:", encore)
Length in seconds: 3:20 '3:20' is not a whole number. Digits only - 200, not 3:20. Length in seconds: -90 -90 is below the minimum of 1. Length in seconds: 9000 9000 is above the maximum of 3600. Length in seconds: 200 Added a 3 min track. How many encores? (input closed - falling back to 0) Encores: 0
robust_input.py and run it. The loop from the last page was a fine idea trapped in one place; this lifts it into a function you can call anywhere. That is the whole move, and it is chapter 9 meeting chapter 13. Read get_int as a promise to its caller: I will not hand you anything that is not a whole number in range. Everything inside serves that one sentence. The while True has no condition, because the exit is not a test — it is the return at the bottom, reached only when nothing objected. Notice there are three ways to be rejected and only one way out. Bad text raises ValueError and continue swings back for another go; out-of-range values never raise at all, they just fail an if and continue too. Both paths print a message that says what was wrong and what to do instead — "3:20" is not a whole number beats invalid input every time. Then look at what is not caught. EOFError is handled one level up in get_int_or, which falls back to a default when the input stream closes; KeyboardInterrupt is caught nowhere at all, so Ctrl-C still ends the program instantly, exactly as the user meant. The staged typed list and the local input exist only so this run reproduces on your machine; delete both and it reads the real keyboard, unchanged. You have written the heart of this before, by the way. Ajai's own guess_the_number.py already runs a while True with int(input(…)) inside a try and an except ValueError below it. What it never got was a name, a range and a return — and those three things are what turn a loop into a tool you can use twice. Three things to try. Call get_int("Year: ", low=1900, high=2030) and watch the same function police a different range. Add a get_choice(prompt, options) beside it on the same pattern. Then delete the continue after the except and work out, before running it, exactly which line now breaks the promise.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.# playlist = ["Blinding Lights", "Titanium"]
it = iter(playlist)
next(it) # 'Blinding Lights'
next(it) # 'Titanium'
next(it) # StopIteration ← the exception every for-loop catches to stopif 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 5× 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.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.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 matters only when the race itself is possible: when the resource is shared, like a file another process can delete, a socket, or 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. →
- An exception is an object, not a scream — built on the heap with a class on the lid, an
.argspayload inside and a message it will read aloud — and then it travels, climbing frame after frame until someone catches it or it runs out of stack and takes the program down. try/exceptnever prevents the fall; it decides what happens next — and the net holds by type, catching the class you named and every child beneath it, so aValueErrorsails straight through a net woven forKeyError.elseis the follow-on work that only makes sense if nothing broke, deliberately kept out from under the net;finallyis the cleanup that fires on every way out — caught, uncaught,return,break— which is the promisewithhas been quietly keeping for you since chapter 11.- Catch narrowly: name the failure you expect and let your own bugs crash loudly, because a crash is a free address for the bug — file, line and diagnosis — and a bare
excepttrades that address for a program that works and lies at the same time. raiseis you sending the signal rather than only receiving one, andclass PlaylistError(Exception)buys a whole named category in a single line — so in Python, trying and catching is ordinary control flow, not an emergency: everyforloop you have ever let run to the end stops on a caughtStopIteration.
with, which turns out to have been finally wearing a bow all along; and chapter 12 gave you the inheritance that class PlaylistError(Exception) is built from — one line, and a failure of your own has a name. Nothing new was invented in this chapter. The machinery you already had just learned how to say no.# guarded_playlist.py -- chapter 12 built the Playlist. This one hardens it.
class PlaylistError(Exception):
"""Base: anything this playlist refuses to do."""
class EmptyPlaylistError(PlaylistError):
def __init__(self, name):
super().__init__(f"{name!r} has nothing to play")
self.name = name
class SongNotFoundError(PlaylistError):
def __init__(self, name, title):
super().__init__(f"{title!r} is not in {name!r}")
self.name, self.title = name, title
class DuplicateSongError(PlaylistError):
def __init__(self, name, title):
super().__init__(f"{title!r} is already in {name!r}")
self.name, self.title = name, title
class Song:
def __init__(self, title, artist, seconds):
if not isinstance(seconds, int) or seconds <= 0:
raise ValueError(f"impossible length for {title!r}: {seconds!r}")
self.title, self.artist, self.seconds = title, artist, seconds
def __str__(self):
minutes, secs = divmod(self.seconds, 60)
return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"
class Playlist:
def __init__(self, name, songs=None):
self.name = name
self.songs = list(songs) if songs else []
self.playing = None
def __len__(self):
return len(self.songs)
def _find(self, title):
for song in self.songs:
if song.title == title:
return song
raise SongNotFoundError(self.name, title)
def add(self, song):
if any(s.title == song.title for s in self.songs):
raise DuplicateSongError(self.name, song.title)
self.songs.append(song)
return self
def remove(self, title):
song = self._find(title) # _find raises if it is not here
self.songs.remove(song)
return song
def play(self):
if not self.songs:
raise EmptyPlaylistError(self.name)
self.playing = self.songs[0]
return self.playing
# ---------- the caller: it catches, it never inspects ----------
def run(shelf, commands):
for verb, arg in commands:
try:
if verb == "add":
shelf.add(arg)
result = f"added {arg.title}"
elif verb == "remove":
result = f"removed {shelf.remove(arg).title}"
else:
result = f"playing {shelf.play()}"
except DuplicateSongError as e:
print(f" skip {e}")
except SongNotFoundError as e:
print(f" miss {e}")
except EmptyPlaylistError as e:
print(f" silence {e}")
except PlaylistError as e: # anything of ours we forgot to name
print(f" refused {type(e).__name__}: {e}")
else:
print(f" ok {result}")
night = Playlist("Night drive")
run(night, [
("play", None),
("add", Song("Blinding Lights", "The Weeknd", 200)),
("add", Song("Titanium", "David Guetta", 245)),
("add", Song("Blinding Lights", "The Weeknd", 200)),
("remove", "Levels"),
("play", None),
("remove", "Titanium"),
])
print("shelf :", [s.title for s in night.songs], "| now playing:", night.playing)
# a bad length never becomes a Song at all
try:
Song("Broken", "Nobody", -50)
except ValueError as e:
print("rejected:", e)
# and a caller who only cares that *something* of ours went wrong writes one line
try:
Playlist("Empty").play()
except PlaylistError as e:
print("one net :", type(e).__name__, "-", e)
silence 'Night drive' has nothing to play ok added Blinding Lights ok added Titanium skip 'Blinding Lights' is already in 'Night drive' miss 'Levels' is not in 'Night drive' ok playing Blinding Lights - The Weeknd (3:20) ok removed Titanium shelf : ['Blinding Lights'] | now playing: Blinding Lights - The Weeknd (3:20) rejected: impossible length for 'Broken': -50 one net : EmptyPlaylistError - 'Empty' has nothing to play
guarded_playlist.py and run it. Chapter 12 built this Playlist and it worked — right up until someone asked it to play an empty one. Here it grows a spine. Look first at the four lines at the top, because that is the whole design: one base class, PlaylistError, and three children under it. A caller can now catch exactly the trouble they can handle, or the whole family in one clause, and never once accidentally swallow a ValueError from somewhere else. Then read the methods and notice what they no longer do. remove does not return 0 and shrug; it asks _find, which raises SongNotFoundError naming the title and the playlist. play does not hand back None for the caller to trip over three lines later; it refuses, loudly, with EmptyPlaylistError. add refuses a duplicate. And Song.__init__ guards the door before an object exists at all — a track with a length of -50 is never built, so it can never be stored, printed or summed. That is the arc of this chapter in one class: the failure is caught where the truth is still local. Ajai's own OOP notes reach for the same move — set_salary raises ValueError("Salary cannot be negative!") rather than quietly storing a negative wage. Same door, one chapter earlier. Now read run, because it is the point. Four except clauses, each naming one meaning, then else for the success line — kept out of the try so a bug in the reporting can never be mistaken for a bad command. There is not one if inspecting an error anywhere in it. The class of the exception is the branch. The last clause, except PlaylistError, is the safety net for the child we have not written yet, and it still lets Ctrl-C and every genuine bug fly straight past. Three things to try. Add a NotPlayableError child and confirm the existing run handles it with no edit at all. Give remove a finally that logs every attempt, and watch it fire on both the hit and the miss. Then delete the guard in Song.__init__, add a track of -50 seconds, and follow how far that one bad number travels before anything complains.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.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.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.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.
try block runs hopefully; when a line raises, control leaps to a matching except instead of crashing.else for the happy path.finally and with both promise the same thing: some code runs no matter how the block ends — success, exception, or early return.raise — perhaps a custom type, perhaps re-raising after a note.assert for the things that must never be false.