Index Ai Academy
Phase 7 · Software engineering · Python in depth

Context managers

Setup and cleanup that always run: with, contextlib, transactions and atomic writes.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Open a file and forget to close it; take a lock and crash before releasing it; start a database change and fail halfway through. These bugs are easy to write and hard to find, because they only bite when something else has already gone wrong. Context managers put the setup and the cleanup in one place, and the with statement guarantees the cleanup runs. You've used them with open(); this lesson shows how to write your own.

What you'll be able to do

  • Explain what a with block guarantees, even when the code inside fails
  • Write a context manager as a class with __enter__ and __exit__, and with contextlib.contextmanager
  • Use suppress, ExitStack and database transactions
  • Write files safely, so a crash never leaves a half-written file behind

What it covers

  1. What with guarantees
  2. Writing one as a class
  3. The shortcut: contextlib.contextmanager
  4. Ready-made managers
  5. Change a setting, then put it back
  6. Where context managers appear
  7. Common mistakes
  8. Worked example: writing a file atomically

What with guarantees

with pairs a setup with a cleanup. Whatever happens inside the block, whether it finishes normally, returns early or raises an exception, the cleanup runs:

with open("notes.txt", "w") as f:
    f.write("first line\n")
print("closed after the block:", f.closed)

try:
    with open("notes.txt", "a") as f:
        f.write("second line\n")
        raise RuntimeError("something failed mid-write")
except RuntimeError as e:
    print("error:", e, "| file closed anyway:", f.closed)

print(open("notes.txt").read())
It runs right here in your browser, with nothing to install.

Without with, you'd need a try/finally around every use to be sure the file gets closed. A context manager is any object that knows its own setup and cleanup. with calls them at the right moments.

Writing one as a class

with thing as x: calls thing.__enter__() at the start; its return value becomes x. At the end it calls thing.__exit__(exc_type, exc, traceback). The three arguments describe the exception that ended the block, or are all None if it ended normally.

import time

class Timer:
    def __init__(self, label):
        self.label = label

    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc, tb):
        self.seconds = time.perf_counter() - self.start
        status = "failed" if exc_type else "ok"
        print(f"{self.label}: {self.seconds * 1000:.1f} ms ({status})")
        return False   # don't swallow the exception

with Timer("sum of squares") as t:
    total = sum(n * n for n in range(200_000))
print(total, t.seconds > 0)

try:
    with Timer("dividing"):
        1 / 0
except ZeroDivisionError:
    print("the error still reached us")

If __exit__ returns True, the exception is swallowed and the program carries on after the block. That's almost never what you want, so return False (or nothing) unless the whole point of the manager is to ignore a specific error.

Try it now

Step through to see the order: __enter__ runs first and its return value becomes status, the block runs, then __exit__ runs with None for the error.

The shortcut: contextlib.contextmanager

Most context managers are easier to write as a generator with one yield. Everything before the yield is the setup, the yielded value is what as receives, and everything after is the cleanup. Put the yield in a try/finally so the cleanup runs even on an error:

import os
from contextlib import contextmanager

@contextmanager
def working_dir(path):
    before = os.getcwd()
    os.makedirs(path, exist_ok=True)
    os.chdir(path)
    try:
        yield path
    finally:
        os.chdir(before)

print(os.getcwd())
with working_dir("reports") as where:
    open("june.txt", "w").write("ok")
    print("inside:", os.getcwd().endswith(where))
print("back:", os.getcwd(), os.path.exists("reports/june.txt"))

Without the finally, an exception inside the block would skip the chdir back and leave the program in the wrong folder. That's exactly the kind of bug context managers exist to prevent.

Quick check
What does this print?
from contextlib import contextmanager
@contextmanager
def tag(name):
    print(f"<{name}>", end="")
    try:
        yield
    finally:
        print(f"</{name}>", end="")
with tag("b"):
    print("hi", end="")
print()
  1. <b>hi</b>
  2. <b></b>hi
  3. hi<b></b>
  4. <b>hi
Show the answer

<b>hi</b>

The code before yield runs as the block starts, the block prints hi, and the finally part runs as it ends.

Ready-made managers

The standard library has several you'll reach for:

import os
import sqlite3
from contextlib import ExitStack, suppress

with suppress(FileNotFoundError):
    os.remove("does-not-exist.tmp")
print("no error: suppress ignored that one kind of exception")

names = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
    files = [stack.enter_context(open(n, "w")) for n in names]
    for f, n in zip(files, names):
        f.write(n)
