Software Engineering

Python Try Except: Complete Error Handling Guide

How Python try/except/else/finally actually work — the exact order blocks run, catching multiple exceptions, except ... as e, raising and re-raising, with context managers, and why finally always runs. With a control-flow diagram and a runs-when table.

By Inventive HQ Team

Python's try/except statement runs the code in the try block and, if it raises an exception, hands control to the first matching except block instead of crashing. Add else for code that should run only when no exception occurred, and finally for cleanup that must run either way. The order is fixed: the try body runs first; if it raises, Python looks for a matching except; if it does not raise, the else runs; and the finally block always runs last, exception or not — even if the function returns or re-raises from inside try. That is the whole model.

That's the summary an AI overview can give you. What it can't give you is the part that actually trips people up: the exact order those four blocks run in every branch, why finally runs even after a return, how except ... as e binds and then deletes the error, when to re-raise versus translate an exception, and how with replaces most of your try/finally boilerplate. This guide covers all of it with correct Python 3 code, a control-flow diagram, and a table you can keep next to your editor.

The control flow, visualized

Every try statement follows the same path. The try body runs; then exactly one of two things happens — it raised, or it didn't — and the two paths reconverge at finally before control leaves the statement.

Control flow of Python try / except / else / finally The try block runs first. If it raises an exception, a matching except block handles it; if it raises nothing, the else block runs. Both paths then reach the finally block, which always runs before control leaves the statement. One try statement, one guaranteed path through it try: the risky code Exception raised? yes no except Err as e: handle the error else: runs only on success finally: ALWAYS runs — cleanup goes here control leaves the statement — code continues, or the exception propagates

Which block runs when

The four keywords answer four different questions. This is the reference to keep next to your editor:

BlockRuns whenTypical use
try:Always — it wraps the code being attemptedThe operation that might fail
except X:Only if the try body raised X or a subclass of XRecover from that specific failure
except X as e:Same, and binds the exception object to eInspect, log, or re-raise the error
else:Only if the try body raised nothingSuccess-path code you keep out of try
finally:Always — success, handled error, unhandled error, or a return/break/continue inside tryCleanup: close files, release locks, roll back

A minimal example that exercises every branch:

try:
    value = int(user_input)        # might raise ValueError
except ValueError as e:
    print(f"Not a number: {e}")    # runs only on failure
else:
    print(f"Parsed {value}")       # runs only on success
finally:
    print("Done")                  # runs every time

Two subtleties worth internalizing. First, else exists so you can separate "code that might raise" from "code that runs after success." If you put the success code inside try, an exception it raises would be caught by your except — usually not what you want. Second, finally runs even when try contains a return; Python holds the return value, runs finally, then returns. If finally has its own return, it wins and silently discards the earlier value or exception — a classic footgun.

Catching the right exceptions

Every except matches an exception type and its subclasses, so ordering and specificity matter.

def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        return default_config()             # specific: missing file is fine
    except json.JSONDecodeError as e:
        raise ConfigError(f"{path} is not valid JSON") from e
    except OSError as e:                     # broader: permissions, disk, etc.
        logger.error("Cannot read %s", path, exc_info=e)
        raise

Python checks except clauses top to bottom and runs the first one that matches. Because FileNotFoundError is a subclass of OSError, listing OSError first would swallow the missing-file case before the specific handler ever saw it. Always order specific exceptions before their parents.

To handle several unrelated types the same way, group them in a tuple:

try:
    payload = parse(raw)
except (ValueError, TypeError, KeyError) as e:
    raise BadRequest(f"Malformed payload: {e}") from e
Advertisement

except Exception vs. a bare except

A bare except: catches everything, including KeyboardInterrupt (Ctrl-C) and SystemExit — the signals meant to stop your program. except Exception: catches only ordinary errors and lets those control signals propagate. Prefer a specific type; fall back to except Exception: only for a genuine top-level catch-all (a request handler, a worker loop), and never a bare except:.

The exception hierarchy

Knowing the class tree tells you what a given except will and won't catch. Everything descends from BaseException; almost everything you should catch descends from Exception:

BaseException
 ├── SystemExit               # raised by sys.exit()
 ├── KeyboardInterrupt        # Ctrl-C
 ├── GeneratorExit
 └── Exception                # catch THIS branch, not BaseException
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── OSError              # aka IOError
      │    ├── FileNotFoundError
      │    ├── PermissionError
      │    └── TimeoutError
      ├── ValueError
      ├── TypeError
      └── RuntimeError

