Cleanup You Can Count On

The Course
•
September 18, 2026
•
8 min read

Some things have to be undone: a file closed, a database connection closed, a temporary file deleted, a lock released. The hard part isn’t the undoing. It’s making sure it happens on every path out of the code: the normal end, an early return, a break, and above all an exception you didn’t expect.

That’s the job of with, and today you’ll write your own.

What with guarantees

You’ve been writing with open(path) as file: since the beginner course. Here’s what it actually promises, shown with a class that announces each step:

Inside a with statement Python
class Announcer:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"  enter {self.name}")
        return self                          # this is what "as" receives

    def __exit__(self, exc_type, exc, traceback):
        print(f"  exit  {self.name} (exception: {exc_type.__name__ if exc_type else None})")
        return False                         # False: don't swallow the exception


print("normal end:")
with Announcer("a"):
    print("  body")

print("return from inside:")
def early():
    with Announcer("b"):
        return "returned"
print(" ", early())

print("exception inside:")
try:
    with Announcer("c"):
        1 / 0
        print("  never printed")
except ZeroDivisionError:
    print("  the exception still reached the caller")

A with block calls __enter__ on the way in, and __exit__ on the way out, however it leaves. __exit__ is told whether an exception is on its way out, and returning False lets it carry on to the caller. Any object with these two methods is a context manager.

A trap: with conn doesn’t close

On Day 3 you used with conn: for transactions. It’s easy to assume it closes the connection, the way with open(...) closes a file. It doesn’t:

Still open Python
import sqlite3

with sqlite3.connect(":memory:") as conn:
    conn.execute("CREATE TABLE t (x)")
    conn.execute("INSERT INTO t VALUES (1)")

print(conn.execute("SELECT x FROM t").fetchall())    # still works: never closed
conn.close()

For a connection, with means “one transaction”: commit if the block succeeds, roll back if it raises. What a context manager does on exit is up to whoever wrote it, so it’s worth checking in the documentation rather than assuming. For a database file on disk, a connection left open keeps the file locked, and leaks one more open file every time the program forgets.

Writing one with contextlib

Writing a class with __enter__ and __exit__ works, but for most cases contextlib.contextmanager is shorter. You write a generator that yields exactly once:

from contextlib import contextmanager


@contextmanager
def open_store(path=":memory:"):
    """Connect, and close the connection when the with block ends, however it ends."""
    conn = connect(path)          # before yield: runs on the way in
    try:
        yield conn                # the value "as" receives; the with block runs here
    finally:
        conn.close()              # after yield: runs on the way out
with open_store("logs.db") as conn:
    save_records(conn, "server.log", records)

The with block runs where the generator yields. When the block ends, the generator carries on after the yield. If the block raised, the exception is raised inside the generator, at the yield, and that’s why the try and finally matter.

Bug hunt: the connection that stayed open

Here’s the first version of open_store, with a test that checks the connection is closed even when the block fails:

open_store, first attempt Python
import sqlite3
import sys
import unittest
from contextlib import contextmanager


@contextmanager
def open_store(path=":memory:"):
    conn = sqlite3.connect(path)
    yield conn
    conn.close()


class TestOpenStore(unittest.TestCase):
    def test_closes_the_connection(self):
        with open_store() as conn:
            conn.execute("SELECT 1")
        with self.assertRaises(sqlite3.ProgrammingError):   # "Cannot operate on a closed database"
            conn.execute("SELECT 1")

    def test_closes_the_connection_even_when_the_block_fails(self):
        with self.assertRaises(ZeroDivisionError):
            with open_store() as conn:
                1 / 0
        with self.assertRaises(sqlite3.ProgrammingError):
            conn.execute("SELECT 1")


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestOpenStore)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)

Reproduce. The normal case passes. The failing-block case fails with “ProgrammingError not raised”. The connection was still usable after the with, so it was never closed.

Inspect. Is the cleanup line running at all? Add a print just before it:

    yield conn
    print("closing")
    conn.close()

Run it. closing is printed once, for the first test. For the second, nothing.

Before you open this, make the second test pass without breaking the first. Then compare with ours, including why the line after yield didn’t run.

Show the diagnosis and the fix

Narrow. When the with block raises, contextmanager raises that same exception inside the generator, at the yield. From the generator’s point of view, yield conn raised ZeroDivisionError. So, like any line after one that raises, conn.close() never runs. The first test couldn’t catch it, because it only tried the path where nothing goes wrong. Both paths need a test, and the failing one is the one people forget.

