Two lines of code, both of which look defensive and both of which are catastrophic.
try:
db.write(record)
except Exception:
pass
That is data loss. Not “a risk of data loss” — data loss, happening right now, silently, at whatever rate your write path fails. Nobody discovers it until a reconciliation job runs a month later and the numbers do not add up.
while True:
try:
process(queue.get())
except:
log.error("task failed")
That is a worker that cannot be stopped. Ctrl-C does nothing. SIGTERM
during a deploy does nothing. The pod hangs until the orchestrator escalates to
SIGKILL, mid-write.
Both come from the same misunderstanding, so start there.
except: catches BaseException
The bare form is not “catch errors”. It is except BaseException:, and the
difference is a small set of classes that exist specifically to be outside
Exception:
| class | what it means | why swallowing it is fatal |
|---|---|---|
KeyboardInterrupt |
the operator pressed Ctrl-C | your process ignores the operator |
SystemExit |
sys.exit() is unwinding |
clean shutdown becomes an infinite loop |
GeneratorExit |
a generator is being closed |
a RuntimeError on the next yield |
asyncio.CancelledError |
a task was cancelled | timeouts and shutdown silently stop working |
That last one is the sharpest. CancelledError was moved from Exception to
BaseException in Python 3.8 for exactly one reason: so that the enormous
amount of existing except Exception code in the world would not eat
cancellations. If you write except BaseException in async code, you have
undone that decision for your own codebase.
💡A except Exception: pass around a metrics call seems harmless — you do not want a broken StatsD socket to take down a request. What is still wrong with it?
click to reveal
The pass, not the except Exception.
Deciding that metrics failures must not break the request is a legitimate, well-reasoned choice. But pass means nobody will ever know your metrics have been dark for three weeks, and the day you actually need that dashboard it will be empty.
The correct spelling logs and moves on:
try:
statsd.increment("charge.ok")
except Exception:
log.warning("metrics emit failed", exc_info=True)
exc_info=True is the part people skip, and it is the part that matters — without it you get the string “metrics emit failed” with no type, no message and no traceback, which is barely better than pass.
There is a second, subtler problem: a broad catch here will also swallow a NameError from a typo in the line above it, and it will look identical to a socket failure. If you can name the failure you are tolerating — except (socket.error, OSError) — name it. Breadth is a last resort, not a default.
PEP 760 was withdrawn: this is a linter’s job, forever
PEP 760, “No More Bare Excepts”, proposed making except: a syntax error.
It was withdrawn. Bare except: is legal Python and always will be, so
there is no future release that will save you from it. Enforcement lives in
your linter and nowhere else:
-
E722— bareexcept:. -
BLE001— blindexcept Exception:(ruff’sflake8-blind-except).
Turn both on. BLE001 will fire at your legitimate boundaries too, and that is
fine: a boundary is a place where you write a comment explaining why the catch
is broad, and # noqa: BLE001 with that comment next to it is a much better
artefact than an unremarked blanket catch. Make the exception explicit and
rare.
The real question is where, not what
Most advice about exception handling is about which class to catch. The more consequential decision is where in the call stack to catch anything at all.
Narrowly, near the raise — when you can recover. You know which exception, you know what it means, and you have something better to do than propagate:
try:
return cache[key]
except KeyError:
return compute_and_store(key)
Three lines, one exception class, an obvious recovery. This is the good case and it should be the common one.
Broadly, at a boundary — when you cannot. A boundary is a place where
“this unit of work failed” is a meaningful, isolatable outcome: an HTTP request
handler, a worker loop iteration, a scheduled job, main(). There, one blanket
except Exception that logs with exc_info=True and converts the failure into
an error response is not sloppy — it is required. A web server that lets one
malformed request kill the process is worse than one that catches too much.
Nowhere else. The middle of the stack should mostly not catch. A function that catches an exception it cannot act on, logs it, and re-raises has produced one duplicate log line per layer and helped nobody.
💡How do you tell a genuine boundary from a middle-of-the-stack function that has decided it is one? click to reveal
Ask what happens to the unit of work when the handler runs.
At a genuine boundary, the unit of work is complete — abandoned, but complete. The request gets a 500 and the connection closes. The queue message goes to the dead-letter queue. The job is marked failed and the next one starts. There is a well-defined next action and no partially-mutated state hanging around.
In the middle of the stack, catching leaves the caller holding a half-finished thing it thinks succeeded. That is the tell: if after your except block the function returns a value that its caller cannot distinguish from success, you are not at a boundary, you are hiding a failure.
The second tell is the log line. A boundary logs once, with full context — request id, user, elapsed time — because it is the only place that has that context. A middle layer logging “something failed” adds a line to the incident and no information to it.
Order matters, and except Exception last
Python takes the first matching clause, top to bottom. So this:
try:
...
except Exception:
...
except TimeoutError: # dead code
...
never reaches the second clause. Ruff catches the obvious cases; the ones it misses are the ones where the relationship between two classes is not visible in the file. Order specific to general, always.
The 3.14 trap: return inside finally
New in Python 3.14, PEP 765: a return inside a finally block emits a
SyntaxWarning. The reason is that it silently discards any exception that was
in flight:
def read(path):
try:
return open(path).read()
finally:
return "" # swallows the FileNotFoundError entirely
The finally clause runs while the FileNotFoundError is unwinding; the
return completes the function normally and the exception simply evaporates.
The caller gets "" and no indication that anything went wrong. This has been
a bug factory for thirty years and is now, finally, noisy.
If you are on 3.12 or 3.13 there is no warning — but the behaviour is the same, so treat “returns from a finally” as a review blocker regardless of version.
The checklist
-
Never bare
except:. It catches Ctrl-C. -
except Exceptiononly where “this unit of work failed” is a complete outcome — and always withexc_info=True, never withpass. - Specific classes near the raise, where you can actually recover.
- Specific clauses before general ones.
-
Never
returnout of afinally. -
Turn on
E722andBLE001, and treat each suppression as a place that owes the reader a sentence.