Using Prisma with PgBouncer and Serverless Functions

This guide is part of Prisma Connection Pool Management. A serverless function platform runs one pool per execution environment, and it creates execution environments as fast as traffic arrives. A Prisma client with default settings in each of them turns a traffic spike into a connection spike, and PostgreSQL starts refusing sessions long before the functions run out of capacity. Adding PgBouncer fixes the connection count and introduces a second class of error, because Prisma’s prepared statements and migrations assume a session that transaction pooling does not provide:

FATAL:  sorry, too many clients already
PrismaClientUnknownRequestError: Error occurred during query execution:
ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(PostgresError {
  code: "42P05", message: "prepared statement \"s3\" already exists", severity: "ERROR" }) })

The objective is a configuration where each function instance holds at most one connection, the proxy multiplexes those instances onto a small backend pool, queries work through transaction pooling, and migrations bypass the proxy.

Rapid incident diagnosis

Count instances, not requests. The connection count follows the number of concurrent execution environments. On AWS Lambda, read ConcurrentExecutions for the function over the incident window and multiply by the pool size each instance can open:

aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name ConcurrentExecutions \
  --dimensions Name=FunctionName,Value=orders-api --statistics Maximum \
  --start-time 2026-09-15T09:00:00Z --end-time 2026-09-15T10:00:00Z --period 60

If 180 concurrent instances ran with the default connection_limit on a two-vCPU configuration (2 × 2 + 1 = 5), the function could open 900 connections.

Confirm from PostgreSQL how many sessions came from the functions, and how many were idle:

SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE application_name LIKE 'orders-fn%'
GROUP BY application_name, state
ORDER BY count(*) DESC;

A large idle share is normal for functions: a frozen execution environment keeps its sockets open while it waits for the next invocation, and each of those sockets holds a server slot.

Identify the proxy-related errors.

  • prepared statement "sN" already exists or prepared statement "sN" does not exist: Prisma is sending named prepared statements through a transaction-mode pooler that cannot track them.
  • prisma migrate deploy hanging or failing with advisory lock errors: migrations are running through the pooler.
  • P2024 with low database session counts: the function’s own pool_timeout is expiring because the proxy is queueing clients. Check the proxy’s waiting-client metric.
Connections opened by concurrent function instances At 180 concurrent instances, a default connection_limit of 5 can open 900 connections, far above a max_connections of 200. A limit of 1 opens at most 180, and a pooler reduces backend connections to 20. Connections at 180 concurrent instances max_connections 200 default limit 5: 900 limit 1: 180 limit 1 + pooler: 20 only the last stays well inside the server's slots during a larger spike
Instance concurrency multiplies the per-instance pool. With the default limit, a modest spike exceeds the server's slots even though each instance only ever runs one request at a time.

Mathematical sizing or parameter formula

A single function instance handles one invocation at a time on most platforms, so it never needs more than one connection for ordinary queries:

function_connections = max_concurrent_instances × connection_limit     (connection_limit = 1)

Without a proxy, that product must fit the budget directly:

max_concurrent_instances ≤ usable_slots_for_this_function

With a proxy in transaction mode, the function connections become proxy clients, and the backend requirement is set by how many transactions are running at once:

backend_pool = ceil( transactions_per_second × mean_transaction_seconds ÷ target_utilisation )

Worked example. Peak load is 1,200 invocations per second, each running two queries in 6 ms total, with no interactive transactions. Backend demand is 1200 × 0.006 = 7.2 busy connections; at 70 % utilisation the proxy needs a default_pool_size of 11. Concurrency peaks at about 250 instances, so the proxy’s max_client_conn must allow 250 clients plus headroom — 1,000 is comfortable. The database now sees 11 backend connections instead of 250 or 1,250.

Two adjustments apply. If a function on your platform can serve several invocations concurrently in one instance, multiply connection_limit by that per-instance concurrency. And set the Lambda reserved concurrency (or the equivalent maximum instances setting) so the client count has a known ceiling even with the proxy in place; an unbounded fleet will eventually exhaust max_client_conn too.

