Using NullPool for serverless and AWS Lambda

Create the engine with poolclass=NullPool when each invocation runs its own asyncio.run() — a pooled asyncpg connection is tied to the event loop that opened it, and Lambda closes that loop between invocations — and put RDS Proxy or PgBouncer in front of the database so opening a connection per invocation stays cheap. This guide belongs to tuning connection pools for cloud databases.

Quick Answer

Serverless handlers look like short scripts, and the default pool assumes a long-running process with one event loop. The mismatch shows up on the second warm invocation.

A pool that outlives its event loop Five steps. A cold start creates the engine at module level. Invocation one runs asyncio.run, checks out an asyncpg connection bound to that loop, and returns it to the pool; asyncio.run then closes the loop. The environment is frozen and thawed. Invocation two runs asyncio.run with a new loop and checks out the pooled connection, which still belongs to the closed loop. The query fails with a different-loop or closed-loop error. cold start engine = create_async_engine(...) module level, reused while warm invocation 1: asyncio.run() connection opened on loop A returned to the pool loop A closed, environment frozen connection still in the pool bound to loop A invocation 2: asyncio.run() loop B checks out that connection attached to a different loop fix NullPool, or one long-lived loop A pooled asyncpg connection can only be used from the event loop that created it.

Before — a default pool reused across invocations:

import asyncio

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

from shop.models import Order

engine = create_async_engine("postgresql+asyncpg://shop:secret@proxy.internal/shop")
Session = async_sessionmaker(engine, expire_on_commit=False)


async def main(order_id: int) -> dict:
    async with Session() as session:
        order = await session.get(Order, order_id)
        return {"id": order.id, "status": order.status}


def handler(event, context):
    return asyncio.run(main(int(event["order_id"])))
# Second warm invocation:
# RuntimeError: Task <Task pending ...> got Future <Future pending ...> attached to a different loop

After — NullPool, so no connection outlives its loop:

import asyncio

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool

from shop.models import Order

engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@proxy.internal/shop",
    poolclass=NullPool,
    connect_args={"timeout": 5, "server_settings": {"application_name": "orders-lambda"}},
)
Session = async_sessionmaker(engine, expire_on_commit=False)


async def main(order_id: int) -> dict:
    async with Session() as session:
        order = await session.get(Order, order_id)
        return {"id": order.id, "status": order.status}


def handler(event, context):
    return asyncio.run(main(int(event["order_id"])))

The engine object is still created once per execution environment, which keeps dialect initialisation and compiled-statement caching warm. Only the connections are per invocation.

Execution Context & Async Workflow Integration

AWS Lambda runs a handler inside an execution environment that is reused for later invocations while warm. Module-level objects — the engine, the session factory — survive between invocations. Between invocations the environment is frozen: no code runs, timers do not fire, and sockets are left as they were.

Two ways to make it work Left: NullPool opens a new connection at checkout and closes it at checkin, so nothing survives between invocations and each asyncio.run is independent; the cost is connection setup on every invocation, which a proxy absorbs. Right: create one event loop at module level and run every invocation on it with run_until_complete, so a small pool with pool_size one stays valid across warm invocations; connections may still be closed while frozen, so pool_pre_ping is required. NullPool + asyncio.run() new connection every invocation nothing crosses loop boundaries setup cost each time: TLS + auth pair with RDS Proxy or PgBouncer persistent loop + tiny pool loop = asyncio.new_event_loop() loop.run_until_complete(main()) pool_size=1, pool_pre_ping=True reuses warm connections NullPool is the default recommendation: it is correct without reasoning about freezes and loops.

The handler itself is synchronous, so async code needs an event loop, and asyncio.run() is the obvious way to get one. asyncio.run() creates a loop, runs the coroutine, and closes the loop. A second invocation creates a second loop. An asyncpg connection, however, is permanently bound to the loop that created it: its socket transport and futures belong to that loop. A default AsyncAdaptedQueuePool keeps the connection from invocation one and hands it to invocation two, whose loop cannot use it. Depending on timing, that surfaces as got Future attached to a different loop, Event loop is closed, or InterfaceError: cannot perform operation: another operation is in progress.

NullPool removes the problem by not pooling. Each checkout opens a new connection and each checkin closes it, so every connection is created and destroyed inside one asyncio.run(). It also sidesteps the freeze: a pooled connection that sat frozen for twenty minutes may have been closed by the server, the proxy or a NAT gateway, and would need pool_pre_ping to detect.

The cost is connection setup on every invocation — TCP, TLS and authentication, typically a few tens of milliseconds to a nearby database and much more for SCRAM authentication across regions. That is where a connection proxy earns its place. RDS Proxy or PgBouncer keeps warm connections to the database and accepts new client connections cheaply, so NullPool's "new connection" becomes a short hop to the proxy. The proxy also solves the other serverless problem, which is count: see the arithmetic in the advanced section below.

