Meta description: I burned through my Postgres max_connections limit twice before fixing it for good — here’s exactly how I handle Prisma connection pooling in Docker.
Last updated: July 5, 2026
The first time I got FATAL: sorry, too many clients already in production, I was three sprints into a Node.js API built on Prisma and Dockerized Postgres, and I had no idea a single service could open that many connections. Turns out, every serverless function cold start and every container replica was spinning up its own Prisma Client, and each client was holding onto its own connection pool. By the time I had four replicas running, I’d blown past Postgres’s default 100 connections during a normal traffic spike.
That incident taught me more about PostgreSQL connection pooling than any docs page. This article is everything I wish I’d known before that pager went off.
TL;DR
- Prisma opens its own connection pool per client instance — in Docker with multiple replicas, this multiplies fast and can exhaust Postgres’s
max_connections. - The fix is an external pooler (PgBouncer or Prisma Accelerate) sitting between your app containers and Postgres, using
connection_limitandpool_timeouttuning on the Prisma side too. - Transaction-mode pooling breaks Prisma’s prepared statements unless you add
?pgbouncer=trueto your connection string — I lost half a day to this exact issue.
Background: Why Connection Pooling Matters Here
Postgres, unlike some databases, forks a full OS process for every connection. That’s expensive — each connection can cost several MB of RAM and real CPU overhead just to maintain. Prisma’s client-side pool, by default, calculates its pool size based on your CPU core count using the formula num_physical_cpus * 2 + 1, which is fine for a single long-running server but dangerous in Docker Compose or Kubernetes setups where you’re running multiple replicas of the same service.
Each replica thinks it’s the only consumer of the database, so it opens its own pool sized for full CPU utilization. Scale to 5 replicas on a 4-core box and you’re suddenly asking Postgres for 45 connections before a single real user shows up. Add a connection-hungry background worker or a migration job, and you’ve got a five-alarm fire.
[INTERNAL LINK: related article]
Prerequisites
Before you touch any config, make sure you have:
- Docker and Docker Compose installed (I’m running Docker 26.1 and Compose v2.29 as of this writing)
- Prisma CLI and Client at version 5.x or later (
npm install prisma @prisma/client) - A Postgres image — I use
postgres:16-alpinein all my examples - Basic familiarity with your
schema.prismadatasource block
Step-by-Step Implementation
Step 1: Check your current max_connections
Before changing anything, find out what you’re working with:
docker exec -it my_postgres_container psql -U postgres -c "SHOW max_connections;"
On the default Postgres Docker image, this comes back as 100. That sounds like a lot until you do the math on replicas times pool size.
Step 2: Add PgBouncer as a pooling layer
I don’t let application containers talk to Postgres directly anymore — everything goes through PgBouncer. Here’s the relevant slice of my docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
pgbouncer:
image: edoburu/pgbouncer:1.22.1
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@postgres:5432/myapp
POOL_MODE: transaction
MAX_CLIENT_CONN: 200
DEFAULT_POOL_SIZE: 20
ports:
- "6432:6432"
depends_on:
- postgres
volumes:
pgdata:
Pro Tip: Set
POOL_MODE: transactionfor web APIs — it reuses connections between transactions instead of holding one per client for the whole session. It’s the single biggest lever for cutting real connection count.
Step 3: Point Prisma at PgBouncer, not Postgres directly
Your .env needs two different URLs — one for runtime queries through PgBouncer, and one for migrations that go straight to Postgres (PgBouncer in transaction mode doesn’t support the CREATE DATABASE-style commands Prisma Migrate sometimes needs):
DATABASE_URL="postgresql://postgres:mypassword@pgbouncer:6432/myapp?pgbouncer=true&connection_limit=1"
DIRECT_URL="postgresql://postgres:mypassword@postgres:5432/myapp"
In schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
The ?pgbouncer=true flag is not optional. Without it, Prisma tries to use prepared statements that PgBouncer’s transaction mode can’t track correctly, and you’ll get cryptic prepared statement "s0" already exists errors.
Step 4: Tune Prisma’s own pool size per container
Even behind PgBouncer, Prisma still maintains its own small internal pool. In a multi-replica setup, I explicitly cap it low since PgBouncer is doing the heavy lifting:
DATABASE_URL="postgresql://postgres:mypassword@pgbouncer:6432/myapp?pgbouncer=true&connection_limit=5&pool_timeout=10"
connection_limit=5 keeps each container’s footprint small; pool_timeout=10 fails fast (10 seconds) instead of letting requests hang indefinitely when the pool is saturated.
Step 5: Verify under load
I use pgbench inside the Postgres container to simulate traffic and watch connections in real time:
docker exec -it my_postgres_container psql -U postgres -c \
"SELECT count(*) FROM pg_stat_activity WHERE datname='myapp';"
If this number stays flat and low while your app handles concurrent requests, pooling is working.
Real-World Tips I Use in Production
- I set
DEFAULT_POOL_SIZEin PgBouncer to roughly(max_connections * 0.8) / number_of_services, leaving headroom for admin connections and migrations. - I run PgBouncer as its own container (not a sidecar) so I can scale app replicas without touching the pooler.
- For serverless or edge deployments, I’ve switched entirely to Prisma Accelerate, which handles pooling externally and removes the need to self-host PgBouncer — worth it if you don’t want to manage infra.
- I always set a
statement_timeouton the Postgres side too, as a second line of defense against runaway queries holding connections open.
Common Errors and How I Fixed Them
Error: FATAL: sorry, too many clients already This was the original incident. Fix: added PgBouncer, dropped Prisma’s connection_limit per replica, and set MAX_CLIENT_CONN appropriately.
Error: prepared statement "s0" already exists Happened right after switching to transaction-mode pooling. Fix: added ?pgbouncer=true to the DATABASE_URL, which tells Prisma to skip prepared statements.
Error: Migrations failing silently against PgBouncer prisma migrate deploy needs a direct connection. Fix: added the directUrl field pointing straight at Postgres, bypassing PgBouncer entirely for migration commands.
Important: Never run
prisma migrate devagainst a pooled connection in a shared environment — it can lock schema changes behind stale transactions from other services.
FAQ
Q: How many connections does Prisma open by default in Docker? A: By default, Prisma calculates a pool size using num_physical_cpus * 2 + 1. In Docker, this is based on the CPU limit the container sees, which can be misleading if you’re using cgroup CPU limits.
Q: Do I need PgBouncer if I only run one container replica? A: Usually not — a single replica with a properly capped connection_limit is often enough. PgBouncer becomes necessary once you scale horizontally.
Q: Why does Prisma need a separate directUrl for migrations? A: Prisma Migrate issues DDL commands and needs session-level features that PgBouncer’s transaction pooling mode doesn’t support reliably.
Q: What connection_limit should I set for Prisma in Docker? A: I typically start at 5 per replica and adjust based on pg_stat_activity monitoring under real load.
Q: Is Prisma Accelerate a replacement for PgBouncer? A: Yes, for most use cases — it’s a managed external pooler, so you skip self-hosting PgBouncer, though it adds a dependency on Prisma’s infrastructure.
Conclusion
Connection pooling isn’t glamorous, but it’s the difference between a Postgres instance that hums along under load and one that pages you at 2 a.m. If you take one thing from this, add PgBouncer in transaction mode, remember the pgbouncer=true flag, and keep a direct URL for migrations.
[SOURCE: https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/databases-connections/connection-pool] [SOURCE: https://www.pgbouncer.org/config.html]
About the Author
I’m a backend engineer with over 8 years of experience building and scaling Node.js and PostgreSQL systems, with the last 4 spent running Prisma in containerized production environments. My current stack is TypeScript, Prisma, Docker, and Kubernetes. I write about the infrastructure lessons I learn the hard way so other engineers don’t have to repeat my mistakes.
