Fixing Drizzle Connection Pool Configuration Errors
A migration starts, the application keeps serving, and suddenly both stall on sorry, too many clients already. Or your migration runs fine locally but on a serverless platform it opens a fresh connection per invocation until the database refuses more. Or prepared statements that worked in dev throw prepared statement "s0" does not exist the moment you put PgBouncer in front in transaction mode. These are connection-pool failures, and with Drizzle they hinge on a detail many teams miss: Drizzle is driver-agnostic, so the pool is configured by whichever driver you pass it — postgres-js, node-postgres, or a serverless HTTP driver — not by Drizzle itself. This page is for the engineer watching a deploy hang on connection errors who needs the right knob for their specific driver.
The deploy-time version of this bites hardest because the migration step and a rolling fleet of application instances reach for the same database at once, with no reserved budget between them. That shared-pool contention is one of the named failure modes in ORM & Framework Migration Workflows, and the fix is the same in spirit: give the migration its own small budget and keep it from competing with live traffic.
Symptom / Error Signatures
These messages point at pool or pooler misconfiguration.
FATAL: sorry, too many clients already(PostgreSQL) — the database hitmax_connections.ERROR 1040 (HY000): Too many connections(MySQL) — same ceiling, MySQL wording.remaining connection slots are reserved for non-replication superuser connections— you are within a few slots of the ceiling.prepared statement "s0" does not existorprepared statement "s1" already exists— a transaction-mode pooler (PgBouncer) broke a client-side prepared statement.Error: timeout exceeded when trying to connector a hungawait db.execute(...)during deploy — the application pool is exhausted and every checkout is queued.CONNECT_TIMEOUT/connection terminated unexpectedlyfrom a serverless driver opening a connection per request.
Root Cause Analysis
Drizzle delegates pooling to the driver, so the cause and the fix depend on which driver you use. The three diverge meaningfully.
| Driver | Pooling model | Common failure | Key knob |
|---|---|---|---|
postgres-js |
built-in pool, default max: 10 |
many instances × 10 exceed max_connections |
max per process; prepare: false behind a transaction pooler |
node-postgres (pg) |
Pool with max: 10 default |
unbounded if you new Client() per request |
max, idleTimeoutMillis; one Pool per process |
serverless (Neon/postgres HTTP, pg over data API) |
one connection per invocation | concurrent invocations multiply connections | route through a pooler; cap concurrency |
Two mechanisms produce nearly every incident. First, exhaustion: each application process opens its own pool, and at deploy the old fleet and the new fleet briefly run together, doubling pools, while the migration job adds its own connections — the sum crosses max_connections and the database rejects clients. Second, transaction-mode pooler breakage: PgBouncer in transaction mode hands a different backend connection to each transaction, so a client-side prepared statement created on one backend is missing on the next, yielding prepared statement "s0" does not exist. Both postgres-js (via prepare: false) and the connection string flag pgbouncer=true exist precisely to disable prepared statements for this case. The deeper reason transaction pooling matters during migrations is that DDL plus a backfill can hold sessions longer than the pooler expects; pace the backfill per Backfill Optimization so it does not pin pooled connections.
Immediate Mitigation
1. See how close to the ceiling you are. Count live connections against max_connections before changing anything.
-- PostgreSQL · read-only · safe at any time
SELECT count(*) AS used,
current_setting('max_connections')::int AS ceiling,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn
FROM pg_stat_activity;
2. Cap each driver’s pool so the fleet cannot oversubscribe. Size max so that instances × max + migration_budget stays comfortably below the ceiling.
// Context: application bootstrap; one pool per process, sized for the whole fleet.
// postgres-js: cap connections and disable prepared statements behind a transaction pooler.
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
const client = postgres(process.env.DATABASE_URL!, {
max: 8, // per process; fleet sum must stay under max_connections
prepare: false, // required behind PgBouncer transaction mode
idle_timeout: 20, // reclaim idle backends quickly during a deploy
});
export const db = drizzle(client);
3. Give the migration its own tiny, separate pool. The migration runner should not borrow the application’s connections.
// Context: migration entrypoint, run as a discrete deploy step, not inside the app.
// A dedicated single-connection client keeps the migration out of the app's budget.
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
const migrationClient = postgres(process.env.MIGRATION_DATABASE_URL!, { max: 1 });
await migrate(drizzle(migrationClient), { migrationsFolder: './drizzle/migrations' });
await migrationClient.end(); // release the connection before the app rolls
4. If you are behind PgBouncer in transaction mode, fix prepared statements at the URL too. Append the flag so the driver disables them even where you cannot set prepare: false.
# Context: connection string for any client behind a transaction-mode pooler.
# pgbouncer=true tells Drizzle-compatible drivers to skip prepared statements.
export DATABASE_URL="postgres://user:pass@pgbouncer:6432/app?pgbouncer=true"
Permanent Fix / Long-Term Pattern
The durable pattern is a connection budget you compute, not guess. Decide max_connections, subtract a reserve for superuser and replication slots, divide the remainder between the application fleet and a fixed migration allowance, and size each driver’s max from that arithmetic. Run the migration as a discrete deploy step with its own max: 1 client that connects, applies, and disconnects before the new application image rolls — so the migration and the app never hold connections simultaneously, exactly the forward-only ordering the ORM & Framework Migration Workflows contract prescribes.
For serverless and edge deployments, never open a raw connection per invocation; route every connection through a pooler (PgBouncer in transaction mode, or a managed equivalent) and disable prepared statements on the client. On platforms that scale to many concurrent invocations, cap the function concurrency so invocation count cannot multiply past the pooled budget. And keep backfills off the hot pool entirely — run them as a separate throttled worker per Backfill Optimization, so a long-running data job never pins the connections live traffic needs. If your drift checks and pool config interact (for example, a push that hangs on connections), reconcile the schema first using the schema drift detection guide.
Verification Checklist
instances × pool.max + migration_budgetis provably belowmax_connectionsminus the superuser reserve.max: 1client that disconnects before the application fleet rolls.prepare: falseorpgbouncer=true).pg_stat_activityshows no growing count ofidle in transactionsessions during or after a deploy.
Frequently Asked Questions
Why do I get too many clients already only during a deploy, never in steady state?
During a rolling deploy the old and new application fleets run simultaneously for a short window, so every per-process pool is briefly doubled, and the migration job adds its own connections on top. The sum crosses max_connections even though either fleet alone fits. Cap each driver’s max for the doubled case and give the migration a separate small budget that releases before the rollout completes.
Why do prepared statements break only behind PgBouncer?
In transaction pooling mode PgBouncer assigns a different backend connection to each transaction, so a prepared statement created on one backend does not exist on the next one your client lands on, yielding prepared statement "s0" does not exist. Disable client-side prepared statements with prepare: false (postgres-js) or pgbouncer=true in the connection string so the driver sends plain queries.
Should the migration use the same pool as the application?
No. Give the migration its own client with max: 1 and run it as a discrete step that connects, applies, and disconnects before the new application image rolls. Sharing the application pool makes the migration compete with live traffic for connections and is a leading cause of deploy-time exhaustion.