The alternative, when connection setup must be avoided, is to stop creating loops. A module-level loop reused with run_until_complete() keeps pooled connections valid across warm invocations:

import asyncio

from sqlalchemy.ext.asyncio import create_async_engine

loop = asyncio.new_event_loop()
engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@proxy.internal/shop",
    pool_size=1, max_overflow=0, pool_pre_ping=True, pool_recycle=300,
)


def handler(event, context):
    return loop.run_until_complete(main(int(event["order_id"])))

pool_size=1 matches Lambda's one-request-per-environment model, and pool_pre_ping catches connections closed while frozen. It works, and it requires every async library in the handler to tolerate a loop that is paused for arbitrary periods — which is why NullPool is the default recommendation.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
RuntimeError: ... got Future <Future pending> attached to a different loopA pooled connection from a previous asyncio.run() used on a new loop.poolclass=NullPool, or a persistent module-level loop.
RuntimeError: Event loop is closedSame cause, surfacing when the old connection's transport is touched.Same fix.
too many connections for role "shop" / remaining connection slots are reservedConcurrent environments each holding connections, multiplied by pool size.NullPool or pool_size=1, plus RDS Proxy or PgBouncer.
First query after a quiet period fails with ConnectionDoesNotExistErrorA pooled connection was closed while the environment was frozen.NullPool; or pool_pre_ping=True with a persistent loop.
prepared statement "__asyncpg_stmt_1__" does not exist behind PgBouncerTransaction pooling does not keep asyncpg's prepared statements on one server connection.Disable asyncpg's statement cache; see the PgBouncer guide.
Invocations slow by 50–300 msConnection setup per invocation without a proxy, or TLS and SCRAM to a distant database.A proxy in the same VPC; keep the engine at module level.
Connections at 200 concurrent invocations Bar chart. A pool_size of 5 per environment would allow 1,000 connections, far beyond a typical database limit. A pool_size of 1 or NullPool means about 200 connections, one per concurrent invocation. Through RDS Proxy or PgBouncer, the database sees a small shared pool, for example 40 connections, because the proxy multiplexes. pool_size=5 per environment up to 1,000 database connections NullPool or pool_size=1 ≈ 200: one per concurrent invocation NullPool behind RDS Proxy / PgBouncer ≈ 40 at the database: the proxy multiplexes Concurrency, not request rate, sets the connection count. A proxy is what decouples the two.

The too many connections row is the one that turns into an incident. Lambda scales by adding execution environments, one per concurrent request, and each environment has its own engine. With a default pool_size=5, two hundred concurrent invocations can open a thousand connections — and even a single idle connection per warm environment adds up, because warm environments linger after traffic drops. NullPool means an environment holds a connection only while an invocation is running.

The PgBouncer row catches teams that add a proxy to fix the connection count. In transaction pooling mode, consecutive statements from one client may run on different server connections, and asyncpg's server-side prepared statements do not follow them. The fix is to disable asyncpg's statement caches and give SQLAlchemy's prepared statements unique names, as described in handling asyncpg prepared statement errors with PgBouncer. RDS Proxy has a related behaviour: some session-level state, prepared statements among it, can pin a client to one database connection, reducing multiplexing. Watch the DatabaseConnectionsCurrentlySessionPinned metric after rollout.

Advanced: Connection Arithmetic, Proxies and Platform Differences

The number that decides whether a serverless service can talk to a relational database is peak concurrency — not requests per second. Two hundred requests per second that each take 50 ms need about ten concurrent executions; two hundred requests per second that each wait two seconds on a slow downstream need four hundred.

Match the pool to the platform Four tiles. AWS Lambda and Google Cloud Functions first generation handle one request per instance, so NullPool or a pool of one fits. Cloud Run and Cloud Functions second generation can handle many concurrent requests per instance, so a small real pool fits, sized to per-instance concurrency. Long-running containers on ECS or Kubernetes use ordinary pool sizing. AWS Lambda one request per environment NullPool (+ proxy) Cloud Functions (1st gen) one request per instance NullPool (+ proxy) Cloud Run / Functions 2nd gen many requests per instance small pool ≈ concurrency ECS / Kubernetes long-lived processes ordinary pool sizing "Serverless" is not one model. What matters is requests per process and whether processes freeze.

Without a proxy, database connections at peak are roughly concurrency times connections per environment. With NullPool that is one per running invocation; with a pool, it is the pool size per warm environment, running or not. PostgreSQL's max_connections on a mid-sized instance is typically a few hundred, shared with every other client. The arithmetic runs out quickly, which is why a proxy is effectively mandatory at any real scale:

import os

from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool

# The proxy endpoint, not the instance endpoint.
DATABASE_URL = os.environ["DATABASE_URL"]   # postgresql+asyncpg://...@shop-proxy.proxy-xxxx.rds.amazonaws.com/shop

