DEV Community

Abhinav Pasham
Abhinav Pasham

Posted on

ASYNC CONTEXT MANAGERS

Why Does Python Need Async Context Managers?

Understanding Resource Pooling with a Fake Database Connection

INTRODUCTION

While learning asyncio, I came across something called an Async Context Manager.

Initially, I thought it was just another way of writing try...finally.

Then I started learning about database connection pools.

That's when another question came to my mind.

Every request needs a database connection. Does every request create a new connection?

If that's true, creating and destroying database connections for every request would be expensive.

So how do frameworks like FastAPI and libraries like asyncpg manage thousands of requests efficiently?

This led me to two important concepts:

  • Resource Pooling
  • Async Context Managers (async with)

In this article, I'll explain the problem they solve and how they work together.


What You Will Learn

  • Why resource pooling exists
  • Why creating database connections repeatedly is expensive
  • How connection pools work
  • Why Python introduced async context managers
  • How async with works internally
  • Building a fake resource pool

Prerequisites

Before reading this article, you should understand:

  • Coroutines
  • Event Loop
  • await
  • Classes
  • asyncio

The Problem

Suppose you're building a backend.

Whenever a request arrives,

it needs to query the database.

Initially, I thought the flow looked like this.

Request

↓

Create Database Connection

↓

Execute Query

↓

Close Connection
Enter fullscreen mode Exit fullscreen mode

Seems perfectly fine.

Now imagine

1000 users send requests simultaneously.

Request 1

Request 2

Request 3

...

Request 1000
Enter fullscreen mode Exit fullscreen mode

If every request creates a brand new database connection,

the server has to

  • Open a network connection
  • Authenticate the user
  • Allocate memory
  • Establish communication

for every request.

Creating database connections is expensive.

So another question came into my mind.

Instead of creating new connections every time, why can't we reuse existing ones?

That's exactly why Resource Pooling exists.


What is Resource Pooling?

Instead of creating a connection for every request,

the application creates a small number of reusable connections.

Imagine

Pool

Connection 1

Connection 2

Connection 3
Enter fullscreen mode Exit fullscreen mode

Now,

when a request arrives,

it doesn't create a new connection.

It simply borrows one.

Request

↓

Take Connection 2

↓

Execute Query

↓

Return Connection 2

↓

Pool
Enter fullscreen mode Exit fullscreen mode

The next request can reuse the same connection.


The Next Question

After understanding connection pools,

another question came to my mind.

Suppose I borrow a connection.

How do I make sure it's always returned to the pool?

Imagine this code.

conn = await pool.acquire()

await conn.execute(query)

await pool.release(conn)
Enter fullscreen mode Exit fullscreen mode

Looks fine.

But what if

await conn.execute(query)

raise Exception()
Enter fullscreen mode Exit fullscreen mode

The exception occurs before

await pool.release(conn)
Enter fullscreen mode Exit fullscreen mode

The connection is never returned.

After enough requests,

every connection remains occupied.

Eventually,

the pool becomes empty.

New requests have no available connections.

This is called a connection leak.


Python's Solution

Python introduced

async with
Enter fullscreen mode Exit fullscreen mode

Instead of writing

conn = await pool.acquire()

try:
    await conn.execute(query)
finally:
    await pool.release(conn)
Enter fullscreen mode Exit fullscreen mode

we simply write

async with pool as conn:

    await conn.execute(query)
Enter fullscreen mode Exit fullscreen mode

Much cleaner.

More importantly,

the connection is always returned,

even if an exception occurs.


How Python Achieves This

When Python sees

async with pool as conn:
Enter fullscreen mode Exit fullscreen mode

it automatically performs

Acquire Connection

↓

Execute Block

↓

Release Connection
Enter fullscreen mode Exit fullscreen mode

Internally,

Python converts it into

conn = await pool.__aenter__()

try:

    await conn.execute()

finally:

    await pool.__aexit__()
Enter fullscreen mode Exit fullscreen mode

This means

__aenter__() acquires the resource,

while

__aexit__() releases it.


Building a Fake Resource Pool

Let's build a simple version.

class FakeConnection:

    async def execute(self, query):

        print(f"Executing: {query}")

        await asyncio.sleep(2)

        print("Query Completed")
Enter fullscreen mode Exit fullscreen mode

Now we create a resource pool.

class ResourcePool:

    async def __aenter__(self):

        print("Acquiring Connection...")

        await asyncio.sleep(1)

        self.connection = FakeConnection()

        print("Connection Acquired")

        return self.connection

    async def __aexit__(self, exc_type, exc, tb):

        print("Releasing Connection...")

        await asyncio.sleep(1)

        print("Connection Released")
Enter fullscreen mode Exit fullscreen mode

Using it becomes very simple.

async with ResourcePool() as conn:

    await conn.execute(
        "SELECT * FROM users"
    )

    print("Working with database...")
Enter fullscreen mode Exit fullscreen mode

Execution Flow

Program Starts

↓

async with

↓

__aenter__()

↓

Acquire Connection

↓

Return Connection

↓

Execute Query

↓

Exit async with

↓

__aexit__()

↓

Return Connection

↓

Program Ends
Enter fullscreen mode Exit fullscreen mode

Real-world Use Cases

Resource pooling is used almost everywhere.

  • Database Connections (asyncpg)
  • HTTP Client Sessions (aiohttp)
  • Redis Connections
  • WebSocket Connections
  • File Handles

Instead of creating expensive resources repeatedly,

applications reuse them.


Advantages

  • Reuses expensive resources
  • Improves performance
  • Prevents connection leaks
  • Automatic cleanup
  • Cleaner code
  • Easier error handling

Conclusion

Initially, I thought every request created and destroyed its own database connection.

Later I learned that this approach doesn't scale.

Instead, applications maintain a pool of reusable connections.

But simply reusing connections isn't enough.

We also need a reliable way to return them back to the pool, even when something goes wrong.

That's exactly why Python provides Async Context Managers.

Once I understood that async with automatically acquires and releases resources, the idea of connection pooling became much easier to understand.

In the next article, we'll explore another interesting concept:

How can a coroutine produce values one at a time while still performing asynchronous operations?

That's where Async Generators come in.

Top comments (0)