Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·30 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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.
You do not need to know in advance
  • 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:

PYTHON
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 case

finally 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

language contractLanguage guarantee: a 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:

PYTHON
def never_fails():
    try:
        raise RuntimeError("anything at all")
    finally:
        return None

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

PYTHON
for _ in range(1):
    try:
        raise KeyError("this one too")
    finally:
        break

A finally that transfers control outwards swallows the exception just as completely as except 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.

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

PYTHON
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 total

The wrong one looks tidier — “we closed the resource and honestly returned what we managed to count”:

PYTHON
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 here

Verified by running it (identical on 3.13.7 and 3.14.7):

caseresult
finally closes, except skips the broken row175
finally with a return100
correct total175

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:

PYTHON
try:
    value = registry[key]          # may raise KeyError
except KeyError:
    value = default
else:
    audit(value)                   # a KeyError from here is NOT caught

If 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 exceptsetnoneFalse“During handling of the above exception…”
raise … from esetsetTrue“The above exception was the direct cause…”
raise … from NonesetnoneTrueno 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:

  1. TaskGroup cancels the siblings and collects their errors;
  2. it hands them over as one object — an ExceptionGroup holding several independent exceptions;
  3. 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.

PYTHON
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: ExceptionGroup

A 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”.

PYTHON
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 raise

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

PYTHON
try:
    raise ExceptionGroup("three", [ValueError(), KeyError(), TypeError()])
except* ValueError:
    pass
# an ExceptionGroup([KeyError(), TypeError()]) propagates out

You 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

measured observationbench/exceptions/cost.py, CPython 3.13.7. The gap between “with try” and “without try” is larger than the measurement spread — so it is a real difference, and the question about it is its size, not its sign.

Now the part that gets argued about without numbers: what the construction itself costs.

form3.13.73.14.7
no try38.7 ns34.5 ns
try/except, not taken40.5 ns36.9 ns
try/except, taken201.1 ns189.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

implementation detail · CPython 3.14The PEP 765 warning belongs to one version: on 3.13 nobody notices the same code.

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

VersionChangeWhat it means for your code
3.0PEP 3134 introduces __context__ and __cause__. The chain appears on its own, without the author doing anything; raise … from sets it explicitly.
3.11PEP 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.14PEP 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

Next they ask

A return inside finally — what happens to the exception in flight?

Short answer

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.

Next they ask

When except* and when a plain except?

Short answer

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

Claim

try/except slows code down; better to check with a condition

Actually

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.

Claim

a return in finally simply overrides the return value

Actually

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.

Claim

3.14 banned return in finally

Actually

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.

Claim

else in a try is syntactic sugar; just append to the try

Actually

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.

Claim

a bare except: is the same as except Exception:

Actually

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.

Claim

raise ... from None erases the previous exception

Actually

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.

Claim

the variable from except ... as e is usable after the block

Actually

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.

Claim

an ExceptionGroup of one behaves like that one exception

Actually

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

Two functions differ by one line in finally. Both raise the same exception. What does this code print?
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

The same try/except: in one case no exception, in the other it is raised and caught. How many times more expensive is the second case?
times

Knowledge check

Question 1 of 4

What does a function return if its try raises RuntimeError and its finally has return None?

Sources & further reading

6 SOURCES

  1. 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
  2. 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/
  3. 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/
  4. 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/
  5. 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
  6. 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