1. Home
  2. Blog
  3. Software Development

Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

The with statement guarantees that setup is always matched by cleanup, even when something goes wrong. Learn how context managers work, how to write your own in two ways, and the surprises we found while testing them.

10 min read
Python context managers explained: the with statement, contextlib and real uses

Key takeaways

  • A context manager pairs setup with cleanup. The with statement runs both, even when an exception occurs.
  • Write one as a class with __enter__ and __exit__, or as a generator with @contextlib.contextmanager.
  • In generator-based managers, put cleanup in try/finally, or it is skipped on errors. We proved it.
  • Returning True from __exit__ swallows the exception, so do it only on purpose.

Opening a file, taking a lock, starting a database transaction, changing a setting for a test: each of these has a matching “undo” that must happen afterwards. Forget it, or let an exception skip it, and you leak file handles, deadlock threads or leave the database half-updated. Python’s with statement exists to make that undo automatic.

We ran every example below with Python 3.13, and the outputs shown are from that run.

The problem context managers solve

Without with, safe cleanup needs a try/finally every time:

f = open("notes.txt", "w")
try:
    f.write("hello")
finally:
    f.close()

It works, but it is easy to forget, and the cleanup ends up far from the setup. The with version says the same thing in two lines:

with open("notes.txt", "w") as f:
    f.write("hello")

We forced an error inside the block, with raise ValueError("boom"), then checked the file afterwards. The output was closed: True | content: hello. The file was closed and the data was saved, even though the block failed.

How the with statement works

Any object with two special methods can be used in a with statement:

  1. Python calls the object’s __enter__() method. Whatever it returns is bound to the name after as.
  2. The indented block runs.
  3. Python calls __exit__(exc_type, exc, tb), whether the block finished normally, returned early, or raised an exception.

That third step is the guarantee. It runs on return, break, continue and exceptions alike, which makes it more reliable than cleanup code written at the end of a block.

Writing a class-based context manager

import time

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

    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"took {self.elapsed:.3f}s, exception: {exc_type.__name__ if exc_type else None}")
        return False

with Timer() as t:
    time.sleep(0.05)
# took 0.050s, exception: None

__enter__ returns self, so the block and later code can read t.elapsed. The class form is best when the manager carries state or offers extra methods, such as a database session or a test server.

Exceptions and the return value of __exit__

__exit__ receives details of any exception, and its return value decides what happens next:

with Timer():
    1 / 0
# took 0.000s, exception: ZeroDivisionError
# ZeroDivisionError still raised

Our Timer returned False, so the exception continued to the caller after the timing was printed. Returning True tells Python the exception has been handled:

class Swallow:
    def __enter__(self): return self
    def __exit__(self, *a): return True

with Swallow():
    raise KeyError("hidden")
print("program continued")

The KeyError vanished completely. That is occasionally what you want, but usually it hides bugs. Note that returning None, which is what a method with no return does, counts as false and lets the exception through. That is the safe default. Our guide to Python exception handling explains why silently swallowing errors is dangerous.

The easy way: contextlib.contextmanager

For simple cases, a generator function with one yield is shorter than a class. Everything before yield is setup, the yielded value goes to as, and everything after is cleanup:

import contextlib, os

@contextlib.contextmanager
def temp_env(name, value):
    old = os.environ.get(name)
    os.environ[name] = value
    try:
        yield
    finally:
        if old is None:
            del os.environ[name]
        else:
            os.environ[name] = old

with temp_env("APP_MODE", "test"):
    print(os.environ["APP_MODE"])   # test
print(os.environ.get("APP_MODE"))   # None

The variable existed inside the block and was removed afterwards, restoring the original state. This pattern is perfect for temporarily changing environment variables, the working directory or a global setting.

The missing finally trap

Here is the mistake almost everyone makes once:

@contextlib.contextmanager
def broken():
    print("setup")
    yield
    print("cleanup (never runs on error)")

with broken():
    raise RuntimeError("x")
# setup
# (cleanup skipped)

When the block raises, the exception is thrown into the generator at the yield line. Without try/finally, the code after yield never runs. Our cleanup line was never printed. Always wrap the yield in try/finally when cleanup must happen, as in temp_env above.

Tip: A good test for any context manager you write is to raise an exception inside the with block and check that the cleanup still happened. It takes two lines and catches exactly this bug.

Useful built-in context managers

