A Hands-On Guide to Using and Creating Python Context Managers

Ever opened a file in Python, forgot to close it, and later ran into weird errors? I did. The same thing happened with a database connection — until I discovered the power of context managers and the mysterious with statement.
At the time, I treated it like magic that “handled resources somehow.” I used it for files, then for database connections, but still had only a vague idea of how it worked — and no clue I could create my own.
If your journey has been similar, this article will fill in the missing pieces.
You’ll learn:
- The simple mechanism behind every context manager.
- Why they’re useful far beyond reading files.
- How to create your own and make your code cleaner, safer, and more reliable.
By the end, you’ll not just use context managers — you’ll understand them deeply and know exactly when to build your own.
What is a Context Manager?
A context manager is an object that does three things:
- Prepares a resource for you.
- Lets you use it.
- Cleans up when you’re done — even if an error happens.
In Python, we use them with the with statement:
with open("data.txt") as file:
contents = file.read()The with statement simplifies resource management by ensuring setup and cleanup happen automatically.
In this example, when the block ends, file.close() is called automatically, even if your code throws an exception.
If we didn’t use a context manager, we would need to write it manually:
file = open("data.txt")
try:
contents = file.read()
finally:
file.close()Notice how the
try/finallyis required to guarantee the file is closed safely. Thewithstatement removes this boilerplate, making your code shorter, safer, and easier to read.
Before we look at some practical examples, let’s peek under the hood and see how the with statement works internally.
How the with Statement Actually Works?
Behind the magic, any context manager — whether it’s built into Python (like open() or sqlite3.connect()) or one you create yourself — follows the same pattern.
A context manager is simply an object that implements two special methods:
__enter__(self): Runs at the start of thewithblock.__exit__(self, exc_type, exc_value, traceback): Runs at the end, even if an error happens.
To see this in action, here’s a very simple custom context manager we’ll build ourselves — just for demonstration:
Example:
class MyContext:
def __enter__(self):
print("Entering…")
return "Resource"
def __exit__(self, exc_type, exc_value, traceback):
print("Exiting…")
if exc_type:
print(f"Error: {exc_value}")
# Return True to suppress exceptions; None lets them propagate
return None
with MyContext() as r:
print(r)
# 1 / 0 # uncomment to see exception propagationTip: Most of the time, you don’t return True, so errors are raised normally. Return True only if you want to ignore exceptions.
Built-in Context Managers
Python comes with several handy context managers built right into the language and standard library. They let you manage resources cleanly without extra code. Here are some common examples:
1. File Handling with open()
The classic example: opening files safely.
with open("log.txt", "w") as f:
f.write("Logging some data")When the block finishes, Python automatically calls f.close(), even if an error occurs while writing.
2. Database Connections
Many database APIs in Python act as context managers, committing or rolling back automatically:
import sqlite3
with sqlite3.connect("example.db") as conn:
conn.execute("CREATE TABLE IF NOT EXISTS test (id INTEGER)")Here, the connection is committed when the block ends successfully, or rolled back if an exception occurs. No extra .close() or manual transaction handling is needed.
3. Threading Locks
When working with threads, it’s important to lock resources to avoid conflicts.
from threading import Lock
lock = Lock()
with lock:
print("Locked section")The lock is acquired when entering the block and released when leaving — even if an exception happens inside.
Tip: See how
Lockhandles context management in the threading.Lock documentation.
4. Decimal Precision with localcontext()
The decimal module allows precise decimal arithmetic. You can temporarily change precision using a context manager:
from decimal import localcontext, Decimal
with localcontext() as ctx:
ctx.prec = 4
print(Decimal('1') / Decimal('7')) # Rounded to 4 decimal placesHere, precision is limited to 4 digits only while the with block runs, and it automatically resets to the default precision once the block ends, so all decimal calculations outside the block return to normal.
Tip: Check the decimal.localcontext documentation to see how it restores the original precision automatically.
These examples are just the tip of the iceberg — you can create your own context managers for almost anything.
Creating Your Own Context Managers
Earlier, we built a simple custom context manager to show how __enter__ and __exit__ work. Now, let’s take that knowledge and apply it to practical, reusable patterns you can use in real projects.
Sometimes, you need to manage resources or behaviors that Python’s standard library doesn’t cover. Creating your own context managers lets you do this cleanly and reliably. There are two main approaches:
1. Class-Based Context Managers
Best for complex setup/teardown logic. This approach is closest to the earlier MyContext example, but here we’ll make something that feels more like a real-world resource:
Example:
class DatabaseConnection:
def __enter__(self):
print("Connecting to DB...")
return self # the resource
def __exit__(self, exc_type, exc_value, traceback):
print("Closing DB connection...")
# You could also handle exceptions here if needed
with DatabaseConnection() as conn:
print("Querying DB...")Here’s what happens:
- Entering the block:
__enter__runs, setting up the connection. - Inside the block: You can use
connas your active database connection. - Exiting the block: _ _ exit
__closes the connection, even if an error was raised in step 2.
2. Function-Based Context Managers with contextlib
Best for simple, lightweight cases where a class would feel like overkill.
Example:
from contextlib import contextmanager
import time
@contextmanager
def timer():
start = time.time()
yield # control passes to the with-block here
end = time.time()
print(f"Elapsed: {end - start:.2f}s")
with timer():
sum(range(1_000_000))The function you decorate with @contextmanager must be a generator:
- Code before
yieldruns on entry. - Code after
yieldruns on exit.
This approach is perfect for small utilities — timers, temporary settings, temporary files — where you want to set something up, run some code, and then restore or clean up automatically.
For a deeper explanation of Python generators, see my previous article on generators.
Best Practices
- Use class-based context managers for complex setup/teardown logic.
- Use
contextlibfor quick, lightweight cases. - Always make sure cleanup runs even if exceptions occur inside the block. In class-based context managers, Python automatically calls the
__exit__method when the block ends, even if an error occurs. In function-based context managers usingcontextlib, code after theyieldruns on exit, handling cleanup regardless of exceptions.
Context managers are one of Python’s cleanest tools for managing resources.
Once you understand how they work, you’ll start spotting opportunities to use them everywhere — from managing API sessions to timing code execution.
Try creating one for your next project. You’ll write cleaner, safer code — and you’ll never look at the with statement the same way again.
Originally published on Medium.

Leave a Reply