Basic Exception Handling

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.

ToolPurpose
try / except
statement
Catches matching exceptions raised in the try body.
except Class as name
clause
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.
raise
statement
Re-raises the current exception inside except.
err.args
attribute
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