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.
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.
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 symptom | Root Cause | Production Fix |
|---|---|---|
RuntimeError: ... got Future <Future pending> attached to a different loop | A pooled connection from a previous asyncio.run() used on a new loop. | poolclass=NullPool, or a persistent module-level loop. |
RuntimeError: Event loop is closed | Same cause, surfacing when the old connection's transport is touched. | Same fix. |
too many connections for role "shop" / remaining connection slots are reserved | Concurrent 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 ConnectionDoesNotExistError | A 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 PgBouncer | Transaction 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 ms | Connection 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. |
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.
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.
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.
Related
- Tuning Connection Pools for Cloud Databases — The parent guide: pool sizing for managed databases.
- Setting pool_size and max_overflow for AWS RDS — Sizing for long-running processes against RDS.
- Using IAM database authentication with async engines — Short-lived credentials, which suit per-invocation connections.
- Handling asyncpg prepared statement errors with PgBouncer — Required settings behind a transaction-pooling proxy.