Errors, exceptions and files · about 40 min

Handling exceptions: try, except, else and finally

Why this matters

Some failures are not bugs but facts of life: users type "twelve" where you asked for a number, files go missing, networks drop, a key is not in a dict yet. A robust program expects these and responds sensibly: ask again, use a default, skip the bad row and report it, clean up and exit with a clear message. Python's try statement is how you do that. Used well, it makes programs resilient; used carelessly (catching everything, everywhere), it hides real bugs. This lesson covers the full statement and the judgment about what to catch.

The basic shape

try:
    n = int(input())
except ValueError:
    print("That was not a whole number.")
    n = 0
print("n is", n)

How it executes:

  1. Run the try block.
  2. If no exception happens, skip the except block entirely and continue after the statement.
  3. If an exception happens, abandon the rest of the try block at that point. If the exception's type matches the except clause (the class itself or a subclass), run the handler, then continue after the statement. The exception is now handled; the program goes on normally.
  4. If it does not match, the exception keeps propagating up the call stack, as if the try were not there.
$ echo twelve | python3 main.py
That was not a whole number.
n is 0

Underneath, CPython does not check for errors after every operation in your code. When it compiles a try block, it records an entry in an exception table attached to the bytecode, saying "if an exception is raised between these instructions, jump to this handler". When something raises, the VM consults the table for the current frame; if no entry covers the failing instruction, it pops the frame and checks the caller's table. Entering a try block therefore costs essentially nothing (Python 3.11 made this "zero-cost") when no exception occurs.

Getting the exception object: as

except SomeError as e: binds the exception object to a name for use in the handler:

try:
    value = int("3,5")
except ValueError as e:
    print("Could not convert:", e)
    print("Type:", type(e).__name__)
Could not convert: invalid literal for int() with base 10: '3,5'
Type: ValueError

str(e) (what print shows) is the message. Note that Python deletes the name e at the end of the handler, to avoid keeping the traceback's frames alive in memory. If you need the exception later, assign it to another name inside the handler.

Several handlers, and tuples

A try can have several except clauses. They are tried top to bottom; the first match wins, and only one handler runs:

def parse_ratio(text):
    try:
        a, b = text.split("/")
        return int(a) / int(b)
    except ValueError:
        return "need two whole numbers like 3/4"
    except ZeroDivisionError:
        return "denominator cannot be zero"

print(parse_ratio("3/4"))     # 0.75
print(parse_ratio("3/0"))     # denominator cannot be zero
print(parse_ratio("3"))       # need two whole numbers like 3/4
print(parse_ratio("a/b"))     # need two whole numbers like 3/4

"3".split("/") gives ["3"], and unpacking one item into a, b raises ValueError: not enough values to unpack, so the same handler covers two different problems. To handle several types identically, give a tuple:

except (KeyError, IndexError) as e:

Because handlers match subclasses, order matters: put specific exceptions before general ones. An except Exception: first would swallow everything, and the specific handlers below it would be dead code.

else: code that runs only on success

The else block runs if the try block finished without an exception:

try:
    n = int(text)
except ValueError:
    print("bad number")
else:
    print("square is", n * n)

Why not just put the print inside the try? Because then a bug in that code that happened to raise ValueError would be caught by a handler written for the conversion, and reported as "bad number". Keep the try block as small as possible: only the operation you expect might fail. Everything that depends on its success goes in else.

finally: code that always runs

The finally block runs no matter how the try statement is left: normally, by an exception that is handled, by one that is not handled (it runs, then the exception continues propagating), or by return, break or continue:

def risky():
    print("working")
    raise RuntimeError("boom")

try:
    risky()
finally:
    print("cleanup")
working
cleanup
Traceback (most recent call last):
  ...
RuntimeError: boom

finally is for cleanup that must happen: releasing a lock, closing a connection, removing a temporary file, restoring a setting. (For files, the with statement in lesson 4 does this for you, and is preferred.)

The full statement, with every part optional except try and at least one except or finally:

try:
    <code that might fail>
except SomeError as e:
    <handle that kind of failure>
else:
    <runs only if no exception>
finally:
    <always runs, last>

Catch narrowly

The most important judgment in exception handling: catch only what you expect and can deal with.

# Bad: hides every bug in the block
try:
    total = compute_total(orders)
except:
    total = 0

A bare except: catches everything, including KeyboardInterrupt (Ctrl-C stops working) and SystemExit, and also every typo: a NameError from misspelling ordres silently becomes "total is 0". except Exception: is slightly better (it lets Ctrl-C through) but still hides bugs. Name the specific exception you anticipate:

try:
    total = compute_total(orders)
except KeyError as e:
    print(f"order is missing field {e}")
    total = 0

Reasonable places for a broad except Exception exist, for example the top level of a long-running server that must log any failure and keep serving the next request. Even then, log the full error rather than discarding it.

Re-raising

Sometimes you want to react to an error (log it, add context, clean up) but still let it propagate. A bare raise inside a handler re-raises the exception currently being handled, with its original traceback:

try:
    config = load_config(path)
except OSError:
    print(f"could not read {path}", file=sys.stderr)
    raise

The caller still sees the original FileNotFoundError (or whatever it was), with the full traceback pointing at the real cause.

EAFP versus LBYL

Two styles exist for dealing with things that might not be there:

# LBYL: Look Before You Leap
if key in table:
    value = table[key]
else:
    value = None

# EAFP: Easier to Ask Forgiveness than Permission
try:
    value = table[key]
except KeyError:
    value = None

