Context Managers

Updated

September 8, 2026

Context Managers

A context manager is an object you use with with. It sets something up, yields a value, and tears it down even if the block raises. Files, locks, and “this table is occupied” are the boring uses. The boring default is with for every resource that has a close, and contextlib.contextmanager for a tiny custom one. A class with __enter__ / __exit__ is fine when the state has a name.

Mental model

with manager as name:
    ...

Python calls manager.__enter__() and binds the return value to name. When the block leaves — return, exception, or the last line — it calls manager.__exit__(exc_type, exc, tb).

If __exit__ returns a true value, the exception is swallowed. Return False (or None) to let it propagate. Swallowing is a defect unless you are writing a helper whose job is to ignore a specific error.

@contextmanager turns a generator into the same protocol: code before yield is enter, code in finally after yield is exit.

threading.Lock is a context manager. with lock: acquires and releases. Do not call acquire()/release() by hand unless you are in a situation with cannot express.

Worked examples

Case 1: a file in a temporary directory

Save as shift_file.py. TemporaryDirectory and path.open() are both context managers. Nothing is left on disk when main returns.

# shift_file.py
from pathlib import Path
import tempfile


def main():
    with tempfile.TemporaryDirectory() as d:
        path = Path(d) / "shift.txt"
        path.write_text("open 4\n")
        with path.open() as f:
            print(f.read().strip())


if __name__ == "__main__":
    main()

Run:

uv run python shift_file.py

Output:

open 4

path.write_text closes the file itself. The inner with path.open() is the read side. Nested with is normal.

Case 2: __enter__ and __exit__

Save as occupied.py. Seating a table is a pair of actions. The class holds the table number.

# occupied.py
class Occupied:
    def __init__(self, table):
        self.table = table

    def __enter__(self):
        print(f"seat {self.table}")
        return self.table

    def __exit__(self, exc_type, exc, tb):
        print(f"clear {self.table}")
        return False


def main():
    with Occupied(12) as table:
        print(f"serving {table}")


if __name__ == "__main__":
    main()

Run:

uv run python occupied.py

Output:

seat 12
serving 12
clear 12

__exit__ still runs if the body raises. Save as occupied_error.py to see the order.

# occupied_error.py
class Occupied:
    def __init__(self, table):
        self.table = table

    def __enter__(self):
        print(f"seat {self.table}")
        return self.table

    def __exit__(self, exc_type, exc, tb):
        print(f"clear {self.table}")
        return False


def main():
    try:
        with Occupied(12):
            raise ValueError("kitchen closed")
    except ValueError as e:
        print(e)


if __name__ == "__main__":
    main()

Run:

uv run python occupied_error.py

Output:

seat 12
clear 12
kitchen closed

Clear, then the exception. That is the contract.

Case 3: @contextmanager

Save as occupied_cm.py. Same seating, less class. Use this when there is no extra state beyond the arguments.

# occupied_cm.py
from contextlib import contextmanager


@contextmanager
def occupied(table):
    print(f"seat {table}")
    try:
        yield table
    finally:
        print(f"clear {table}")


def main():
    with occupied(12) as table:
        print(f"serving {table}")


if __name__ == "__main__":
    main()

Run:

uv run python occupied_cm.py

Output:

seat 12
serving 12
clear 12

The try / finally around yield is required so clear runs on error. Forgetting it is the generator version of swallowing cleanup.

Case 4: a lock

Save as desk_lock.py. The counter is shared. The lock makes stamp safe if two threads called it. One thread still uses with lock so the acquire/release pairing cannot drift.

# desk_lock.py
import threading

lock = threading.Lock()
next_id = 0


def stamp():
    global next_id
    with lock:
        next_id += 1
        return f"ticket {next_id}"


def main():
    print(stamp())
    print(stamp())


if __name__ == "__main__":
    main()

Run:

uv run python desk_lock.py

Output:

ticket 1
ticket 2

global is ugly. A small class with self.next_id and self.lock is the next step when this grows. The with lock stays.

The trap

__exit__ returning True eats the error. The with looks successful.

# swallow_exit.py
class Occupied:
    def __init__(self, table):
        self.table = table

    def __enter__(self):
        print(f"seat {self.table}")
        return self.table

    def __exit__(self, exc_type, exc, tb):
        print(f"clear {self.table}")
        return True


def main():
    with Occupied(12):
        raise ValueError("kitchen closed")
    print("kept going")


if __name__ == "__main__":
    main()

Run:

uv run python swallow_exit.py

Output:

seat 12
clear 12
kept going

The kitchen closed and main continued. Return False. If you meant to handle ValueError, catch it inside the block or outside the with, not by a boolean from __exit__.

The boring rule

  • with for files, locks, temp directories, and any pair of setup/teardown.
  • Class with __enter__ / __exit__ when the manager has state. @contextmanager when it is a short generator.
  • __exit__ returns False. Cleanup goes there; handling goes in except.
  • Put yield inside try / finally in a @contextmanager function.
  • Nest with or use with a, b: for two managers. ExitStack is the next chapter.
  • Do not open a file in __init__ and hope the caller closes it.

Try this

  1. In shift_file.py, write two lines and print f.readline().strip() twice inside the with.
  2. Change occupied.py so __enter__ returns self. Print ctx.table inside the block.
  3. In occupied_cm.py, raise ValueError("kitchen closed") inside the with and catch it in main. Confirm clear still prints.
  4. Return False from swallow_exit.py’s __exit__ and catch the ValueError in main.