◈ python mapVol 1 · Ch 13/14
Volume 1 Python, from the metal up · chapter 13

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.

iolinked · chapter 13 — the checkpoints6 steps
$ sections covered in When it breaks
01An exception is an object, not a scream
02try / except: a net under the risky line
03else and finally: the two forgotten blocks
04Catch the error you expect — not all of them
05raise: signalling your own errors
06Ask forgiveness, not permission

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.

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. 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:

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 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 stack▸ drag me
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 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:

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. 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. →

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; 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.

⚠ MOST BEGINNERS THINK…the loop that lost a song and said nothing
A 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.
TYPE THIS — 10 SECONDS
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
445 was the honest answer, and the program said 200 without blinking: an 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.
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.
SYNTAX · try / except — four shapes of one netAjai's Type 1/2/3 order, plus the bare net you should never write
try: — Type 1: the minimum. Name the failure you expect risky_line except SomeError: what to do instead try: — Type 2: bind the thrown object with as risky_line except SomeError as e: print(e) — read the message it carried up try: — Type 3: two failures, one response risky_line except (ErrorA, ErrorB) as e: — a tuple of classes, one clause what to do instead try: — Type 4: two failures, two responses risky_line except ErrorA: — Python reads these top to bottom… except ErrorB: — …first match wins, then it stops looking except: — the bare net. It eats Ctrl-C too. Don't.
Type 1The smallest thing that compiles. try: opens the guarded stretch; except names the class of failure you are ready for. Anything else keeps flying.
Type 2as 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__.
Type 3One clause, a tuple of classes. Reach for it when two different failures honestly deserve the same recovery.
Type 4Two clauses, two recoveries. Order is law: the first matching net wins, so the specific one goes above the general one.
the bare netexcept: 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
where beginners trip
  • 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 KeyError catches KeyError and every subclass of it. A ValueError raised on the very same line sails straight past.
  • The name in as e is deleted when the block ends. Need the error below? Copy it out inside the block with err = 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) is False, 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.

Fire a line through the net — success, caught, or escaped▸ drag me
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. 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. →

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.

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.

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.
SYNTAX · else / finally — the two blocks almost nobody writesfour blocks, one fixed order, and a run that proves exactly who fires when
try: — the risk, and nothing else risky_line except SomeError: — runs ONLY if the risk failed with a matching class recover else: — runs ONLY if the risk succeeded. Not guarded. follow_on_work finally: — runs on EVERY way out: success, failure, cleanup    an early return, a break, an uncaught error try: — legal on its own: no except in sight risky_line finally: cleanup
tryKeep it to the smallest code whose failure you are volunteering to catch. Everything that merely depends on it belongs in else.
exceptThe recovery, one clause per class of failure. Exactly one of except and else runs on any completed attempt.
elseThe success-only block. It sits outside the net on purpose, so a genuine bug in the follow-on work is never mislabelled as the failure you were catching.
finallyThe only unconditional block. It fires the instant control leaves, including while an exception is still climbing toward your caller.
the orderPython enforces this exact sequence: 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 zero
where beginners trip
  • else cannot stand alone: expected 'except' or 'finally' block. A bare try/finally, though, is perfectly ordinary Python.
  • A return inside finally overrides everything — including an exception in flight, which then disappears with no traceback. Never do it.
  • An error raised in else is not caught by the except sitting right beside it. That is the entire reason else exists.
  • finally runs 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) skips finally. Even sys.exit() honours it, because it raises SystemExit and 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.

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 flow re-route through the four blocks.

Flip the outcome — watch control flow re-route▸ drag me
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, 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.

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. 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 →

