Cleanup Actions During Error Handling

Cleanup is the bookkeeping your program does to leave the world in a consistent state: closing files, releasing locks, rolling back transactions, deleting temporary data. If cleanup is skipped when an exception is raised, small leaks accumulate into bigger problems — locked files on Windows, hanging database connections, half-written outputs. Python offers two clean ways to attach cleanup to code: the finally clause and the with statement.

The finally block runs after try and any except/else, whether the body completed normally, returned early, or raised. It is the right place for “no matter what, do this afterwards” code. It is also the simplest error-safety tool: pair a resource acquisition with a matching release inside the same try/finally.

The with statement is a higher-level form of the same idea. Any object that defines __enter__ and __exit__ (a context manager) can be used with with, and its cleanup is executed on exit automatically, even during exceptions. Files, locks, temporary directories, database connections and many other resources ship their own context managers.

For ad-hoc cleanup where you don't want to write a whole class, contextlib.contextmanager turns a generator function into a context manager in three lines. contextlib.ExitStack lets you enter an arbitrary number of context managers at runtime and clean them all up together, which is invaluable when the number of resources is dynamic.

try/finally: the primitive

The simplest pattern is try: resource = acquire(); do_work(resource) finally: resource.release(). It is verbose compared to with, but it works for any cleanup, including ones that depend on variables defined in the try body.

finally runs even on return, break, continue, and raise. If finally itself raises, the new exception replaces the original. Keep finally bodies simple to avoid losing the first error.

with and contextlib

with open("f.txt") as f: is the canonical example. Most library resources follow the same convention: with threading.Lock() as lock, with sqlite3.connect(db) as conn, with tempfile.TemporaryDirectory() as tmp.

contextlib.contextmanager lets you write @contextmanager def my_ctx(): try: yield x finally: cleanup(). Handy for a one-off setup/teardown pair you want to use in several call sites.

Tools for attaching cleanup to blocks of code.

ToolPurpose
try / finally
statement
Runs cleanup even on early return or exception.
with ctx as x
statement
Automatic setup/teardown via context managers.
@contextmanager
decorator
Turns a generator into a context manager.
ExitStack
class
Register an arbitrary number of context managers.
contextlib.suppress(E)
class
Ignore a specific exception as a context manager.
TemporaryDirectory
class
Directory that is removed on context exit.
threading.Lock()
class
Lock with automatic release on with exit.
sqlite3.Connection
class
Commits or rolls back based on with exit.

Cleanup Actions During Error Handling code example

The script shows three cleanup patterns — try/finally, with, and @contextmanager — side by side on a small resource.

# Lesson: Cleanup Actions During Error Handling
from contextlib import contextmanager, ExitStack
from pathlib import Path
from tempfile import TemporaryDirectory


class Connection:
    def __init__(self, name: str): self.name = name; self.open = True
    def close(self): self.open = False; print(f"  closed {self.name}")


# Pattern 1: try/finally
def run_1():
    conn = Connection("A")
    try:
        print("  using A")
        raise RuntimeError("boom in A")
    finally:
        conn.close()


try:
    run_1()
except RuntimeError as err:
    print("caught:", err)


# Pattern 2: @contextmanager
@contextmanager
def open_conn(name: str):
    conn = Connection(name)
    try:
        yield conn
    finally:
        conn.close()


try:
    with open_conn("B") as c:
        print("  using", c.name)
        raise RuntimeError("boom in B")
except RuntimeError as err:
    print("caught:", err)


# Pattern 3: ExitStack for dynamic resource count
with ExitStack() as stack:
    conns = [stack.enter_context(open_conn(n)) for n in ("C", "D", "E")]
    print("  opened:", [c.name for c in conns])


# Built-in cleanup: TemporaryDirectory
with TemporaryDirectory() as tmp:
    p = Path(tmp) / "note.txt"
    p.write_text("hello", encoding="utf-8")
    print("  tmp file exists?", p.exists())
print("tmp still exists?", Path(tmp).exists())

Pay attention to the order of prints:

1) Pattern 1: try/finally always closes, even when a RuntimeError escapes.
2) Pattern 2: @contextmanager hides the try/finally inside a reusable helper.
3) Pattern 3: ExitStack handles an arbitrary number of resources uniformly.
4) TemporaryDirectory deletes itself on exit — `Path(tmp).exists()` is False afterwards.

Write a one-shot helper with @contextmanager and wrap it around a file.

from contextlib import contextmanager
from pathlib import Path
from tempfile import gettempdir

@contextmanager
def writing(path: Path):
    f = open(path, "w", encoding="utf-8")
    try:
        yield f
    finally:
        f.close()
        print("closed", path.name)

p = Path(gettempdir()) / "note.txt"
with writing(p) as f:
    f.write("hello\n")
print(p.read_text(encoding="utf-8"))
p.unlink()

Confirm the order of cleanup.

from contextlib import contextmanager

calls = []

@contextmanager
def step(name):
    calls.append(f"in:{name}")
    try: yield
    finally: calls.append(f"out:{name}")

with step("A"), step("B"):
    calls.append("body")
assert calls == ["in:A", "in:B", "body", "out:B", "out:A"]

Running the script prints (order matters):

  using A
  closed A
caught: boom in A
  using B
  closed B
caught: boom in B
  opened: ['C', 'D', 'E']
  closed E
  closed D
  closed C
  tmp file exists? True
tmp still exists? False