Prisma functions behind a transaction pooler with a direct migration path Function instances each hold one client connection to PgBouncer on port 6432 with pgbouncer=true. PgBouncer keeps 11 backend connections to PostgreSQL. The CI migration job connects directly on port 5432 using directUrl. instance 1 · limit 1 instance 2 · limit 1 instance 250 · limit 1 PgBouncer :6432 pool_mode transaction max_client_conn 1000 default_pool_size 11 PostgreSQL :5432 11 backend connections + migration session CI migrate job directUrl bypasses the pooler session features intact
Behind a transaction-mode pooler, hundreds of function instances share a backend pool sized by concurrent transactions. Migrations take a separate direct path.

Exact remediation & configuration

Environment for the functions. The runtime URL goes through the pooler with prepared statements disabled; the direct URL is used only by migrations and introspection:

# Function runtime
DATABASE_URL="postgresql://orders_fn:${DB_PASSWORD}@pgbouncer.internal:6432/orders?pgbouncer=true&connection_limit=1&pool_timeout=5&connect_timeout=5&application_name=orders-fn"
# CI only
DIRECT_URL="postgresql://orders_migrator:${MIGRATOR_PASSWORD}@orders-db.internal:5432/orders"
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

If PgBouncer is version 1.21 or later with max_prepared_statements configured, pgbouncer=true is no longer required, and keeping prepared statements improves query planning overhead. Check with SHOW VERSION; on the PgBouncer console before removing it. On newer Prisma releases the datasource URLs may live in prisma.config.ts instead of the schema file; the principle — runtime through the pooler, migrations direct — is unchanged.

Construct the client once per instance. Module scope runs once per execution environment, so warm invocations reuse the same client and its single connection:

// handler.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();   // module scope: created at cold start only

export const handler = async (event: { orderId: string }) => {
  const order = await prisma.order.findUnique({ where: { id: event.orderId } });
  return { statusCode: order ? 200 : 404, body: JSON.stringify(order) };
  // no $disconnect(): the next invocation on this instance reuses the connection
};

Calling $disconnect() at the end of each invocation looks tidy but forces every invocation to open a new connection — a TCP and TLS handshake to the pooler each time — and does not reduce the number of concurrent connections, which is set by instance count.

Bound concurrency.

aws lambda put-function-concurrency --function-name orders-api --reserved-concurrent-executions 300

Size the pooler.

[databases]
orders = host=orders-db.internal port=5432 dbname=orders

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 11
reserve_pool_size = 4
reserve_pool_timeout = 2
server_idle_timeout = 300
client_idle_timeout = 900       ; reclaim sockets held by long-frozen instances
max_prepared_statements = 0     ; with pgbouncer=true; set to 200 on 1.21+ to keep prepares

client_idle_timeout matters for functions specifically: execution environments that the platform has frozen and then discarded may never close their sockets cleanly, and without an idle timeout they occupy client slots until TCP keepalive notices.

Run migrations from CI, not from the function. Migrations need the direct URL, a role with DDL privileges, and a single runner. Invoking prisma migrate deploy inside a function’s cold start runs it concurrently from many instances.

The proxy choice itself — self-managed PgBouncer, RDS Proxy or a managed pooler — is compared in Choosing a Connection Proxy for Serverless Postgres.

Validation & verification

Prepared statements through the pooler. Run a burst of concurrent invocations and search the function logs for 42P05 and 26000 (prepared statement does not exist). Both counts must be zero:

aws logs filter-log-events --log-group-name /aws/lambda/orders-api \
  --start-time $(date -d '-15 min' +%s000) \
  --filter-pattern '?"42P05" ?"26000" ?"prepared statement"' --query 'length(events)'

Backend connections stay flat while clients scale. During a load test that ramps concurrency from 10 to 300 instances, sample both sides:

-- On PostgreSQL: should plateau at default_pool_size + reserve_pool_size
SELECT count(*) FROM pg_stat_activity WHERE usename = 'orders_fn';
-- On the PgBouncer console: cl_active grows with instances, sv_active stays at or below 15
SHOW POOLS;
Pooler clients and backend connections during a concurrency ramp As function concurrency ramps from 10 to 300 instances over ten minutes, cl_active on PgBouncer rises in step, while backend connections on PostgreSQL rise to 11 and never exceed the 15 allowed by pool plus reserve. cl_active (PgBouncer clients) backend sessions (PostgreSQL) pool + reserve = 15 10 instances 300 instances after 10 min clients grow with instances
The expected shape of a correct ramp test: client connections to the pooler follow instance concurrency, while backend connections plateau at the pool size plus reserve.