print("all closed:", all(f.closed for f in files))

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE stock (sku TEXT PRIMARY KEY, qty INTEGER CHECK (qty >= 0))")
db.execute("INSERT INTO stock VALUES ('TE-101', 3)")
try:
    with db:
        db.execute("UPDATE stock SET qty = qty + 5 WHERE sku = 'TE-101'")
        db.execute("UPDATE stock SET qty = qty - 20 WHERE sku = 'TE-101'")
except sqlite3.IntegrityError as e:
    print("rolled back:", e)
print(db.execute("SELECT qty FROM stock").fetchone())
  • suppress(SomeError) ignores one specific exception. Use it only when the error genuinely doesn't matter.
  • ExitStack manages any number of context managers, even when you don't know how many in advance. Everything entered is cleaned up when the block ends.
  • A sqlite3 connection used with with is a transaction: if the block raises, every change in it is undone (rolled back); if it finishes, all of them are saved (committed). Here the second update would make stock negative, so both updates are undone and the quantity stays 3.
Drill

Change a setting, then put it back

Write a context manager override(settings, key, value) with @contextmanager: inside the block settings[key] is value; afterwards it's restored to what it was, even if the block raises. If the key didn't exist before, remove it afterwards.

from contextlib import contextmanager

@contextmanager
def override(settings, key, value):
    yield


config = {"currency": "USD"}
with override(config, "currency", "EUR"):
    print(config["currency"])
print(config["currency"])
In the lesson, your code is checked as you write it.

Where context managers appear

Once you recognize the shape, "set something up, do some work, always clean up", you'll see context managers all over real code:

  • Files and network connections: open, requests.Session(), smtplib.SMTP, socket objects.
  • Locks in threaded code: with lock: takes the lock and always releases it, so one crash can't freeze every other thread.
  • Databases: sessions and transactions in sqlite3 and SQLAlchemy, where the block's changes are saved together or not at all.
  • Temporary state: tempfile.TemporaryDirectory() deletes its folder afterwards; decimal.localcontext() changes precision for one calculation; tests use pytest.raises and patching helpers to change something only for the duration of a block.
  • Timing and profiling: a timer around a block, as you built above.

A useful test for your own code: whenever you write a try/finally whose finally undoes something the try set up, ask whether it should be a context manager. Naming the pattern once means every caller gets the cleanup right without thinking about it.

Common mistakes

A few slips catch almost everyone the first time:

  • Using the resource after the block. with open(...) as f: closes f at the end of the block, so reading from it on the next line fails with "I/O operation on closed file". Do the work inside, or keep the data you read, not the file.
  • A @contextmanager generator that yields twice, or never. It must yield exactly once: that one yield is where the block runs. Yielding inside a loop raises "generator didn't stop".
  • Forgetting that the block's exception arrives at the yield. Code after a bare yield doesn't run when the block fails. Cleanup belongs in finally.
  • Opening many files in a loop without closing them. Opening thousands of files without with can exhaust what the operating system allows. Either close each one in its own with, or hold them in an ExitStack.
  • Swallowing errors by accident. A class whose __exit__ returns a true value hides every exception raised in the block, so bugs vanish silently. Return False, or nothing at all, unless hiding one specific error is the whole point.
Worked example

Worked example: writing a file atomically

If a program crashes while overwriting a file, the file can be left half-written: the old data gone, the new data incomplete. Write to a temporary file first, and only replace the real one when everything succeeded.

import os
import tempfile
from contextlib import contextmanager

@contextmanager
def atomic_write(path):
    folder = os.path.dirname(os.path.abspath(path))
    fd, temp_path = tempfile.mkstemp(dir=folder, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            yield f
        os.replace(temp_path, path)
    except BaseException:
        os.remove(temp_path)
        raise

with atomic_write("prices.json") as f:
    f.write('{"TE-101": 200}')
print(open("prices.json").read())

try:
    with atomic_write("prices.json") as f:
        f.write('{"TE-101": 17')
        raise RuntimeError("crashed halfway")
except RuntimeError:
    pass
print(open("prices.json").read())
print([n for n in os.listdir(".") if n.endswith(".tmp")])
In the lesson you can change this and run it.

tempfile.mkstemp creates a uniquely named temporary file in the same folder. The block writes to it. Only if the block finishes does os.replace swap it in for the real file. That step is atomic: anyone reading the file sees either the old version or the new one, never half of each. If anything fails, the temporary file is deleted and the exception passes on, and prices.json still holds the last good data. Save files, caches and configuration this way whenever a half-written file would hurt.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

In a @contextmanager generator, why put the yield inside try/finally?

  1. So the cleanup runs even if the with block raises an exception
  2. Because yield can't be used without try
  3. To make the block run faster
  4. To swallow every exception

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson