Python Exceptions and Error Handling: `try`, `except`, `else`, `finally`, and `raise`
A try statement is not a safety net. It is a four-clause control-flow machine, and most production bugs come from putting logic in the wrong slot.

Key topics
A try statement is not a safety net. It is a four-clause control-flow machine, and most production bugs come from putting logic in the wrong slot.
I have watched a data pipeline log record handled for three weeks while quietly dropping every malformed row. The code looked defensive. It had a try, an except, and a log line. What it did not have was a correct model of which clause runs when — so the handler swallowed a schema bug, returned a default, and let the pipeline march forward on missing data.
That is the failure this article is about. Not syntax. Control flow. By the end, you should be able to trace any try statement clause by clause, predict exactly which block executes for a given failure, and place recovery, cleanup, and propagation logic in the clause that actually owns that responsibility.
What Actually Happens When a Line Raises
Before the clause syntax matters, you need the runtime mechanism. An exception is an object — a value with a type, a message, and often extra attributes. raise hands that object to the interpreter, which then has to decide where execution continues.
When a line raises, four things happen in order:
- Control transfers immediately. Statements after the failing line in the same block never run.
- The interpreter begins unwinding the call stack, looking for a
trystatement whoseexceptclauses match the exception's type. - If a handler matches, it runs. That handler may finish normally, raise a new exception, or re-raise the current one.
- Any
finallyclause on the way out gets its chance to run, and the final outcome is whatever survives that step.
Matching is by type, not by message text. except ValueError matches ValueError and its subclasses. It does not match because the string "invalid literal" appeared somewhere. This distinction matters the moment you are tempted to write except Exception as e: if "timeout" in str(e): ... — you are pattern-matching on prose, and prose changes between library versions.
The traceback is the record of the path the exception took: each frame, each call site, the line that raised. Treat it as evidence, not noise. When someone pastes a traceback into a chat and says "it's broken," they have usually already handed you the answer.
An exception is a value that travels. Every clause in a
trystatement is a decision about whether that value stops here, continues, or keeps moving with more context attached.
The Four Clauses and Their Trigger Conditions
Here is the decision table I keep in my head. It is the single most useful artifact in this article.
| Clause | Runs when | Does not run when |
|---|---|---|
try | Always, first | Never skipped |
except | A matching exception was raised inside try | try completed normally, or the exception type did not match |
else | try completed with no exception | Any exception was raised, handled or not |
finally | On every exit path, before the construct is left | Never — it always gets a chance to run |
A few consequences fall directly out of that table:
elseis a success-only branch. It does not run after a handled exception. It is not "the code after the except."finallyruns even when the exception is unhandled and the program is about to die. It runs onreturn,break, andcontinuetoo.- Multiple
exceptclauses are tested top to bottom. The first matching type wins, and the rest are never evaluated.
The ordering rule is where people get burned. This is wrong:
try:
config = load_config(path)
except Exception as exc:
log.error("config load failed: %s", exc)
except FileNotFoundError:
config = DEFAULT_CONFIG
The second clause is dead code. FileNotFoundError is a subclass of Exception, so the first clause always wins. Specific types must precede their base classes:
try:
config = load_config(path)
except FileNotFoundError:
config = DEFAULT_CONFIG
except Exception as exc:
log.error("config load failed: %s", exc)
raise
Why else Exists: Separating the Attempt From the Success Path
The most common question I get about this syntax is why else is not redundant. Why not just put the follow-up code at the end of try?
Because code at the end of try is inside the protected region. Any exception it raises will be caught by your own except clause — and misattributed to the attempt.
Here is the bug in miniature:
try:
record = parse(raw)
audit_log.write(record) # inside the guard
except ValueError as exc:
metrics.increment("parse_failed")
return None
If audit_log.write raises a ValueError — a bad path, a serialization problem, anything — you will increment parse_failed and return None. The parse succeeded. The audit write failed. Your metric lies, and the caller gets a silent None instead of an error.
Move the follow-up work into else and the failure surfaces where it belongs:
try:
record = parse(raw)
except ValueError as exc:
metrics.increment("parse_failed")
return None
else:
audit_log.write(record) # outside the guard; failures propagate
return record
Now a ValueError from audit_log.write travels up the stack with its real traceback. The except clause only ever sees failures from parse.
My rule: put only the operation you intend to guard in
try. Put dependent follow-up work inelse. If you cannot name what thetryblock is guarding in one sentence, it is guarding too much.
Knowledge check
Check your understanding
Answer this question before you continue.
finally, Cleanup, and the Exit-Path Guarantee
finally is the clause that runs no matter what. Normal completion, handled exception, unhandled exception, return, break, continue — it runs. That guarantee is exactly why it is dangerous when misused.
The precise rule is worth stating carefully, because "always runs" hides the part that bites people. finally executes while the construct is being left, before the pending outcome — a return value or an in-flight exception — is finalized. If finally itself raises or returns, that new outcome replaces the pending one. The original exception or return value is gone.
Two failure modes follow from that.
A raise inside finally replaces the in-flight exception. If the original exception was the interesting one and your cleanup code throws, the traceback you see is the cleanup failure. The original cause is gone. This is a real source of lost diagnostics in services that close connections or flush buffers in finally.
A return inside finally overrides everything. This is legal Python and almost always a mistake:
def fetch(url):
try:
return client.get(url)
finally:
return None # swallows the return value AND any exception
That function always returns None. It also silently discards any exception raised by client.get. If you ever see a return in a finally, treat it as a bug until proven otherwise.
Here is the same trap with an exception, so you can see the replacement happen:
def load(path):
try:
raise ValueError("bad config")
finally:
raise RuntimeError("cleanup failed")
Run it and the traceback names RuntimeError, not ValueError. The ValueError is still attached as __context__, but the exception that reaches the caller is the cleanup failure. If you were relying on the original to diagnose the problem, you just lost the thread.
The operational rule: keep finally narrow, make it idempotent where you can, and do not let it raise or return unless you have deliberately decided that the cleanup failure is the more important signal. If cleanup errors need their own handling, handle them explicitly — wrap the cleanup body in its own try and log there — rather than letting them silently replace the primary failure.
For state you must restore — a flag, a lock, a temp file — finally is the right tool. For resource ownership, a context manager is usually better, because it makes the setup and teardown a single named unit and handles the exception path for you. The with statement is its own topic; the bridge is simply this: if the cleanup is tied to an object's lifetime, reach for with; if it is tied to a block of logic, finally is fine.
Knowledge check
Check your understanding
Answer this question before you continue.
raise: Re-Raising, Wrapping, and Preserving the Cause
raise is a control-flow decision, not just a way to create errors. Three forms matter.
Bare raise re-raises the currently handled exception and preserves the original traceback:
try:
process(batch)
except TransientError:
metrics.increment("retry")
raise # same exception, same traceback, one level up
This is the correct way to log-and-propagate. You get your metric without destroying the evidence.
raise NewError(...) from exc wraps the original and makes the causal chain explicit. The traceback shows both exceptions and labels the relationship:
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise InvalidRequest("body is not valid JSON") from exc
The reader of that traceback sees InvalidRequest caused by JSONDecodeError, with the original line and message intact.
raise ... from None deliberately suppresses the chain. Use it when the original is genuinely noise — for example, an internal KeyError that you are converting into a clean domain error at an API boundary. Do not use it when the original is the only diagnostic you have.
If you raise inside an except without from, Python still records the original as __context__. The relationship is implicit and easier to misread in a long traceback. I prefer explicit from in almost every case, because the traceback is a document someone will read at 2 a.m.
Catch only what you can act on. If you cannot recover, re-raise or wrap with context. Returning a sentinel value instead of raising is how a bug becomes a data-quality incident.
Knowledge check
Check your understanding
Answer this question before you continue.
Matching, Ordering, and the Broad-Except Trap
Clause selection is top-to-bottom, first match wins. That single rule produces most of the ordering bugs, and the broad-except trap produces the rest.
except Exception catches nearly everything — including bugs you did not anticipate. That is the point and the problem. A TypeError from a typo in your own code becomes "handled," the function returns a default, and the wrong value flows downstream. You have converted a crash into silent wrong behavior, which is strictly worse because a crash tells you where to look.
A bare except: is worse still. It also catches KeyboardInterrupt and SystemExit, which means Ctrl-C stops working and your process refuses to shut down cleanly. I have never seen a legitimate use for a bare except in application code.
The most expensive pattern in long-lived services is this:
try:
do_work()
except Exception:
pass
Every failure disappears. No log, no metric, no traceback. The service stays up and gets quietly wrong. When you finally investigate, you have no evidence of when the problem started or what triggered it.
If you genuinely need a catch-all at a boundary — a request handler, a worker loop, a top-level main — then log the full exception with its traceback and either re-raise or convert it deliberately:
try:
handle(request)
except Exception:
log.exception("unhandled error in request handler")
raise
log.exception includes the traceback. raise preserves the failure for whatever is above you. The handler does its job: it records evidence and refuses to pretend the request succeeded.
Reading a Real Traceback Clause by Clause
Let me put the whole model on one small function. This is a pipeline step that parses a record, writes it, and cleans up a temp file.
def ingest(raw, tmp_path):
try:
record = parse(raw) # the guarded attempt
except ValueError as exc:
metrics.increment("parse_failed")
raise InvalidRecord(str(exc)) from exc
else:
write(record) # success-only follow-up
return record
finally:
cleanup(tmp_path) # runs on every path
Now walk the three scenarios.
Success. parse returns. except is skipped. else runs: write executes, return record fires. finally runs cleanup before the function actually returns. Clauses executed: try, else, finally.
Recoverable failure. parse raises ValueError. except runs: metric increments, and a new InvalidRecord is raised with the original chained. else is skipped. finally runs cleanup. The InvalidRecord propagates. Clauses executed: try, except, finally.
Unrecoverable failure. parse raises TypeError — a bug, not a data problem. No except clause matches. else is skipped. finally still runs cleanup. The TypeError propagates with its original traceback, pointing at the exact line in parse. Clauses executed: try, finally.
That third case is the one people get wrong. They assume an unmatched exception means finally is skipped. It is not. finally is unconditional.
Now the one-line change that turns a silent data-loss bug into a visible failure. Suppose the original code had write(record) at the end of the try block instead of in else. A ValueError from write — a bad path, a full disk surfaced as a value error, anything — would be caught by the except, logged as parse_failed, and re-raised as InvalidRecord. The metric blames the parser. The real problem is the writer. Moving that one line into else makes the writer's failures propagate as themselves.
One more thing to notice about this function: cleanup(tmp_path) is assumed to succeed. If it can fail, that failure will replace the InvalidRecord or the TypeError on the way out, and the caller will see a cleanup error instead of the real problem. If your cleanup can fail, wrap it and log there, or accept that the cleanup failure is the signal you want. Do not leave the choice implicit.
Knowledge check
Check your understanding
Answer this question before you continue.
A Checklist for Placing Logic in the Right Clause
When you are writing or reviewing a try statement, run these five questions in order.
- What is this
tryguarding? If the answer is "several unrelated things," split it. One guarded operation pertrykeeps theexceptclauses honest. - Can the handler actually recover? If not, it should re-raise or wrap with context — not return a default, not log and continue.
- Does the follow-up work depend on success? If yes, it belongs in
else. If it must run regardless, it belongs infinally. - Must the cleanup run on every path? If yes,
finallyor a context manager. If the cleanup is tied to a resource's lifetime, preferwith. And if the cleanup itself can raise, decide deliberately whether that failure should replace the original. - What evidence does the failure leave behind? If the answer is "nothing," the handler is hiding a bug. Log the traceback, increment a metric, or re-raise.
The checklist is short on purpose. Most exception-handling bugs are not subtle; they are a clause doing a job that belongs to a different clause.
The Honest Move
An except clause is a promise that you know how to continue. If you cannot keep that promise, the honest move is to let the exception travel — with its traceback intact, with its cause attached, with enough context for the next reader to understand what actually broke.
So here is the concrete next action. Open your own codebase and find one broad except. Ask the five questions. Then do exactly one of three things: narrow it to the specific exception you can handle, re-raise it with context so the failure stays visible, or delete it and let the exception propagate. Pick the one that matches what the handler can actually promise.
The natural next step is designing exception boundaries for a larger system — custom exception hierarchies, where to convert infrastructure failures into domain failures, and how to aggregate multiple failures without losing the originals. That is a design problem, and it starts from the clause semantics you now have.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


