Context Managers: The Cleanup Problem

Context managers in Python are constructs that allow you to properly manage resources, ensuring that they are acquired and released correctly. They are primarily used with the with statement to handle resources like file handling, database connections, threading locks, etc.

The Issue

Every resource you open — a file, a database connection, a network socket, a lock — eventually needs to be closed. The naive way to write this looks fine at first glance:

f = open("file.txt")
data = f.read()
f.close()
Python

This works — right up until something goes wrong. If f.read() raises an exception, f.close() never runs. The file handle stays open. Do this enough times in a long-running program (a web server, a data pipeline, a background worker) and you leak file descriptors or database connections until the process falls over.

The instinctive fix is try/finally:

f = open("file.txt")
try:
    data = f.read()
finally:
    f.close()
Python

This is correct — finally runs whether or not an exception occurred. But it’s verbose, and worse, it’s easy to forget. Nothing forces you to wrap every resource this way. Multiply this pattern across a codebase with files, locks, and connections, and you get:

  • Inconsistent cleanup — some code paths remember try/finally, others don’t
  • Boilerplate that obscures the actual logic
  • Bugs that only show up under load or under failure, exactly when you can least afford them

Python needed a way to bake “always clean up, no matter what” into the language itself, rather than relying on every developer to remember it every time.

Context Managers as the Solution

Context managers in Python are constructs that allow you to properly manage resources, ensuring that they are acquired and released correctly. They are primarily used with the with statement to handle resources like file handling, database connections, threading locks, etc.

A context manager is an object that knows how to set itself up and tear itself down, and the ‘with’ statement is the syntax that guarantees the teardown happens — even if an exception is raised.

with open("file.txt") as f:
    data = f.read()
Python

No explicit close(). No try/finally. The file is closed automatically the moment the with block ends, whether it ends normally or via an exception.

Syntax

with expression as variable:
    # Code block
Python

Common Use Cases

  • File Handling: with open()
  • Database Connections: with sqlite3.connect()
  • Thread Locks: with threading.Lock()
  • Temporary Files: with tempfile.TemporaryFile()
  • Managing Network Connections: with socket.create_connection()

Custom Context Managers

This protocol is two dunder methods:

class MyContext:
    def __enter__(self):
        print("Entering the context")
        return self  # The return value is assigned to 'as' variable in 'with'
    
    def __exit__(self, exc_type, exc_value, traceback):
        print("Exiting the context")
        return False # If exception occurs, it will still call this method

# Usage
with MyContext() as t:
    print("Inside the context block")
    

'''
Entering the context
Inside the context block
Exiting the context
'''    
Python

__enter__()

It is the first method that gets called when you enter a with block using a context manager.

__exit__()

It is always called — whether:

  • The with block completes normally, or
  • an exception is raised inside the with block.

__enter__ return

Whatever __enter__ returns becomes t. That’s the entire job of the return value — bind a name for use inside the block. Python doesn’t inspect it, check it for True/False, or use it to decide anything.

__exit__ return

  • False (or None, which is the default if you don’t return anything) → “I did not handle this exception, let it propagate.” The exception continues as normal, exactly as if the with block weren’t there.
  • True → “I’ve handled it, swallow it.” The exception is suppressed — code after the with block continues running as if nothing happened.

The Decorator Shortcut

Writing a full class with __enter__ and __exit__ is fine, but it’s a lot of ceremony for something conceptually simple: run some setup, run the block, run some teardown. The contextlib module gives you a decorator that turns an ordinary generator function into a context manager:

from contextlib import contextmanager

@contextmanager
def my_context():
    print("Entering the context")
    yield  # This is where the block inside 'with' runs
    print("Exiting the context")

# Usage
with my_context():
    print("Inside the context block")
Python

Example

Temporary directory change

class based

import os

class ChangeDir:
    def __init__(self, path):
        self.path = path

    def __enter__(self):
        self.old_path = os.getcwd()
        os.chdir(self.path)
        return self.path

    def __exit__(self, exc_type, exc_val, exc_tb):
        os.chdir(self.old_path)
        return False
Python

Usages

with ChangeDir("/tmp") as p:
    print(os.getcwd())   # /tmp
    print(p)              # /tmp  (this is __enter__'s return value)

print(os.getcwd())        # back to wherever you started
Python

Decorator-based

from contextlib import contextmanager
import os

@contextmanager
def change_dir(path):
    old_path = os.getcwd()
    os.chdir(path)
    try:
        yield path
    finally:
        os.chdir(old_path)
Python

Usages

with change_dir("/tmp"):
    print(os.getcwd())   # /tmp
print(os.getcwd())       # back to original, guaranteed
Python

Database-style transaction with commit/rollback

class based

class Transaction:
    def __init__(self, conn):
        self.conn = conn

    def __enter__(self):
        return self.conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self.conn.commit()
        else:
            self.conn.rollback()
        return False   # don't swallow the exception, just clean up
Python

Usages

with Transaction(db_conn) as conn:
    conn.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
    conn.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
Python

Decorator-based

@contextmanager
def transaction(conn):
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise   # re-raise so the caller still sees the error
Python

Usages

with transaction(db_conn) as conn:
    conn.execute("UPDATE ...")
    # if this raises, rollback() runs; if it succeeds, commit() runs
Python

Conclusion

Context managers exist to solve one specific problem: cleanup that must happen no matter what. Whether it’s closing a file, releasing a lock, restoring a directory, or committing/rolling back a database transaction, the pattern is always the same — set something up, run some code, guarantee the teardown even if that code blows up.


About Puneet Verma

Puneet Verma is a software developer specialising in backend architecture, Dynamic Programming, and SaaS solutions. He focuses on building optimised, scalable applications and sharing deep-dive technical tutorials to help developers master complex algorithmic patterns.

Leave a Comment