engine = create_async_engine(
    DATABASE_URL,
    poolclass=NullPool,
    connect_args={
        "timeout": 5,
        # Behind a transaction-pooling proxy, do not rely on server-side prepared statements.
        "statement_cache_size": 0,
        "prepared_statement_cache_size": 0,
        "server_settings": {"application_name": "orders-lambda"},
    },
)

Lambda's reserved concurrency is the other lever. Setting it on the function caps concurrent environments, and therefore caps connections even without a proxy — at the cost of throttling requests above the cap. For a function that writes to a small database, a deliberate cap is often better than letting a traffic spike exhaust connections for every other client.

Not every serverless platform shares Lambda's model. Google Cloud Run and second-generation Cloud Functions send many concurrent requests to one instance, and instances are long-lived, with a single event loop per process in an ASGI server. There, a small ordinary pool sized to per-instance concurrency is correct and NullPool would waste connection setup. First-generation Cloud Functions and Azure Functions on the consumption plan are closer to Lambda. The deciding questions are the same everywhere: how many requests does one process handle at once, does the process freeze between requests, and does each request get a new event loop?

The sizing logic for the long-running case — pool size from concurrency, and max_overflow as a burst buffer — is worked through in setting pool_size and max_overflow for AWS RDS.

Structuring a Handler That Stays Correct

Beyond the pool, a few handler-level habits keep serverless database code correct as it grows — most of them about making each invocation self-contained.

Four habits for serverless handlers Four bands. Keep only the engine and session factory at module scope. Wrap database work in asyncio.timeout shorter than the remaining invocation time so the session can roll back. Set statement_timeout and idle_in_transaction_session_timeout on the server. Test the warm path by calling the handler twice in one process. module scope: engine and session factory only never sessions, results or ORM objects — they would cross invocations asyncio.timeout(remaining time − 1 s) the session rolls back cleanly before Lambda kills the invocation statement_timeout, idle_in_transaction_session_timeout bounds what a killed invocation can leave behind on the server test: call the handler twice in one process reproduces the event-loop and pool failures of warm invocations

Keep module scope to configuration. The engine and session factory at module level are fine, because with NullPool they hold no connections. Sessions, results and ORM objects must not live at module level, where they would leak state from one invocation into the next.

Put a deadline on database work. Lambda kills an invocation at its timeout with no chance to clean up, which can leave a transaction open on the proxy until the server notices. A timeout slightly shorter than the function's own lets the session roll back cleanly:

import asyncio

from shop.db import Session
from shop.orders import load_order_summary


async def main(order_id: int, remaining_ms: int) -> dict:
    budget = max(0.5, remaining_ms / 1000 - 1.0)   # leave a second for cleanup
    async with asyncio.timeout(budget):
        async with Session() as session:
            return await load_order_summary(session, order_id)


def handler(event, context):
    return asyncio.run(main(int(event["order_id"]), context.get_remaining_time_in_millis()))

Set server-side limits too. statement_timeout and idle_in_transaction_session_timeout in server_settings bound what happens on the database side if the function is killed mid-transaction anyway.

Load everything the response needs, explicitly. Lazy loading raises under async regardless of platform, and in a handler that serialises ORM objects to JSON after the session has closed it raises in the least helpful place. Load relationships with selectinload() inside the session block and build plain dictionaries before returning.

Test the warm path. Most serverless database bugs appear on the second invocation in an environment. A local test that calls the handler twice in a row, in the same process, reproduces the event-loop and pooling failures in this guide in milliseconds:

from shop import lambda_handler


def test_handler_survives_warm_invocations(seeded_order_id):
    first = lambda_handler.handler({"order_id": seeded_order_id}, FakeContext())
    second = lambda_handler.handler({"order_id": seeded_order_id}, FakeContext())
    assert first == second

With NullPool that test passes; switch the engine back to a default pool and it fails on the second call, which is a useful regression guard. The patterns for engine creation and disposal outside serverless are in setting up an async engine from scratch.

Frequently Asked Questions

Should I use NullPool in AWS Lambda?

Yes, when each invocation calls asyncio.run(), which is the common pattern. It keeps every connection inside one event loop and one invocation. Pair it with RDS Proxy or PgBouncer so per-invocation connection setup stays cheap.

Why does my Lambda fail only on warm invocations?

Because the pooled connection from the previous invocation belongs to an event loop that asyncio.run() has already closed. The first invocation in a fresh environment has no pooled connection to reuse, so it works.

Do I still need a proxy with NullPool?

At any meaningful concurrency, yes. NullPool bounds connections to one per running invocation, but hundreds of concurrent invocations still means hundreds of database connections without a proxy to multiplex them.

Is NullPool right for Cloud Run?

Usually not. Cloud Run instances are long-lived and handle many concurrent requests on one event loop, so a small ordinary pool sized to per-instance concurrency is more efficient.