Manager What it handles
open() Closes the file
threading.Lock() Acquires and releases the lock
sqlite3.connect() Commits or rolls back a transaction
tempfile.TemporaryDirectory() Deletes the folder afterwards
contextlib.suppress(Error) Ignores one specific exception type
contextlib.chdir(path) Changes directory and changes back (Python 3.11+)
decimal.localcontext() Temporary decimal precision
unittest.mock.patch() Temporary replacement in tests

With a lock, our check printed locked: True inside the block and released: True after it. contextlib.suppress(FileNotFoundError) let us delete a file that might not exist without a try/except. Use suppress only for errors you truly expect and do not care about.

You can also open several managers in one statement, as in with open(a) as x, open(b) as y:. Our test read both files and closed both.

ExitStack: a variable number of managers

When you do not know in advance how many resources you need, ExitStack collects them and cleans up all of them at the end:

with contextlib.ExitStack() as stack:
    files = [stack.enter_context(open(n, "w")) for n in names]
    for i, fh in enumerate(files):
        fh.write(str(i))

print(all(fh.closed for fh in files))   # True

All three files were closed. If opening the third had failed, the first two would still have been closed, which is hard to get right by hand. ExitStack also has a callback() method for registering any cleanup function.

Database transactions: a surprising detail

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t (x INT)")

with conn:
    conn.execute("INSERT INTO t VALUES (1)")
    raise ValueError("abort")
# rows after error: 0

with conn:
    conn.execute("INSERT INTO t VALUES (2)")
# rows after success: 1

A sqlite3 connection used in with commits if the block succeeds and rolls back if it fails, exactly what a transaction should do. But note what it does not do: the connection was still open afterwards. Unlike open(), the sqlite3 connection’s context manager manages the transaction, not the connection. Close it separately, for example with contextlib.closing(conn). Always check what a library’s context manager actually does; the name of the thing is not enough.

Async context managers

Asynchronous code uses async with and the methods __aenter__ and __aexit__, or @contextlib.asynccontextmanager. You will meet them with HTTP client sessions, database pools and asyncio.TaskGroup, which we cover in our asyncio guide.

Context managers in tests

Testing relies heavily on temporary changes that must be undone: patching functions, setting environment variables, creating temporary files. unittest.mock.patch works as a context manager, as shown in our mocking guide, and pytest’s raises is one too: with pytest.raises(ValueError): .... pytest fixtures with yield follow the same setup-yield-cleanup shape, described in our pytest fixtures guide.

Common mistakes

  • No try/finally around yield in a generator-based manager.
  • Returning True from __exit__ by accident, which hides every exception.
  • Using the resource after the block. A file object still exists after with, but it is closed.
  • Assuming what cleanup happens. As with sqlite3, read the documentation.
  • Yielding more than once in a @contextmanager function, which raises a RuntimeError.

When to write your own context manager

Look for a pair of actions that must always happen together, where the second one is easy to forget. Good signs include:

  • The same try/finally block appears in several places.
  • Code changes some global state, such as a setting, a working directory or a feature flag, and must restore it.
  • A resource is acquired from a pool and must be returned, such as a connection or a temporary account in a test environment.
  • You want to time, trace or log a block of code consistently.

If the cleanup is a single call that already has a context manager, such as open() or a lock, use that instead of writing a new one. And if the “cleanup” needs data produced inside the block, a context manager may hide too much; a plain function with explicit steps can be clearer. The goal is fewer ways to forget cleanup, not cleverness.

Final thoughts

Context managers turn “remember to clean up” into a guarantee. Use with for every file, lock, connection and temporary change. When you write your own, choose a generator with try/finally for simple cases and a class when you need state, and test them by raising an exception inside the block.

Frequently asked questions

What is a context manager in Python?

It is an object that defines setup and cleanup through __enter__ and __exit__ methods. The with statement calls both, running cleanup even when an exception occurs.

How do I create a context manager?

Write a class with __enter__ and __exit__, or decorate a generator function with @contextlib.contextmanager and yield once, with cleanup in a finally block.

Does with close the file if an exception happens?

Yes. In our test, the file was closed and its content saved even though the block raised a ValueError.

What happens if __exit__ returns True?

The exception is suppressed and execution continues after the with block. Return False or None to let exceptions propagate.

Does with sqlite3.connect() close the connection?

No. It commits or rolls back the transaction, but the connection stays open. Close it separately or use contextlib.closing.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →