skip to content

Why does a `break` or `continue` inside a Python `finally` block silently swallow an exception?

level: middleimportance: should knowfreq 30%

answer

  1. The loop keeps going, quietly
  2. Something is held during the block
  3. Re-raise happens only on normal completion
  4. Control transfer replaces the pending exception
  5. PEP 765 warns only on boundary crossings

basics

~20 s

A finally block runs while the exception is still in flight and re-raises it only when the block ends normally. A break or continue leaves the block early, so the held exception is dropped and the loop simply carries on.

solid answer

~40 s

When a `try` block raises, the interpreter holds the exception, runs `finally`, and re-raises it only if `finally` completes normally. `break` and `continue` transfer control out of the block, so the held exception is discarded — no traceback, no chaining, no log line. It is worse than a `return` in `finally` because the loop keeps running and produces a plausible partial result: `for line in lines: try: kept.append(int(line)) finally: continue` quietly skips every unparseable line. `continue` in `finally` was a `SyntaxError` before Python 3.8; since 3.14 the compiler warns about all three statements under PEP 765, but only when they exit the `finally` block — a loop written wholly inside `finally` may `break` freely. Put the `continue` in an `except` clause instead.

code

python · 11 lines
python
def parse_batch(lines):
    kept = []
    for line in lines:
        try:
            kept.append(int(line))
        finally:
            continue
    return kept


print(parse_batch(["1", "oops", "3"]))  # [1, 3] -- the ValueError vanished

go deeper

for a junior

Know that finally always runs, and that jumping out of it with break or continue makes an exception disappear. Recognising the shape in a code review is enough at this level.

for a middle

Explain the mechanics: the exception is held during finally and re-raised only on normal completion, so any control transfer out of the block replaces it. Be ready to predict the output of a small loop that does this.

for a senior

Talk about how the bug arrives — refactoring a continue out of an except clause into cleanup — and why tests miss it: only failing iterations change, from loud to invisible. Name the 3.14 warning and how you would enforce it.

for a principal

Frame it as a silent-failure class rather than a keyword quirk: which layers in your services are permitted to absorb exceptions at all, and how you make swallowing explicit and auditable across a codebase and its lint gates.

