The try/except statement is the fundamental building block of error handling. The try clause holds the code that might fail; each except clause declares a class (or a tuple of classes) to match and an optional name under which you can inspect the exception. The whole block executes in order: try first, then the first matching except, then (if there is one) else when no exception was raised, and finally no matter what happened.
The shortest useful form is try: ... except SpecificError: .... Pick a specific class: ValueError for bad user input, KeyError for a missing dict entry, FileNotFoundError for a missing file. Catching Exception is sometimes appropriate at the outermost boundary of an application (to log and return a user-friendly message), but it is almost always wrong deep inside your code.
Bind the exception to a name with as err if you need to inspect it: err.args, str(err), type(err).__name__, or any attribute the exception defines. When you print or log an exception, include at least the message and the class so the reader can tell which failure occurred.
Use else when there is a step you want to run only if no exception was raised (for example, commit a transaction after a successful write). Use finally for cleanup that must happen either way (closing a resource, restoring global state). These extra clauses are rarely needed, but when they fit the problem, they make the intent very clear.
The four clauses and their order
The order is fixed: try, then zero or more except, then an optional else, then an optional finally. Except clauses are tested top to bottom; the first matching one wins. Put the most specific classes first: except FileNotFoundError before except OSError.
A single except clause can match multiple classes with a tuple: except (ValueError, TypeError) as err:. The bound name err is the actual exception, whatever its class. If you don't need the value, omit the as part entirely.
When to recover and when to re-raise
Recover when you have a plan: a default value, a retry, a different code path. Log a short message, return a sensible fallback, and move on. Never pass silently: at least log type(err).__name__ and the relevant input.
Re-raise when the current function cannot make a decision. The plain raise statement inside an except re-raises the same exception with its original traceback preserved. For added context, use raise MyError(...) from err.
The try/except clauses and their roles.
| Tool | Purpose |
|---|---|
try / exceptstatement | Catches matching exceptions raised in the try body. |
except Class as nameclause | Binds the exception to a local name. |
else:clause | Runs when no exception was raised. |
finally:clause | Runs whether or not an exception was raised. |
raisestatement | Re-raises the current exception inside except. |
err.argsattribute | Tuple of arguments passed to the exception. |
logging.exception()function | Log a message at ERROR including traceback. |
__cause__attribute | Set by raise ... from. |
Basic Exception Handling code example
The script walks through try/except, else, finally, and multi-class catches using a tiny parser.
# Lesson: Basic Exception Handling
def parse_int(raw: str) -> int:
try:
value = int(raw)
except ValueError as err:
print(f" [warn] not an int: {raw!r} ({err})")
return 0
else:
print(f" [ok] parsed {raw!r} -> {value}")
return value
finally:
print(" [done] attempt finished")
for raw in ("42", "oops", "3.14"):
parse_int(raw)
def safe_div(a: float, b: float) -> float | str:
try:
return a / b
except (ZeroDivisionError, TypeError) as err:
return f"error: {type(err).__name__}: {err}"
print(safe_div(10, 2))
print(safe_div(1, 0))
print(safe_div(1, "x"))
def read_until_ok(values: list[str]) -> int:
for v in values:
try:
return int(v)
except ValueError:
continue
raise ValueError("no valid integer in input")
print("first ok:", read_until_ok(["a", "b", "12"]))
As you read, notice:
1) `else` runs only when the try body completed without exceptions.
2) `finally` runs in every path: success, exception, or return.
3) Tuple-catch `except (A, B)` is the shortest way to handle a few related cases.
4) Re-raising with a blank `raise` preserves the original traceback.
Try a narrow catch and a retry loop.
def as_float(s: str) -> float | None:
try:
return float(s)
except ValueError:
return None
print(as_float("3.14")) # 3.14
print(as_float("nope")) # None
def retry(fn, attempts=3):
last_err = None
for _ in range(attempts):
try:
return fn()
except Exception as err:
last_err = err
raise RuntimeError("all retries failed") from last_err
count = {"n": 0}
def flaky():
count["n"] += 1
if count["n"] < 3:
raise RuntimeError("transient")
return "ok"
print(retry(flaky))
Check the core semantics.
def f():
try:
raise ValueError("x")
except ValueError:
return "caught"
finally:
pass
assert f() == "caught"
try:
raise KeyError("k")
except (LookupError,) as err:
assert isinstance(err, KeyError)
Running the script prints:
[ok] parsed '42' -> 42
[done] attempt finished
[warn] not an int: 'oops' (invalid literal for int() with base 10: 'oops')
[done] attempt finished
[warn] not an int: '3.14' (invalid literal for int() with base 10: '3.14')
[done] attempt finished
5.0
error: ZeroDivisionError: division by zero
error: TypeError: unsupported operand type(s) for /: 'int' and 'str'
first ok: 12