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:

  1. Prepares a resource for you.
  2. Lets you use it.
  3. 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/finally is required to guarantee the file is closed safely. The with statement 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 the with block.
  • __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 propagation

Tip: 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 Lock handles 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 places

Here, 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:

  1. Entering the block: __enter__ runs, setting up the connection.
  2. Inside the block: You can use conn as your active database connection.
  3. 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 yield runs on entry.
  • Code after yield runs 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 contextlib for 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 using contextlib, code after the yield runs 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.

2 responses to “Mastering Python Context Managers: Beyond Just with open()”

  1. james gardner Avatar
    james gardner

    also use monotonic time 🙂

    Great article, thank you!

    1. Moh Haziane Avatar

      Thanks a lot, James! Good catch! time.monotonic() is indeed a better option for measuring elapsed time reliably. Always nice to connect with readers who notice the fine details!

Leave a Reply

I’m Moh

Welcome to my blog! I’m Moh, a seasoned telecommunications engineer with over two decades of experience in the field. I’ve been passionately working in telecommunications since 2004, and for the past ten years, I’ve been delving deep into the world of programming, particularly in Python and Django.

Let’s connect

Discover more from Hazimed Tech

Subscribe now to keep reading and get access to the full archive.

Continue reading