`break` and `continue` inside `finally` are the loop-shaped version of `return` inside `finally`, and they lose exceptions in exactly the same way — but they are harder to spot, because the code keeps looping and looks like it is working. ### Why the exception disappears When an exception is raised inside a `try` block that has a `finally` clause, the interpreter does not propagate it immediately. It holds the exception, runs the `finally` body, and re-raises the held exception **only if the `finally` body finishes normally**. Any statement that leaves the `finally` block early replaces that pending outcome with its own. `break` says "leave this loop"; `continue` says "go to the next iteration". Either instruction is honoured, and the exception the interpreter was holding for you is dropped on the floor — no traceback, no `sys.excepthook`, no chained `__context__`. ```python def parse_batch(lines): kept = [] for line in lines: try: kept.append(int(line)) finally: continue return kept parse_batch(["1", "oops", "3"]) # [1, 3] ``` The `ValueError` from `int("oops")` is real; the `continue` throws it away. The function returns a plausible-looking result, and the only symptom is that some records silently do not arrive. This is worse than the `return` case in practice, because a `return` at least ends the function, whereas a `continue` lets the loop keep running and produces partial output that nobody flags. ### Which statements are affected, and since when * `break` and `return` have always been legal inside `finally`. * `continue` inside `finally` was a **`SyntaxError` until Python 3.8**, which lifted the restriction; from 3.8 on it compiles and behaves as described above. * Python **3.14** implements PEP 765: the compiler emits a `SyntaxWarning` — `'break' in a 'finally' block`, `'continue' in a 'finally' block`, `'return' in a 'finally' block` — for any of the three when it would transfer control *out of* the `finally` block. That scoping matters. A loop written entirely inside the `finally` body may `break` freely, because that `break` targets the inner loop and never crosses the block boundary: ```python def close_all(handles): try: raise ValueError("batch failed") finally: for h in handles: # loop lives inside finally if h is None: break # not flagged; does not exit the finally print("closing", h) ``` Here the `ValueError` still propagates normally after the cleanup loop ends, because the `finally` body itself completed. The warning targets the boundary crossing, not the keyword. The 3.14 warning is emitted at compile time, so it appears even for branches that never execute, and it is a warning rather than an error — behaviour is unchanged, nothing that worked on 3.13 breaks. In CI it is worth promoting: `python -W error::SyntaxWarning` (or `PYTHONWARNINGS=error::SyntaxWarning`) turns it into a compile-time `SyntaxError`, which is where PEP 765 hints the language may eventually go. ### How the bug gets written Almost nobody types `continue` into a `finally` block believing it swallows exceptions. It arrives by refactoring. Someone writes a per-item loop with a `try`/`except`/`continue` to skip bad items, later adds a `finally` for cleanup, and at some point the `continue` migrates into the cleanup block — often while moving a `logging` call around, or when an `except` clause is deleted because "the `finally` handles it anyway". The compiler was silent about it before 3.14, and tests rarely catch it because the happy path is unaffected: only the failing iterations change behaviour, and they change from "loud" to "invisible". Code review misses it for a related reason. The `finally` keyword reads as "cleanup", so the eye skips the block looking for the interesting logic, and a single `continue` sitting under it does not look like a control-flow decision at all. Reviewers who have seen the pattern once tend to catch it forever, which is exactly the argument for turning the 3.14 warning into a gate rather than relying on attention. ### The correct shape Decide the loop's control flow where the failure is actually classified — in an `except` handler — and leave `finally` for cleanup that has no opinion about whether the iteration succeeded: ```python for line in lines: try: kept.append(int(line)) except ValueError: logging.warning("skipping unparseable line %r", line) continue # deliberate, and typed finally: release(line) # cleanup only ``` This version says out loud which exception is being tolerated, keeps the cleanup unconditional, and lets anything unexpected — a `MemoryError`, a `KeyboardInterrupt` — propagate as it should. It is also the version a reviewer can check: the set of swallowed exceptions is written in the `except` clause instead of being "everything, implicitly, because of one keyword three lines below".

  • Which Python version first allowed `continue` inside a `finally` block at all?
    Python 3.8. Before that the compiler rejected it with a `SyntaxError`, while `break` and `return` in `finally` were always accepted — an inconsistency, since all three lose the pending exception the same way. Python 3.14 closed the gap from the other direction: under PEP 765 all three now produce a compile-time `SyntaxWarning` when they exit the block.
  • Does Python 3.14 warn about a `break` belonging to a loop written entirely inside the `finally` block?
    No. PEP 765's warning targets statements that transfer control *out of* the `finally` block. A `break` that binds to a loop declared inside the block never crosses that boundary, the block still completes normally, and the held exception is re-raised as usual. The same applies to a `return` inside a nested function defined in the `finally` body.
  • How would you rewrite a loop that used `continue` in `finally` to skip bad records?
    Move the `continue` into an `except` clause naming the exception you are willing to tolerate, log it there, and leave `finally` for cleanup only. That makes the swallowed set explicit and reviewable, keeps the cleanup unconditional, and lets anything unexpected — a `MemoryError`, a `KeyboardInterrupt` — propagate instead of being absorbed by a keyword three lines below.

The exception is a message the interpreter is holding to deliver once cleanup finishes. break and continue send the courier somewhere else mid-errand, and the message is never delivered or filed.

saying these in an interview costs you the question

  • Thinks the exception is re-raised after the loop ends
  • Believes Python 3.14 forbids continue inside finally
  • Says the interpreter logs the exception before dropping it
  • Confuses this with an except clause swallowing the error
  • Assumes the SyntaxWarning stops the program running
  • Claims break in finally has always been a SyntaxError

context