SYNTAX · with — the finally you never have to rememberfirst taste: the one cleanup shape you will type a thousand times
with open(path) as f: — Type 1: open, use, closed for you on every exit text = f.read() with open(path, "w", encoding="utf-8") as f: — the mode picks read / write / append f.write(text) with open(a) as src, open(b, "w") as dst: — Type 2: two resources, one block dst.write(src.read()) f = open(path) — Type 3: the same promise, written by hand try: text = f.read() finally: f.close()
what it iswith takes an object that knows how to tidy up after itself — a context manager — and guarantees the tidying happens on every exit.
why not finallyfinally: f.close() breaks the day open() itself fails: f was never bound, so your cleanup raises a NameError on top of the real error.
what it does not dowith catches nothing. An exception inside the block still flies out at full speed — it just leaves a closed file behind it.
two at onceCommas chain them. Each resource is closed in reverse order, so dst shuts before src, exactly like nested blocks.
the honest limitThis is a first taste, aimed at the cleanup guarantee. Modes, paths, encodings and writing your own __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
where beginners trip
  • The name f survives the block; only the file is shut. f.closed reads True, and reading it again raises ValueError: I/O operation on closed file.
  • A missing file raises FileNotFoundError before with owns 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 with block is a finally with a bow on it. If you can name the resource, use with; hand-roll the try/finally only when nothing else fits.
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.

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, 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.

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 swallowing▸ drag me
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. It 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.
ERROR CLINICyou will meet these — decoded
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
Nothing was wrong with the data: "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.
Say which name you meant — 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 parenthesized
There is no Traceback 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.
Decide which you meant — 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'
The first call was caught — 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.
A tuple, not a boolean — except (ValueError, TypeError):.
TYPE THIS · save it, then run itread_the_traceback.py — one crash, three frames deep, read from the bottom up
# 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'
Save it as 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.)
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. 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? →

NOW WRITE IT YOURSELFthe net that ate two things it should never have touched
Here is a jukebox lookup that a colleague swears is bulletproof: 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) -> False
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.

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.

SYNTAX · raise — three ways to throw, and one you may notbuild-and-throw, re-throw, and translate — each shape run and caught below
raise ValueError(f"impossible length: {seconds}s") — Type 1: construct it, then launch it raise ValueError — a bare class: Python calls it for you except SomeError: — Type 2: inside a handler… log("rejected a bad row") raise — …re-throw the SAME object, traceback intact except ValueError as e: — Type 3: translate a low-level failure raise PlaylistError("bad length field") from e — e lands in __cause__ raise PlaylistError("bad length field") from None — cause deliberately suppressed raise "the playlist is broken" — TypeError. Only exceptions may be thrown.
Type 1Two acts in one breath: the class is called to build an object, then raise launches it up the stack. The rest of the function never runs.
the messageWhatever you pass rides inside as e.args, and comes back out of str(e). Put the offending value in it — f"impossible length: {seconds}s" debugs itself later.
Type 2A bare 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.
Type 3from 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.
which classRight type, impossible value: 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 BaseException
where beginners trip
  • raise only 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 raise with nothing in flight raises RuntimeError: No active exception to reraise. It only works inside a handler.
  • Whatever you throw must derive from BaseException. raise "broken" gives you TypeError: exceptions must derive from BaseException.
  • Every assert x, "msg" is a raise AssertionError in disguise — and python -O strips them all out. Never validate user input with assert.
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. 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.

SYNTAX · your own exception — one line buys a whole categorya base, its children, and a child that carries fields — chapter 12's inheritance, cashed in
class PlaylistError(Exception): — Type 1: one line, and you own a named failure """Base for every failure this module raises.""" class SongNotFoundError(PlaylistError): — Type 2: a child, so callers pick their width """No song by that title on the shelf.""" class PlaylistFullError(PlaylistError): — Type 3: a child carrying fields of its own def __init__(self, name, limit): super().__init__(f"{name!r} is full at {limit} songs") self.name, self.limit = name, limit except SongNotFoundError: — a caller catches yours narrowly… except PlaylistError: — …or the whole family in one clause
Type 1Subclass Exception and you inherit .args, a working str() and a live __traceback__ for free. The docstring is the whole body.
Type 2Children give your callers a choice of width. They can catch the exact trouble, or the family, without ever writing a bare net.
Type 3Need extra facts on the object? Write __init__, hand the message to super().__init__(), then store your fields — chapter 12, unchanged.
the namingEnd the class in Error, and give each module one base class. except PlaylistError then means anything this module refuses to do.
when to botherMake your own the moment a caller might want to catch your failure and not Python's. Until then, 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'
where beginners trip
  • Your class joins the same tree as KeyError: SongNotFoundError → PlaylistError → Exception → BaseException → object. Nothing about it is special.
  • Inherit from Exception, never from BaseException. Below BaseException is where KeyboardInterrupt lives, 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 PlaylistError where a ValueError would do is noise. One base plus two or three children covers almost every real program.
  • The message still lives in args, so str(e) works with no effort from you. Extra fields you add are for the handler to act on, not to read aloud.
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.
NOW WRITE IT YOURSELFdesign the exception family for a jukebox — three failures, one tree
You have seen Python's tree. Now grow a small one of your own. A coin-operated jukebox in a bar can refuse a customer in exactly three ways: the money is wrong (they put in 20c when a play costs 50c), the code they punched maps to no song (there is no 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']
↺ 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. Then notice what the shelf looks like afterwards.