SystemExit and KeyboardInterrupt sit outside Exception on purpose — so except Exception: won't accidentally trap them. For your own errors, subclass Exception (or a fitting built-in) and build an application-specific hierarchy:

class InventoryError(RuntimeError): ...
class ItemNotFound(InventoryError): ...
class InventoryCapacityExceeded(InventoryError): ...
class InventoryPersistenceError(InventoryError): ...

Now callers can catch the umbrella InventoryError for generic handling, while specific handlers distinguish ItemNotFound (return 404) from InventoryPersistenceError (return 503 and retry). Translating third-party exceptions into your own hierarchy at integration boundaries keeps implementation details from leaking into calling code.

Raising and re-raising

You raise an exception with raise. Inside an except block, three forms mean three different things:

try:
    charge_card(order)
except PaymentDeclined as e:
    # 1. Bare raise — re-raise the SAME exception, original traceback intact
    logger.warning("Declined for order %s: %s", order.id, e)
    raise

except GatewayTimeout as e:
    # 2. Translate — raise your own type, keep the cause chain
    raise CheckoutFailed("Payment gateway timed out") from e

except CardError as e:
    # 3. Suppress the cause — hide noisy internal detail
    raise CheckoutFailed("Could not process payment") from None

Use a bare raise to log-and-propagate: it preserves the original traceback, so the logs still point at the real source. Writing raise e instead can reset the traceback to the current line, obscuring where the error actually came from. Use raise ... from to translate a low-level error into a domain error while recording the cause — the traceback then reads "The above exception was the direct cause of the following exception." Use from None to deliberately drop the original when it would only add noise.

Two more points on except ... as e: the bound name e is deleted automatically when the except block ends (a Python 3 change that prevents reference cycles), so copy it to another variable if you need it afterward. And the exception object carries useful attributes — e.args, str(e), and e.__traceback__ — that you can inspect while handling it.

with and context managers: cleanup without the boilerplate

Most try/finally blocks exist just to guarantee a resource gets released. The with statement does that for you: a context manager defines setup and teardown once, and the teardown runs however the block exits — normal return, exception, or break.

# Verbose, easy to get wrong:
f = open("data.txt")
try:
    process(f.read())
finally:
    f.close()

# Idiomatic — the file closes no matter how the block ends:
with open("data.txt") as f:
    process(f.read())

open, sqlite3.connect, threading.Lock, and ThreadPoolExecutor are all context managers. You can write your own with contextlib.contextmanager, where the code after yield becomes the guaranteed cleanup — it is finally in disguise:

from contextlib import contextmanager

@contextmanager
def transaction(conn):
    tx = conn.begin()
    try:
        yield tx
        tx.commit()          # runs only if the block succeeded
    except Exception:
        tx.rollback()        # runs on any error, then re-raises
        raise
    finally:
        tx.close()           # always runs

with transaction(conn) as tx:
    tx.execute(insert_stmt)

You can even open several resources in one with (with open(a) as fa, open(b) as fb:) and they all close in reverse order. When a reusable context manager exists, prefer it; reach for a manual try/finally only for one-off cleanup that doesn't belong in its own object.

Anti-patterns to avoid

  • Bare except: or a lone except Exception: that hides real bugs. Catch the narrowest type that makes sense; only widen at genuine top-level boundaries.
  • Swallowing an exception silentlyexcept Exception: pass — with no log and no re-raise. Operational teams need breadcrumbs; at minimum log it.
  • Catching a parent before its child, which makes the specific handler dead code.
  • A return inside finally, which overrides — and silently discards — any exception or return value from the try body.
  • Leaking raw exception text into user-facing responses. Log the full stack trace server-side; return a sanitized message and a reference ID to the user. Attackers mine raw errors and SQL for reconnaissance.

Logging exceptions well

Turn exceptions into signals your monitoring can query:

import logging

logger = logging.getLogger(__name__)

def process_event(event: dict) -> None:
    try:
        handler.handle(event)
    except ValidationError as e:
        logger.warning("Validation failed for %s: %s", event["id"], e)
    except Exception:
        # logger.exception() records the full traceback automatically
        logger.exception("Unexpected failure handling %s", event["id"])
        raise

logger.exception() (or logger.error(..., exc_info=True)) captures the full traceback — reserve it for genuinely unexpected errors so you don't drown responders in alerts for handled, expected conditions.

Testing your error paths

The unhappy path deserves the same test coverage as the happy path:

import pytest

def test_rejects_bad_input():
    with pytest.raises(ValueError, match="not a number"):
        parse_amount("abc")
  • Unit tests assert that the expected exception is raised (pytest.raises) and that handlers transform data correctly.
  • Integration tests use mocks that raise ConnectionError/TimeoutError to confirm retries and alerts actually fire.
  • Fault injection in staging (kill Redis, drop the network) verifies the failover path holds under load.

Key takeaways

  • The order is fixed: try runs, then either a matching except or else runs, and finally runs last — always.
  • Catch the narrowest exception that fits, and list specific types before their parents.
  • Use a bare raise to re-raise with the original traceback; use raise ... from to translate while keeping the cause.
  • except Exception as e binds the error to e for the duration of the block, then deletes it.
  • Prefer with over manual try/finally — a context manager guarantees cleanup and removes the boilerplate.
  • Test the failure paths as thoroughly as the success path.

Frequently Asked Questions

What is the difference between try, except, else, and finally in Python?

The try block wraps code that might raise an exception. An except block runs only if the try body raised a matching exception, and handles it. The else block runs only if the try body raised nothing — it holds the success-path code you want kept out of the try. The finally block runs no matter what happened: exception or clean run, and even when the function returns or re-raises. So try is the risky code, except is the recovery path, else is the success path, and finally is the guaranteed cleanup path.

Does finally always run in Python?

Yes. The finally block runs whether the try body succeeded, raised a handled exception, raised an unhandled exception, or hit a return, break, or continue. That guarantee is exactly why finally is used for cleanup — closing files, releasing locks, committing or rolling back transactions. The only ways to skip it are events that stop the interpreter itself, such as os._exit() or the process being killed. Note that if finally itself executes a return, it overrides any exception or return value from the try block, which is a common source of silently swallowed errors.

What does except Exception as e do?

It catches any exception that is an instance of Exception (or a subclass) and binds the caught exception object to the name e, so you can inspect it — read str(e) for the message, e.args for the arguments, or e.traceback for the traceback. In Python 3 the name e is deleted automatically when the except block ends, so you cannot reference it afterward; assign it to another variable first if you need it later. Prefer catching a specific type over bare Exception whenever you can.

How do I catch multiple exceptions in Python?

Two ways. Use separate except clauses when each error needs different handling — Python checks them top to bottom and runs the first one that matches, so list more specific exceptions before their parent classes. Or group types that share handling into a single clause with a tuple: except (ValueError, TypeError) as e. Since Python 3.11 you can also catch groups of simultaneous errors with except* and ExceptionGroup, mainly used with concurrent tasks.

How do I re-raise an exception after catching it?

Use a bare raise statement with no arguments inside the except block. It re-raises the exception currently being handled and preserves the original traceback, so the log still points at the real source. Writing raise e instead technically works but can truncate or reset the traceback, so prefer the bare form. When you want to translate a low-level error into your own type while keeping the cause, use raise MyError(...) from exc, which sets the cause chain.

What is the difference between except: and except Exception:?

A bare except: catches everything, including BaseException subclasses like KeyboardInterrupt (Ctrl-C) and SystemExit — so it can trap the very signals meant to stop your program. except Exception: catches only ordinary errors that inherit from Exception and lets KeyboardInterrupt and SystemExit propagate normally. Always prefer except Exception: or a specific type; a bare except: is almost always a bug.

Should I use try/except or if/else to handle errors?

Python idiom favors EAFP — Easier to Ask Forgiveness than Permission — meaning you try the operation and catch the exception if it fails. This avoids race conditions (the thing you checked can change before you use it) and is often faster on the success path. Use LBYL — Look Before You Leap, an if/else guard — when a check is cheap and unambiguous, or when raising and catching would be more expensive than testing a condition. For file and network operations, EAFP with try/except is usually the safer choice.

What does raise ... from do in Python?

raise NewError(...) from original_exc explicitly chains exceptions: it raises NewError while recording original_exc as its cause, so the traceback shows 'The above exception was the direct cause of the following exception.' This is how you translate a third-party or low-level error into your own domain exception without losing the original context. Use raise NewError(...) from None to deliberately suppress the original cause when it would only add noise.

When should I use a with statement instead of try/finally?

Use with whenever a resource has a defined setup and teardown — files, sockets, locks, database sessions. A with statement is backed by a context manager whose exit method runs on the way out no matter how the block ends, so it is a cleaner, reusable replacement for a try/finally that just closes something. Reach for a manual try/finally only when there is no context manager available, or when your cleanup logic is one-off and does not belong in a reusable object.

python try excepttry except pythonpython error handlingpython exceptionsexception handling
Advertisement