Pin it. test_closes_the_connection_even_when_the_block_fails is the regression test. The pattern is worth remembering: assertRaises around the with, so the test survives the exception, then check the resource afterwards.

Fix. Put the cleanup in a finally:

    conn = sqlite3.connect(path)
    try:
        yield conn
    finally:
        conn.close()
Protect the cleanup around the yield

If there’s cleanup after the yield, it must be protected: by a finally, or by an enclosing with whose own exit does the cleanup, as in the next lesson’s checkpoint. Unprotected, a context manager only cleans up when nothing went wrong, which is the one time it mattered least. Standard library tools like contextlib.closing(conn) and tempfile.TemporaryDirectory() already get this right.

Your turn

Exercise

A file that cleans up after itself

Tests often need a real file for a moment. Write a context manager working_file(path, text) that:

  • creates a new file at path and writes text to it. If something is already at path, it raises FileExistsError and leaves that file alone: a helper that cleans up after itself must never delete something it didn’t create;
  • gives the with block the path;
  • deletes the file when the block ends, however it ends, including when writing the text fails part-way.
import os
import sys
import unittest
from contextlib import contextmanager


@contextmanager
def working_file(path, text):
    """Create path containing text for the length of a with block, then delete it."""
    raise NotImplementedError("your code here")
    yield


class TestWorkingFile(unittest.TestCase):
    def test_the_file_exists_inside_the_block(self):
        with working_file("sample.log", "line one\n") as path:
            with open(path) as file:
                self.assertEqual(file.read(), "line one\n")

    def test_the_file_is_gone_afterwards(self):
        with working_file("sample.log", "x"):
            pass
        self.assertFalse(os.path.exists("sample.log"))

    def test_the_file_is_gone_even_when_the_block_fails(self):
        with self.assertRaises(ValueError):
            with working_file("sample.log", "x"):
                raise ValueError("something broke")
        self.assertFalse(os.path.exists("sample.log"))

    def test_an_existing_file_is_refused_and_left_alone(self):
        with open("keep.txt", "w") as file:
            file.write("precious")
        self.addCleanup(os.remove, "keep.txt")
        with self.assertRaises(FileExistsError):
            with working_file("keep.txt", "x"):
                pass
        with open("keep.txt") as file:
            self.assertEqual(file.read(), "precious")

    def test_a_failed_write_leaves_nothing_behind(self):
        with self.assertRaises(UnicodeEncodeError):          # a lone surrogate can't be UTF-8
            with working_file("sample.log", "\ud800"):
                pass
        self.assertFalse(os.path.exists("sample.log"))


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestWorkingFile)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

open(path, "x") creates a new file, and raises FileExistsError if one is already there — so it both creates and refuses. The real question is exactly when the file becomes yours to delete. That moment decides where the try begins.

Hint 2: a first step

The shape is: create the file; then, in a try, write it and yield path; in the finally, delete it. Now decide which of those lines go before the try. A finally that deletes a file you didn’t create breaks the fourth test; one that misses a half-written file breaks the fifth.

Show one solution
@contextmanager
def working_file(path, text):
    """Create path containing text for the length of a with block, then delete it."""
    file = open(path, "x", encoding="utf-8")     # "x": a new file only
    try:
        with file:
            file.write(text)
        yield path
    finally:
        os.remove(path)

The line that matters is where the try starts. It divides the work in two:

  • Acquiring the resource, open(path, "x"), comes before the try. If it fails, because something is already at path, nothing was created, so there’s nothing to clean up. The finally must not run, or it would delete a file that was never ours.
  • Everything after acquiring it goes inside the try, including the write. Once open succeeds the file exists, and it’s ours, so a failed write must still delete it. Writing before the try, as a first attempt often does, leaves a half-written file behind when the write fails.

The file is written and closed before the yield, so the block can open it itself and find everything flushed to disk.

For real code, tempfile.TemporaryDirectory() gives you a whole directory with the same guarantee, and a name no other program is using.

What you can now do

  • Say exactly what with guarantees, and check what a particular context manager does on exit, rather than assuming.
  • Close a database connection deliberately, not just commit it.
  • Write a context manager with @contextmanager, with its cleanup in finally.
  • Test cleanup on the failure path, where it matters, not only the happy path.

Next: the Day 4 checkpoint, a configurable analyzer, and, if something really does repeat, a decorator.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.