Skip to content
intermediate

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.

Published 2026-09-11Updated 2026-09-1213 min read
A complex network of cables in a data center with a monitor in the foreground.
A complex network of cables in a data center with a monitor in the foreground. Photo by panumas nikhomkhai on Pexels.

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:

  1. Control transfers immediately. Statements after the failing line in the same block never run.
  2. The interpreter begins unwinding the call stack, looking for a try statement whose except clauses match the exception's type.
  3. If a handler matches, it runs. That handler may finish normally, raise a new exception, or re-raise the current one.
  4. Any finally clause 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 try statement is a decision about whether that value stops here, continues, or keeps moving with more context attached.

The Four Clauses and Their Trigger Conditions

A flowchart begins with the try block and branches on whether an exception occurs. Normal completion goes to else; a matching exception goes to except; an unmatched exception propagates. All paths then pass through finally before the final outcome.
`else` is success-only, `except` requires a matching type, and every exit path gets a chance to run `finally`.

Here is the decision table I keep in my head. It is the single most useful artifact in this article.

ClauseRuns whenDoes not run when
tryAlways, firstNever skipped
exceptA matching exception was raised inside trytry completed normally, or the exception type did not match
elsetry completed with no exceptionAny exception was raised, handled or not
finallyOn every exit path, before the construct is leftNever — it always gets a chance to run

A few consequences fall directly out of that table:

  • else is a success-only branch. It does not run after a handled exception. It is not "the code after the except."
  • finally runs even when the exception is unhandled and the program is about to die. It runs on return, break, and continue too.
  • Multiple except clauses 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 in else. If you cannot name what the try block is guarding in one sentence, it is guarding too much.

Knowledge check

Check your understanding

Answer this question before you continue.

A function should catch `ValueError` from `parse(raw)`, but any `ValueError` from `audit_log.write(record)` must propagate unchanged. Which structure best achieves that goal?
Scenario Interpretation

Focus: Place success-dependent follow-up work outside the protected operation so failures are attributed correctly.

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.

What exception reaches the caller when this function is called?
Output Prediction

Focus: Predict how an exception raised in `finally` changes the exception that reaches the caller.

```python
def load(path):
    try:
        raise ValueError("bad config")
    finally:
        raise RuntimeError("cleanup failed")
```

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.

A boundary should expose `InvalidRequest` to its caller while preserving that invalid JSON caused the failure. Which statement best matches the article's recommended pattern?
Comparison Reasoning

Focus: Choose explicit exception wrapping when translating a lower-level failure while preserving its diagnostic cause.

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.

In the article's `ingest` function, if `parse(raw)` raises `TypeError` and no `except` clause matches, which outcome is correct?
Output Prediction

Focus: Trace clause execution when an exception raised in `try` matches no handler.

A Checklist for Placing Logic in the Right Clause

When you are writing or reviewing a try statement, run these five questions in order.

  1. What is this try guarding? If the answer is "several unrelated things," split it. One guarded operation per try keeps the except clauses honest.
  2. Can the handler actually recover? If not, it should re-raise or wrap with context — not return a default, not log and continue.
  3. Does the follow-up work depend on success? If yes, it belongs in else. If it must run regardless, it belongs in finally.
  4. Must the cleanup run on every path? If yes, finally or a context manager. If the cleanup is tied to a resource's lifetime, prefer with. And if the cleanup itself can raise, decide deliberately whether that failure should replace the original.
  5. 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.

Why does the article warn against using `except Exception` to return a default for every failure?
Question 1 of 2Misconception Check

Focus: Recognize why broad exception handling can hide programming bugs and create silent wrong behavior.

A handler cannot recover from an exception and has no safe default. According to the article's decision rule, what is the honest move?
Question 2 of 2Scenario Interpretation

Focus: Select propagation behavior when an exception handler cannot genuinely recover from the failure.

References

  1. Python Exceptions: An Introduction – Real Pythonrealpython.com
8sources checked
7source domains
10searches run

Research updated Sep 11, 2026

Related sites

Build the foundations behind advanced AI systems

Use LearnLLMFast for practical LLM application foundations and LearnPyFast for the Python mechanisms that support implementation work.

LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast
Python tutorialstutorial

LearnPyFast

Beginner-friendly Python tutorials, examples, and learning paths for practical programming foundations.

PythonProgrammingBeginners
Visit LearnPyFast

Keep exploring

Related AI engineering tutorials

Continue with adjacent system layers, implementation patterns, and current AI engineering ideas.