Skip to content
aviral gupta

// I1.3 · ~30 min · Intermediate

Clean-up with finally and with

After this lesson you can make clean-up code run however a block ends, predict what return and break do next to finally, and let with close your files.

Lesson 3 of 5 in I1 Errors in depth

You will be able to

  • Predict the order in which try, except, else and finally run, with and without an error
  • Predict what return, break and an unhandled exception do when there is a finally clause
  • Close files with with, also when the block fails, and open two files in one with
  1. Warm-up · Activity 1 of 7

    Warm-up from the last lesson: inside except KeyError, you write raise ConfigError("no port") from None. Which statements are true? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: what does this print?

    log = []
    
    
    def divide(a, b):
        try:
            log.append("try")
            result = a / b
        except ZeroDivisionError:
            log.append("except")
        else:
            log.append("else")
        finally:
            log.append("finally")
    
    
    divide(1, 0)
    print(log)
  3. Practice · Activity 3 of 7

    Fill in the keyword so that "cleanup" is appended even though the try block raises, and the ValueError still reaches the caller.

    def use(log):
        try:
            log.append("work")
            raise ValueError("boom")
        ____:
            log.append("cleanup")
    :
  4. Practice · Activity 4 of 7

    return inside try. What does this print?

    def save():
        try:
            print("saving", end=" ")
            return "saved"
        finally:
            print("closing", end=" ")
    
    
    print(save())
  5. Practice · Activity 5 of 7

    A function has a try statement with except ValueError and finally. Match each case to what happens.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Python 3.14 prints a SyntaxWarning for this code, but it runs. What does it print?

    def risky():
        try:
            return 1 / 0
        finally:
            return "finally wins"
    
    
    print(risky())
  7. Apply · Activity 7 of 7

    Mini-task. Write copy_numbered(src, dst). It copies a text file, putting the line number, a colon and a space in front of every line, and returns the number of lines. Open both files in a single with statement, with encoding="utf-8". Try it on a file with two lines.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Counting lines, whatever goes wrong

count_lines opens a file with with and raises ValueError for an empty file. Its finally clause reports that it has finished, for a good file, an empty one and one that does not exist. Every file object it opened is kept in a list, so the last line can check that all of them were closed, even the one that raised.

main.py

from typing import TextIO

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("buy milk\ncall Ada\n")
with open("empty.txt", "w", encoding="utf-8") as f:
    pass

opened: list[TextIO] = []


def count_lines(path: str) -> int:
    try:
        with open(path, encoding="utf-8") as f:
            opened.append(f)
            lines = f.readlines()
            if not lines:
                raise ValueError(f"{path} is empty")
            return len(lines)
    finally:
        print(f"finished {path}")


for path in ["notes.txt", "empty.txt", "missing.txt"]:
    try:
        print(path, "has", count_lines(path), "lines")
    except ValueError as err:
        print("error:", err)
    except OSError as err:
        print("cannot open:", err.filename)

print("all closed:", all(f.closed for f in opened))

Run it with

python main.py

Output

finished notes.txt
notes.txt has 2 lines
finished empty.txt
error: empty.txt is empty
finished missing.txt
cannot open: missing.txt
all closed: True
  • finished comes before has 2 lines: count_lines, with its finally, runs before print can show its result.
  • finally runs even for missing.txt, where open fails before the with block starts.
  • The file that raised ValueError is closed too: with closes it on the way out.
  • Only two files were ever opened; the missing one never became a file object.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Clean-up that always runs

run_step(name, log, fail) appends "start <name>", raises RuntimeError("<name> failed") when fail is true, and otherwise appends "done <name>". Make it always append "end <name>" as its last entry, also when it raises, and let the RuntimeError reach the caller.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Put the start, the check and the done line in a try block.

  2. Hint 2

    The end line goes into finally, so it runs whether or not the try block raised.

  3. Hint 3

    Do not add an except clause: the RuntimeError has to go on to the caller after finally.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def run_step(name: str, log: list[str], fail: bool) -> None:
    try:
        log.append(f"start {name}")
        if fail:
            raise RuntimeError(f"{name} failed")
        log.append(f"done {name}")
    finally:
        log.append(f"end {name}")
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def run_step(name: str, log: list[str], fail: bool) -> None:
    log.append(f"start {name}")
    if fail:
        raise RuntimeError(f"{name} failed")
    log.append(f"done {name}")
    log.append(f"end {name}")

test_main.py

from main import run_step


def test_success():
    """A step that works logs start, done and end"""
    log = []
    run_step("a", log, False)
    assert log == ["start a", "done a", "end a"], f"the log is {log!r}"


def test_failure_logs_end():
    """A failing step still logs end"""
    log = []
    try:
        run_step("b", log, True)
    except RuntimeError:
        pass
    assert log == ["start b", "end b"], f"the log is {log!r}"


def test_failure_reaches_caller():
    """The RuntimeError reaches the caller"""
    try:
        run_step("c", [], True)
    except RuntimeError as err:
        assert str(err) == "c failed", f"the message was {str(err)!r}"
    else:
        assert False, "run_step returned normally; the RuntimeError must reach the caller"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Two files, one with

copy_upper(src, dst) copies a text file in upper case and returns the number of lines. It works, but it opens dst first, so a missing src leaves an empty dst behind. Rewrite it with a single with statement that opens src first, both with encoding="utf-8", so a missing src raises FileNotFoundError before dst exists.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    One with statement can open two files, separated by a comma: with open(…) as fin, open(…) as fout:.

  2. Hint 2

    Files open from left to right. Put src first: if it is missing, the second open never happens.

  3. Hint 3

    Inside the block, loop over fin, write line.upper() to fout and count. No close() calls are needed.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def copy_upper(src: str, dst: str) -> int:
    count = 0
    with open(src, encoding="utf-8") as fin, open(dst, "w", encoding="utf-8") as fout:
        for line in fin:
            fout.write(line.upper())
            count += 1
    return count
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def copy_upper(src: str, dst: str) -> int:
    count = 0
    with open(dst, "w", encoding="utf-8") as fout:
        with open(src, encoding="utf-8") as fin:
            for line in fin:
                fout.write(line.upper())
                count += 1
    return count

test_main.py

import os

from main import copy_upper


def test_copy():
    """The copy is in upper case, line by line"""
    with open("in.txt", "w", encoding="utf-8") as f:
        f.write("hello\nworld\n")
    count = copy_upper("in.txt", "out.txt")
    with open("out.txt", encoding="utf-8") as f:
        got = f.read()
    assert got == "HELLO\nWORLD\n", f"out.txt holds {got!r}"
    assert count == 2, f"copy_upper returned {count!r}, expected 2"


def test_missing_source():
    """A missing source raises FileNotFoundError and creates no target"""
    if os.path.exists("never.txt"):
        os.remove("never.txt")
    try:
        copy_upper("no_such_file.txt", "never.txt")
    except FileNotFoundError:
        pass
    else:
        assert False, "copy_upper returned normally for a missing source"
    assert not os.path.exists("never.txt"), "never.txt was created although the source is missing; open src first"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Writing after the with block

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("first line\n")
f.write("second line\n")

What Python prints

ValueError: I/O operation on closed file.

Why, and the fix

with closes the file when its block ends. The name f still exists afterwards, but the file behind it is closed. Indent every line that uses the file into the with block, or open the file again.

Closing in finally what was never opened

try:
    f = open("missing.txt", encoding="utf-8")
    print(f.read())
finally:
    f.close()

What Python prints

NameError: name 'f' is not defined

Why, and the fix

open failed, so f was never assigned, and finally then fails too, on top of the FileNotFoundError. Open the file before the try (then finally only runs once it is open), or simply use with open(...) as f:, which closes only what it opened.

else after finally

try:
    x = 1
finally:
    print("done")
else:
    print("ok")

What Python prints

SyntaxError: invalid syntax

Why, and the fix

The clauses have a fixed order: try, except, else, finally. else also needs at least one except clause before it. Move finally to the end and add the except clause that else depends on, or drop else.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

finally always runs, last

A finally clause runs as the last step of a try statement, whether or not an exception happened. The order is: try, then except (if an error was handled) or else (if there was none), then finally. If an exception is not handled, finally runs first and then the exception goes on to the caller; the same holds for an exception raised inside except or else. That makes finally the place to release what the try block used, such as a file, a lock or a connection.

return, break and finally

When the try block reaches return, break or continue, finally still runs, just before the function returns or the loop moves on. The return value is worked out first, so changing a variable in finally does not change what is returned. A return in finally itself wins over every other return and even discards an exception that was on its way out. The docs call that confusing, and since 3.14 Python prints a SyntaxWarning for return, break or continue in finally. Keep finally for clean-up only.

with closes the file for you

with open(path, encoding="utf-8") as f: opens the file and closes it when the block ends, normally or through an exception: the same as a try with f.close() in finally, without the chance to forget it. After the block, f still exists, but f.closed is True, and reading or writing raises ValueError. One with can open several files: with open(src) as fin, open(dst, "w") as fout:. They open from left to right, and if the first fails, the second is never created.

Sources

Last reviewed September 29, 2026