Exceptions and finally: the one word that stops a function from failing
A try that does not fire costs about two nanoseconds — five percent of an empty call — and the bytecode explains why. The expensive thing is something else: a return in finally, which throws the exception away in silence and turns the function into one no error can leave.
Full technical treatment
TL;DR
A try statement has four parts, each with its own job: try is the
attempt, except handles the error, else is the "no error happened" branch,
and finally is the exit taken either way. The thing to know about finally
is that it runs after success and after failure alike — which is exactly what
it is written for.
Hence the main consequence: a finally that transfers control outwards
destroys the exception in flight. A return in finally does it — the
reference puts it in a single sentence: “the saved exception is discarded” —
and so do break and continue. Hence the silent data loss: the same rows as
in the context-managers lesson yield 100 instead of 175, a different route
to the same wrong answer, with not one exception in the log. Next to it sits a
second trap: a bare except: catches BaseException, meaning Ctrl+C and
sys.exit().
Beyond that is what separates knowing from having read. A try/except
that does not fire costs about two nanoseconds — 40.5 ns against 38.7 ns
for the same code without the try (3.13.7). The gap is resolvable: the
instrument's own spread over five repeats of the whole measurement is 0.4 ns,
so the difference is real — and it is five percent of an empty call, which
is a different statement from "free". A taken exception is 201.1 ns — five
times as much. The reason is structural rather than accidental:
SETUP_FINALLY is a pseudo-instruction and never reaches the bytecode, so on
the happy path a try adds one NOP. From 3.14 the compiler warns about a
return in finally (PEP 765) — warns only: the behaviour is unchanged.
- when an error happens inside a function, the remaining lines do not run;
- an error travels outwards — from the called function to the caller and on, until something catches it;
- an error has a type —
ValueError,KeyError— and it is caught by that type.
BaseException,ExceptionGroup,except*,__context__,__cause__;- PEP 765,
SETUP_FINALLY, the exception table in the bytecode, and nanoseconds from measurements.
Base: four parts, each with its own job
A try statement has four parts. Everyone knows the first two; the other two
are the ones people mix up — and mixing up their jobs is what produces the
errors this whole lesson is about.
try— the attempt: code that might not work out;except— the handling: what to do if it did not, and for which error exactly;else— the "no error happened" branch: code that only makes sense when the attempt succeeded;finally— the exit taken either way: code that runs after success and after failure alike.
One example shows all four at once:
try:
value = int(raw) # the attempt
except ValueError:
value = 0 # reached only if the attempt failed
else:
audit(value) # reached only if it succeeded
finally:
counter += 1 # reached in either casefinally deserves a sentence of its own, because its job is wider than it
looks. It runs not only after a success and after a caught error, but also when
control leaves the block outwards: on a return from the middle of the
try, and on an error nobody caught. That is why releasing resources — closing
a file, releasing a lock — goes in a finally: it is the one place that is
known to be reached.
And here is the question this lesson is about. finally runs while the
exception is on its way out — so what happens to the exception itself if the
finally decides to leave outwards too, with a return, a break or a
continue?
Usually nothing unusual: the finally runs and the exception carries on. But
in this one case the exception disappears. Not deferred, not replaced: it is
gone, and the function returns a value as if nothing had failed.
That is already enough to answer the basic interview question: try is the
attempt, except the handling, else the success branch, finally the exit
taken either way — and that exit can swallow the error. Everything below is
about what that looks like in code nobody would suspect; about how a bare
except: differs from except Exception:; and about exception chains and
groups, where several errors arrive at once.
Mechanism 1: the one word that stops a function from failing
return in finally discards the saved exception. That is what the reference says, and it holds on every version.The reference puts it in a single sentence: “If the finally clause executes a
return, break or continue statement, the saved exception is discarded”.
Read “discarded” literally. Not overridden, not replaced — gone:
def never_fails():
try:
raise RuntimeError("anything at all")
finally:
return NoneThis function cannot fail. Not ever, not from anything: any exception inside the
try is dropped on the way out. Verified by running it: the call returns None, and there is no
exception.
break does the same, and it is harder to spot because there is no return in
sight:
for _ in range(1):
try:
raise KeyError("this one too")
finally:
breakA
finallythat transfers control outwards swallows the exception just as completely asexcept BaseException: pass. The only difference is that the second one is visible when you read the code and the first one is not.
The fourth case in the figure is the same return in finally without an
exception: what gets discarded there is an ordinary result.
Mechanism 2: the error that does not crash
The rows are the same as in the context-managers lesson. Different mechanism — same wrong answer.
ROWS = [
{"id": 1, "amount": "100"},
{"id": 2, "amount": "no data"}, # broken
{"id": 3, "amount": "50"},
{"id": 4, "amount": "25"},
]The right version: finally only closes the resource, and a separate except
decides what to do with a broken row.
def correct(rows):
total = 0
for row in rows:
handle = open("/dev/null", "w")
try:
total += int(row["amount"])
except ValueError:
continue
finally:
handle.close()
return totalThe wrong one looks tidier — “we closed the resource and honestly returned what we managed to count”:
def broken(rows):
total = 0
for row in rows:
handle = open("/dev/null", "w")
try:
total += int(row["amount"])
finally:
handle.close()
return total # ← right hereVerified by running it (identical on 3.13.7 and 3.14.7):
| case | result |
|---|---|
finally closes, except skips the broken row | 175 |
finally with a return | 100 |
| correct total | 175 |
75 of 175 lost, not one exception in the log. One line separates the two
functions, and that line looks like care for a resource. But a return in
finally leaves the function at the first broken row, dropping the exception on
the way out — whereas except ValueError: continue in the right version simply
moves on to the next record.
Mechanism 3: else — what is it for, if you can just add to try
The difference shows up not in the successful case, but in what happens to an exception raised by the code you added.
The reference: “The optional else clause is executed if the control flow leaves
the try suite, no exception was raised… Exceptions in the else clause are
not handled by the preceding except clauses”.
So else is the way to narrow the try down to the line that can actually
fail:
try:
value = registry[key] # may raise KeyError
except KeyError:
value = default
else:
audit(value) # a KeyError from here is NOT caughtIf audit raises a KeyError of its own, the handler will not catch it — and
rightly so: this except was written for one specific line, not for whatever
happens to sit nearby. Verified by running it: an exception raised in else
walks straight past the except above it.
Add a finally to the same block and the order comes out like this (verified by
running it):
no exception: try -> else -> finally
with exception: try -> except -> finally
Mechanism 4: a bare except: catches more than you think
KeyboardInterrupt and SystemExit are not Exception. One line settles it:
issubclass(SystemExit, Exception) is False, and the MRO of
KeyboardInterrupt is KeyboardInterrupt → BaseException → object.
The documentation explains the design: “The exception inherits from
BaseException so as to not be accidentally caught by code that catches
Exception and thus prevent the interpreter from exiting”.
That is, the hierarchy is built so that except Exception: lets Ctrl+C
through. A bare except: removes that protection: it catches BaseException,
and the program no longer exits on Ctrl+C.
Mechanism 5: chains — __context__, __cause__ and from None
An exception raised inside an except gets a reference to the previous one —
that is PEP 3134, and the interpreter does it without the author's involvement.
| form | __context__ | __cause__ | __suppress_context__ | in the traceback |
|---|---|---|---|---|
plain raise inside except | set | none | False | “During handling of the above exception…” |
raise … from e | set | set | True | “The above exception was the direct cause…” |
raise … from None | set | none | True | no linking sentence |
The third row is the useful one. from None does not remove the previous
exception: __context__ stays where it was, and only __suppress_context__
changes — the flag that keeps the traceback from printing it.
In practice this means from None is about the readability of a message, not
about hiding data. The previous exception remains reachable from the object, and
a top-level handler can still log it.
One more detail, this time from the language reference rather than the PEP: the
name bound by except … as e is
deleted at the end of the clause. The reference gives the exact desugaring —
it is as if the body were wrapped in try: … finally: del N. Touching e after
the block raises NameError; to keep the object, assign it to another name.
Mechanism 6: groups — how except* differs from except
PEP 654 (3.11) added the ExceptionGroup type and the except* syntax for one
job: to raise and handle several independent errors at once. The typical
source is parallel tasks that each failed differently. That is exactly what
asyncio.TaskGroup reports, and the chain is a direct one:
TaskGroupcancels the siblings and collects their errors;- it hands them over as one object — an
ExceptionGroupholding several independent exceptions; except*splits the group by type, and each branch runs at most once.
TaskGroup itself is covered in the async/await lesson; what is taken apart
here is the second half of the chain. Everything below is verified by running
it.
A group is an object, not its contents. An ExceptionGroup does not "unwrap"
into its errors: a plain except ValueError: will not catch the group, even if
there is exactly one ValueError inside.
try:
raise ExceptionGroup("one", [ValueError("lonely")])
except ValueError:
print("not here")
except ExceptionGroup as e:
print("caught the group:", type(e).__name__) # → caught the group: ExceptionGroupA plain except Exception does catch the group, but as a whole — as one object,
not error by error.
except* splits the group by type and can run several times for one raise.
Each clause receives a subgroup — again an ExceptionGroup — with all leaves
of its type. The PEP: “a single exception group can cause several except*
clauses to execute, but each such clause executes at most once”.
try:
raise ExceptionGroup("two troubles", [ValueError("value"), KeyError("key")])
except* ValueError as eg:
print("ValueError:", [str(x) for x in eg.exceptions]) # → ValueError: ['value']
except* KeyError as eg:
print("KeyError:", [str(x) for x in eg.exceptions]) # → KeyError: ["'key'"]
# both clauses ran for one raiseNote: the clause variable (eg) is not a single exception but a group; the
errors themselves are in eg.exceptions.
The unhandled remainder travels on — again as a group. If the clauses did not
take everything, the remaining leaves keep propagating as an ExceptionGroup,
not as separate exceptions:
try:
raise ExceptionGroup("three", [ValueError(), KeyError(), TypeError()])
except* ValueError:
pass
# an ExceptionGroup([KeyError(), TypeError()]) propagates outYou cannot mix except and except* in one try — it is a syntax error. A
try's clauses are either all plain or all starred.
Deeper: what a try costs
Now the part that gets argued about without numbers: what the construction itself costs.
| form | 3.13.7 | 3.14.7 |
|---|---|---|
no try | 38.7 ns | 34.5 ns |
try/except, not taken | 40.5 ns | 36.9 ns |
try/except, taken | 201.1 ns | 189.5 ns |
Read the columns separately. The builds of 3.13.7 and 3.14.7 differ by more than the compiler, and the gap between columns is not a difference between language versions.
The gap between the first two rows has to be put next to the spread of the instrument before anything is said about it — and the run does that itself, by repeating the whole measurement five times (labels translated from the script's output, the numbers are as printed):
SPREAD OVER 5 REPEATS OF THE WHOLE MEASUREMENT
no try 38.7 .. 39.1 ns spread 0.4
try/except, not taken 40.5 .. 40.9 ns spread 0.4
try/except, taken 201.1 .. 203.4 ns spread 2.3
gap between the first two rows 1.8 ns
widest spread of those same two rows 0.4 ns
can the instrument resolve the gap yes
Resolvable: 1.8 ns against a spread of 0.4. So the claim here is not
“indistinguishable” but something narrower and more useful: a try that does
not fire costs about two nanoseconds — five percent — and the sign of that
difference is stable, with the try version slightly slower in all five
repeats.
That changes the question. It is no longer “is there a difference” — there is —
but “is it worth anything”, and that is a question about size. Five percent on
an empty call pays for no restructuring at all: for it to show up as a win, the
try would have to sit in a loop that is itself the bottleneck.
The bytecode shows why. SETUP_FINALLY is documented under pseudo-instructions,
and about those the dis page says outright: “They do not appear in Python
bytecode. They are used by the compiler but are replaced by real opcodes or
removed before bytecode is generated”. Verified with the disassembler on all
four versions — SETUP_FINALLY appears in none of them.
Here is what actually executes:
no try: RESUME LOAD_NAME PUSH_NULL CALL POP_TOP
in try: RESUME NOP LOAD_NAME PUSH_NULL CALL POP_TOP
One NOP apart. Where did the handling go? Into a separate table: the
disassembler prints it as an ExceptionTable block under the code. While no
exception occurs, nobody reads that table.
And here the boundary of the observation matters as much as the number. What
was measured is one empty call in a loop, on two CPython builds, on one
machine. Exactly one thing follows: in this measurement a try on the
successful path costs about two nanoseconds — not that a try is free in
general, and not that it can go anywhere without consequence.
Nested handlers, a finally with work in it, a try inside a generator are
not covered by this measurement.
So the practical conclusion is written from the boundary rather than from the
number: the argument “drop the try, it is slowing us down” has nothing
behind it — when it comes up, ask for a measurement instead of removing the
construction. The expensive case is a taken exception — five times as much —
and that is what does not belong in a hot loop as a routine branch.
Deeper: what changed in 3.14
The compiler started warning about a return or a break that leaves a
finally. Verified by compiling:
3.11.15 warnings: 0
3.12.3 warnings: 0
3.13.7 warnings: 0
3.14.7 SyntaxWarning: 'return' in a 'finally' block
break gets its own message, 'break' in a 'finally' block.
The behaviour did not change. PEP 765 (Irit Katriel, Alyssa Coghlan) says so
directly: “we leave it open whether, and when, this will become a
SyntaxError”. Code written this way runs exactly as it did — it is merely
reported now. The PEP names the reason for the caution: runs with -We turn
warnings into errors, and an immediate ban would break them.
Deeper: version history
| Version | Change | What it means for your code |
|---|---|---|
| 3.0 | PEP 3134 introduces __context__ and __cause__. The chain appears on its own, without the author doing anything; raise … from sets it explicitly. | |
| 3.11 | PEP 654: ExceptionGroup and except*. Verified with the disassembler: on all four versions, 3.11 through 3.14, SETUP_FINALLY is already absent from the bytecode, and the cost of a try that is not taken stays inside the spread of the measurement. | |
| 3.14 | PEP 765: the compiler warns about return, break and continue leaving a finally. The behaviour is unchanged — the exception is still discarded in silence. |
How to answer in an interview
The short answer: try is the attempt, except handles the error, else is
the "no error happened" branch, finally is the exit taken either way. And the
key point about finally: if it transfers control outwards itself — return,
break, continue — the exception in flight disappears. The reference puts it
in one sentence: "the saved exception is discarded".
That is enough to answer correctly. Beyond it is what you add if the interviewer digs.
If the interviewer digs deeper
First, show what it looks like in real code: a return in finally reads as
care for a resource, yet it leaves the function at the very first error; the
same rows as in the context-managers lesson yield 100 instead of 175, with not
one exception in the log. From 3.14 the compiler warns about it (PEP 765) — and
only warns: the behaviour is unchanged.
Second, a bare except: catches BaseException, meaning Ctrl+C and
sys.exit() — the hierarchy is built precisely so that except Exception:
lets them through.
Third, if the conversation turns to speed, quote the measurement together with
its boundary: on the successful path a try costs about two nanoseconds in
this measurement (40.5 ns against 38.7 for the same code without the try, a
gap of five percent against an instrument spread of 0.4 ns), while a taken
exception costs 201.1 ns — five times more. The reason is
structural: SETUP_FINALLY is a pseudo-instruction and never reaches the
bytecode, so on the happy path try adds a single NOP. The conclusion is
narrow: there is no reason to remove a try for speed, and there is a reason
not to use exceptions as routine branching in a hot loop.
Next they ask
A return inside finally — what happens to the exception in flight?
It disappears, and the function returns a value as on an ordinary exit. One word, and the function stops raising at all — including exceptions the author never had in mind.
When except* and when a plain except?
except* is for where several exceptions can arrive at once and none of them may
be lost — that is, where exception groups came from in the first place. A plain
except handles one exception and picks one branch.
Common misconceptions
try/except slows code down; better to check with a condition
It does slow it down — by five percent of an empty call, which is not the same claim: 38.7 ns without the try against 40.5 ns with it (3.13.7), a gap of 1.8 ns against an instrument spread of 0.4 ns over five repeats. The reason is structural: SETUP_FINALLY is a pseudo-instruction and never reaches the bytecode, so the happy path gains one NOP. The question is therefore about size, not sign: the argument “drop the try, it is slowing us down” needs a measurement of the surrounding code behind it, because two nanoseconds pay for no restructuring. The expensive case is a TAKEN exception: 201.1 ns, five times as much.
a return in finally simply overrides the return value
It also destroys the exception. The reference: “the saved exception is discarded”. A function with a return in its finally cannot fail at all — verified by running it: a raise RuntimeError inside the try never comes out, and the call returns None. break and continue do the same.
3.14 banned return in finally
It warned, it did not ban. PEP 765: “we leave it open whether, and when, this will become a SyntaxError”. Verified by compiling: 3.14 emits SyntaxWarning: 'return' in a 'finally' block, 3.11–3.13 emit nothing, and the code behaves identically on all four.
else in a try is syntactic sugar; just append to the try
Not without changing the meaning. The reference: “Exceptions in the else clause are not handled by the preceding except clauses”. Move a line out of else into try and you hand its exceptions to the same handler — a KeyError from a nested call becomes indistinguishable from the KeyError the except was written for.
a bare except: is the same as except Exception:
It catches BaseException, which includes KeyboardInterrupt and SystemExit. The hierarchy is deliberate — the documentation: “inherits from BaseException so as to not be accidentally caught by code that catches Exception”. A bare except: removes that protection, and the program no longer exits on Ctrl+C.
raise ... from None erases the previous exception
It does not. Verified: after from None the object's __context__ is still the ZeroDivisionError; only __suppress_context__ changes — so the traceback stops printing it. The previous exception stays reachable and can be logged at the top level.
the variable from except ... as e is usable after the block
It is not: it gets deleted. The reference gives the exact desugaring — the except body is wrapped in try: … finally: del N. Touching it afterwards raises NameError: name 'e' is not defined. To keep the object, assign it to another name inside the block.
an ExceptionGroup of one behaves like that one exception
No. ExceptionGroup("one", [ValueError()]) is not caught by a plain except ValueError: — verified by running it, the except ExceptionGroup clause fires instead. A group stays a group whatever its size, and it is taken apart with except*.
Practice
Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.
Practice · predict the output
def swallow():
try:
raise ValueError("boom")
finally:
return "ok"
def keep():
try:
raise ValueError("boom")
finally:
pass
print(swallow())
try:
keep()
except ValueError as e:
print("ValueError:", e)Practice · estimate
Knowledge check
What does a function return if its try raises RuntimeError and its finally has return None?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A
trystatement has four parts, each with its own job:tryis the attempt,excepthandles the error,elseis the "no error happened" branch, andfinallyis the exit taken either way. The thing to know aboutfinallyis that it runs after success and after failure alike — which is exactly what it is written for. - Hence the main consequence: a
finallythat transfers control outwards destroys the exception in flight. Areturninfinallydoes it — the reference puts it in a single sentence: “the saved exception is discarded” — and so dobreakandcontinue. Hence the silent data loss: the same rows as in the context-managers lesson yield 100 instead of 175, a different route to the same wrong answer, with not one exception in the log. Next to it sits a second trap: a bareexcept:catchesBaseException, meaningCtrl+Candsys.exit(). - Beyond that is what separates knowing from having read. A
try/exceptthat does not fire costs about two nanoseconds — 40.5 ns against 38.7 ns for the same code without thetry(3.13.7). The gap is resolvable: the instrument's own spread over five repeats of the whole measurement is 0.4 ns, so the difference is real — and it is five percent of an empty call, which is a different statement from "free". A taken exception is 201.1 ns — five times as much. The reason is structural rather than accidental:SETUP_FINALLYis a pseudo-instruction and never reaches the bytecode, so on the happy path atryadds oneNOP. From 3.14 the compiler warns about areturninfinally(PEP 765) — warns only: the behaviour is unchanged.
In fact
- It does slow it down — by five percent of an empty call, which is not the same claim: 38.7 ns without the
tryagainst 40.5 ns with it (3.13.7), a gap of 1.8 ns against an instrument spread of 0.4 ns over five repeats. The reason is structural:SETUP_FINALLYis a pseudo-instruction and never reaches the bytecode, so the happy path gains oneNOP. The question is therefore about size, not sign: the argument “drop thetry, it is slowing us down” needs a measurement of the surrounding code behind it, because two nanoseconds pay for no restructuring. The expensive case is a TAKEN exception: 201.1 ns, five times as much. - It also destroys the exception. The reference: “the saved exception is discarded”. A function with a
returnin itsfinallycannot fail at all — verified by running it: araise RuntimeErrorinside thetrynever comes out, and the call returnsNone.breakandcontinuedo the same. - It warned, it did not ban. PEP 765: “we leave it open whether, and when, this will become a
SyntaxError”. Verified by compiling: 3.14 emitsSyntaxWarning: 'return' in a 'finally' block, 3.11–3.13 emit nothing, and the code behaves identically on all four. - Not without changing the meaning. The reference: “Exceptions in the
elseclause are not handled by the precedingexceptclauses”. Move a line out ofelseintotryand you hand its exceptions to the same handler — aKeyErrorfrom a nested call becomes indistinguishable from theKeyErrortheexceptwas written for. - It catches
BaseException, which includesKeyboardInterruptandSystemExit. The hierarchy is deliberate — the documentation: “inherits fromBaseExceptionso as to not be accidentally caught by code that catchesException”. A bareexcept:removes that protection, and the program no longer exits onCtrl+C. - It does not. Verified: after
from Nonethe object's__context__is still theZeroDivisionError; only__suppress_context__changes — so the traceback stops printing it. The previous exception stays reachable and can be logged at the top level. - It is not: it gets deleted. The reference gives the exact desugaring — the
exceptbody is wrapped intry: … finally: del N. Touching it afterwards raisesNameError: name 'e' is not defined. To keep the object, assign it to another name inside the block. - No.
ExceptionGroup("one", [ValueError()])is not caught by a plainexcept ValueError:— verified by running it, theexcept ExceptionGroupclause fires instead. A group stays a group whatever its size, and it is taken apart withexcept*.
By version
- 3.0
- PEP 3134 introduces
__context__and__cause__. The chain appears on its own, without the author doing anything;raise … fromsets it explicitly.< - 3.11
- PEP 654:
ExceptionGroupandexcept*. Verified with the disassembler: on all four versions, 3.11 through 3.14,SETUP_FINALLYis already absent from the bytecode, and the cost of atrythat is not taken stays inside the spread of the measurement.< - 3.14
- PEP 765: the compiler warns about
return,breakandcontinueleaving afinally. The behaviour is unchanged — the exception is still discarded in silence.<
What is covered
- Base: four parts, each with its own job
- Mechanism 1: the one word that stops a function from failing
- Mechanism 2: the error that does not crash
- Mechanism 3: `else` — what is it for, if you can just add to `try`
- Mechanism 4: a bare `except:` catches more than you think
- Mechanism 5: chains — `__context__`, `__cause__` and `from None`
- Mechanism 6: groups — how `except*` differs from `except`
- Deeper: what a `try` costs
- Deeper: what changed in 3.14
- Deeper: version history
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
6 SOURCES
- The language reference — the try statementOfficial documentation. Two normative sentences carry half this lesson. On finally: “If the finally clause executes a return, break or continue statement, the saved exception is discarded”. On else: “The optional else clause is executed if the control flow leaves the try suite, no exception was raised… Exceptions in the else clause are not handled by the preceding except clauses”. The same page states that the name bound by `except … as N` is deleted at the end of the clause, and spells the desugaring out as try/finally: del N.https://docs.python.org/3.14/reference/compound_stmts.html#the-try-statement
- PEP 765 — Disallow return/break/continue that exit a finally blockPEP. Irit Katriel and Alyssa Coghlan, status Final, Python 3.14. From 3.14 the compiler warns — but warns only: “we leave it open whether, and when, this will become a SyntaxError”. The code keeps behaving exactly as before; it is now merely reported.https://peps.python.org/pep-0765/
- PEP 654 — Exception Groups and except*PEP. Irit Katriel, Yury Selivanov, Guido van Rossum; Final, Python 3.11. Two of the three properties in the groups section come from here: “a single exception group can cause several except* clauses to execute, but each such clause executes at most once” and “the remaining part of the group is propagated on”. The third — that a one-element group is not caught by an except on the leaf type (except ValueError) — is verified by running it (
bench/exceptions/groups.py); the PEP has no normative sentence for it.https://peps.python.org/pep-0654/ - PEP 3134 — Exception Chaining and Embedded TracebacksPEP. Ka-Ping Yee, Final, Python 3.0. The document that introduced `__context__` and `__cause__`: the interpreter sets the first on its own, while `raise EXCEPTION from CAUSE` — equivalent to `exc.__cause__ = CAUSE` — sets the second.https://peps.python.org/pep-3134/
- dis — SETUP_FINALLY among the pseudo-instructionsOfficial documentation. The instruction is documented under “Pseudo-instructions”, about which the page says outright: “They do not appear in Python bytecode. They are used by the compiler but are replaced by real opcodes or removed before bytecode is generated”. That is what explains the measurement: on the happy path a try executes nothing but a NOP.https://docs.python.org/3.14/library/dis.html
- Built-in exceptions — Exception versus BaseExceptionOfficial documentation. “All built-in, non-system-exiting exceptions are derived from this class [Exception].” And on KeyboardInterrupt, literally: “The exception inherits from BaseException so as to not be accidentally caught by code that catches Exception and thus prevent the interpreter from exiting”.https://docs.python.org/3.14/library/exceptions.html