Python culture often prefers EAFP, especially when the check and the action could disagree (a file could be deleted between os.path.exists(path) and open(path), a classic race condition). For simple dict lookups table.get(key) beats both. Use the clearest option; just know that exceptions in Python are a normal control mechanism, not something reserved for catastrophes.

Common mistakes

A try block that is too big. Fifty lines inside try with except ValueError: at the end: you no longer know which line failed. Wrap only the risky call and use else.

Swallowing errors silently. except ValueError: pass throws away the information. At least log or count what was skipped.

Catching the wrong type. int("x") raises ValueError, not TypeError. int(None) raises TypeError. When unsure, trigger the error in the REPL and read the type off the last line of the traceback.

General before specific. except Exception: followed by except KeyError: means the second never runs. Python does not warn you.

Returning from finally. A return inside finally discards any exception in flight, silently. Python 3.14 starts warning about this; just do not do it.

Recap

Going further: read the tutorial chapter "Errors and Exceptions" in the Python documentation, and in the REPL run import dis; dis.dis("try:\n x\nexcept NameError:\n pass") to see the ExceptionTable section of the bytecode.

Exercises

Exercise 1 · quiz

Which blocks run?

What does this print? Type the printed letters in order, separated by spaces.

try:
    print("A")
    x = int("42")
    print("B")
except ValueError:
    print("C")
else:
    print("D")
finally:
    print("E")
Answer

int("42") succeeds, so the whole try body runs (A, B), no except matches (no exception), the else block runs because the try finished without an exception (D), and finally always runs (E). With int("forty-two") the output would be A C E.

Exercise 2 · quiz

Order of except clauses

What does this print when d = {}?

try:
    d["missing"]
except LookupError:
    print("lookup")
except KeyError:
    print("key")
Answer
  • lookup

Except clauses are tried top to bottom and the first matching one wins. KeyError is a subclass of LookupError, so the first clause matches and the second can never run. Put specific exceptions before general ones.

Exercise 3 · write code

Ask again on bad input

Read lines from input until one of them is a valid integer, then print got N (where N is the integer) and stop. For each invalid line, print not a number: <line>. If input runs out before a valid number arrives, print no number given and stop.

For input:

twelve
3.5
 12

the output is:

not a number: twelve
not a number: 3.5
got 12

Catch only ValueError (from int) and EOFError (from input). (int ignores surrounding spaces, so 12 is valid.)

File: main.py

Starting point

line = input()
print("got", int(line))

Example

input

twelve
3.5
 12

expected output

not a number: twelve
not a number: 3.5
got 12

Example

input

-7
ignored

expected output

got -7
Hint 1

Wrap everything in while True: and break when you are done.

Hint 2

Use one try around input() for EOFError, and another around int(line) for ValueError.

Hint 3

Put the success case in an else: block of the int try, so only the conversion is inside try.

Reference solution
while True:
    try:
        line = input()
    except EOFError:
        print("no number given")
        break
    try:
        n = int(line)
    except ValueError:
        print(f"not a number: {line}")
    else:
        print("got", n)
        break

Exercise 4 · write code

Handle each failure differently

In solution.py, write describe(table, key, index). table is a dict mapping strings to lists. The function returns table[key][index] as a string with str(), but handles each failure with a specific message:

t = {"fruit": ["apple", "pear"]}
describe(t, "fruit", 1)      # 'pear'
describe(t, "veg", 0)        # 'no such key: veg'
describe(t, "fruit", 5)      # 'no item 5 under fruit'
describe(t, "fruit", "1")    # "bad index: '1'"

Use separate except clauses. Do not use in checks or len(): this exercise is about handling exceptions.

File: solution.py

Starting point

def describe(table, key, index):
    return str(table[key][index])
Hint 1

Look up the key in its own try so a KeyError is never confused with an index problem.

Hint 2

A second try around items[index] with except IndexError: and except TypeError:.

Hint 3

f"{index!r}" formats with repr, so a string shows its quotes.

Reference solution
def describe(table, key, index):
    try:
        items = table[key]
    except KeyError:
        return f"no such key: {key}"
    try:
        value = items[index]
    except IndexError:
        return f"no item {index} under {key}"
    except TypeError:
        return f"bad index: {index!r}"
    return str(value)

Exercise 5 · write code

finally for cleanup

solution.py has a global list log. Write run(task) that:

  1. appends "start" to log,
  2. calls task() and returns its result,
  3. always appends "end" to log afterwards, whether task() returned normally or raised,
  4. if task() raised a ValueError, appends "error: <message>" to log (before "end") and returns None,
  5. any other exception must propagate to the caller (not be swallowed), but "end" must still be logged.
run(lambda: 42)        # returns 42; log gains "start", "end"
run(lambda: int("x"))  # returns None; log gains "start", "error: invalid literal for int() with base 10: 'x'", "end"
run(lambda: 1 / 0)     # raises ZeroDivisionError; log gains "start", "end"

File: solution.py

Starting point

log = []

def run(task):
    log.append("start")
    result = task()
    log.append("end")
    return result
Hint 1

finally: runs on every way out of the try statement: normal completion, return, or an exception passing through.

Hint 2

Use except ValueError as e: and str(e) (or f"{e}") for the message.

Hint 3

Do not catch other exceptions at all; finally still runs as they propagate.

Reference solution
log = []

def run(task):
    log.append("start")
    try:
        return task()
    except ValueError as e:
        log.append(f"error: {e}")
        return None
    finally:
        log.append("end")