Migrations bypass the pooler. Run npx prisma migrate deploy from CI with verbose logging and confirm the connection target is port 5432. A migration that succeeds only intermittently is almost always going through a pooler.

Cold starts do not reconnect on warm invocations. Invoke the same function sequentially 50 times and check the pooler’s SHOW STATS total_xact_count against new client logins: logins should increase by roughly the number of cold starts, not by 50.

Cold starts, connection setup and latency

Setting connection_limit=1 and routing through a pooler fixes the connection count, but every cold start still pays for a new client connection, and in a function platform cold starts are frequent during scale-out. The first query in a new execution environment includes the engine start-up, a TCP handshake to the pooler, TLS negotiation and authentication.

Three measures keep that cost down.

Keep the pooler close. Place PgBouncer in the same region and network as the functions. A pooler across regions adds tens of milliseconds to every cold start and every query. For VPC-attached Lambda functions, run PgBouncer inside the same VPC, or use RDS Proxy, which is reachable through VPC endpoints without internet routing.

Use lighter authentication to the pooler. The pooler must authenticate each new client connection. SCRAM-SHA-256 is the right default, but its cost is per connection; with thousands of cold starts per minute, check the pooler’s CPU during scale-out. If authentication dominates, larger instances or several pooler processes help more than tuning Prisma.

Connect eagerly during initialisation. Code at module scope runs during the function’s initialisation phase. Calling $connect() there moves connection setup out of the first invocation’s measured duration and, on platforms that initialise environments ahead of traffic, out of user-facing latency entirely:

const prisma = new PrismaClient();
const ready = prisma.$connect();          // starts during initialisation

export const handler = async (event: { orderId: string }) => {
  await ready;                            // resolves immediately on warm invocations
  return prisma.order.findUnique({ where: { id: event.orderId } });
};

Measure the effect rather than assuming it. Compare the p99 duration of cold-start invocations — identifiable through the platform’s initialisation duration field — before and after each change:

Change Cold-start latency effect Connection count effect
Pooler in the same VPC and region Large reduction None
$connect() at module scope Moves setup into initialisation None
Provisioned concurrency Removes most cold starts Holds connections permanently for provisioned instances
Larger pooler instance or more processes Reduces handshake queueing during scale-out None

Provisioned concurrency deserves a budget note: every provisioned environment holds its connection to the pooler continuously, so 50 provisioned instances are 50 permanent pooler clients even at zero traffic. That is harmless for PgBouncer’s client limit, but it matters when functions connect directly to PostgreSQL.

Frequently Asked Questions

Is connection_limit=1 too small if a handler runs queries in parallel?
If a handler uses Promise.all over several queries, they queue on the single connection and run one after another. That is usually acceptable in a function — the total time is the sum of short queries — and far safer than letting every instance open several connections. If parallel queries matter, raise the limit to 2 and halve the concurrency cap.
Do interactive transactions work through transaction pooling?
Yes. PgBouncer assigns a server connection at BEGIN and keeps it until COMMIT, and Prisma sends the whole interactive transaction on one connection. Keep them short: in a function, a long interactive transaction holds a backend connection that hundreds of other instances are queuing for.
What about RDS Proxy instead of PgBouncer?
RDS Proxy works with Prisma and handles IAM authentication and failover, but prepared statements cause session pinning there rather than errors. Keep pgbouncer=true or equivalent statement settings to avoid pinning, and monitor DatabaseConnectionsCurrentlySessionPinned.
Should edge runtimes use the same setup?
Edge runtimes generally cannot open raw TCP connections, so they use an HTTP- or WebSocket-based driver adapter to a pooling service. The budget reasoning is the same — one logical client per isolate, a bounded backend pool — but the parameters belong to the adapter and the service, not to connection_limit.
Does pgbouncer=true slow queries down?
It disables named prepared statements, so each query is parsed and planned by PostgreSQL every time instead of reusing a plan. For short OLTP queries the difference is usually small; for complex queries executed very frequently it can be measurable, which is the main reason to move to PgBouncer 1.21 or later with max_prepared_statements enabled.