Feed the border a length — watch it pass or raise▸ drag me
-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 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:

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 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. →

NOW WRITE IT YOURSELFwrite the guard — one door, and nothing malformed gets past it
A playlist file is about to hand your program seven rows, and some of them are rubbish. Write 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']
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

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.

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.
SYNTAX · LBYL vs EAFP — the same access, written both wayscheck-then-act, try-then-catch, and the built-in that beats them both
if "genre" in song: — Type 1: LBYL. Ask, then act. Two lookups. genre = song["genre"] else: genre = "unknown" try: — Type 2: EAFP. Do it, meet reality. One lookup. genre = song["genre"] except KeyError: genre = "unknown" genre = song.get("genre", "unknown") — Type 3: the built-in for exactly this case try: — the conversion no if can test honestly secs = int(raw) except ValueError: secs = None
LBYLLook before you leap. A separate check, then the action. Two touches of the same key, and a gap between them where the world can move.
EAFPEasier to ask forgiveness than permission. The attempt is the test. One touch, no gap, and the failure does the talking.
the shortcutdict.get(key, default) is a single lookup with no exception built at all. Reach for it when the key is often missing.
why int() settles itThere is no honest hand-written test for "is this text an integer?". "-50".isdigit() is False, and so is " 245 ".isdigit(), yet int() parses both.
the priceMeasured below over a million lookups each: with the key present the 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
where beginners trip
  • 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 try that 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 an if is cheaper than building an exception on every call.
  • You already rely on this: every for loop ends when the iterator raises StopIteration, and the loop quietly catches it. Iteration is EAFP wearing a for.
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 fires▸ drag me
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. Meanwhile the EAFP program, which never asked, simply meets the missing file and catches it.

Race the world — watch the check go stale▸ drag me
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.

TYPE THIS · save it, then run itrobust_input.py — a get_int() that loops until the answer is usable
# 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
Save it as 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.
↺ 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 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. →

SAY IT BACKthe chapter in five breaths
  1. An exception is an object, not a scream — built on the heap with a class on the lid, an .args payload 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.
  2. try/except never 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 a ValueError sails straight through a net woven for KeyError.
  3. else is the follow-on work that only makes sense if nothing broke, deliberately kept out from under the net; finally is the cleanup that fires on every way out — caught, uncaught, return, break — which is the promise with has been quietly keeping for you since chapter 11.
  4. 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 except trades that address for a program that works and lies at the same time.
  5. raise is you sending the signal rather than only receiving one, and class PlaylistError(Exception) buys a whole named category in a single line — so in Python, trying and catching is ordinary control flow, not an emergency: every for loop you have ever let run to the end stops on a caught StopIteration.
You already owned the pieces: chapter 6 gave you the heap these error objects are built on; chapter 9 gave you the stack of frames they climb and abandon; chapter 11 gave you 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.
TYPE THIS · the chapter's capstone — save it, then run itguarded_playlist.py — chapter 12 built the Playlist; this one hardens it
# 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
Save it as 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.
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.
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
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked