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.
Which block runs when
The four keywords answer four different questions. This is the reference to keep next to your editor:
| Block | Runs when | Typical use |
|---|---|---|
try: | Always — it wraps the code being attempted | The operation that might fail |
except X: | Only if the try body raised X or a subclass of X | Recover from that specific failure |
except X as e: | Same, and binds the exception object to e | Inspect, log, or re-raise the error |
else: | Only if the try body raised nothing | Success-path code you keep out of try |
finally: | Always — success, handled error, unhandled error, or a return/break/continue inside try | Cleanup: 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
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 loneexcept Exception:that hides real bugs. Catch the narrowest type that makes sense; only widen at genuine top-level boundaries. - Swallowing an exception silently —
except 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
returninsidefinally, which overrides — and silently discards — any exception or return value from thetrybody. - 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/TimeoutErrorto 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:
tryruns, then either a matchingexceptorelseruns, andfinallyruns last — always. - Catch the narrowest exception that fits, and list specific types before their parents.
- Use a bare
raiseto re-raise with the original traceback; useraise ... fromto translate while keeping the cause. except Exception as ebinds the error toefor the duration of the block, then deletes it.- Prefer
withover manualtry/finally— a context manager guarantees cleanup and removes the boilerplate. - Test the failure paths as thoroughly as the success path.
Related Resources
- How to Create Functions in Python 3 — where most of your
try/exceptblocks will live - Python if/else and elif Conditionals — EAFP vs. LBYL and when a guard beats a
try - Working with Datetime Objects in Python — a frequent source of
ValueErroron parsing