Your app scaled from 2 containers to 10, and now it logs this:
FATAL: sorry, too many clients already
The first instinct is to raise max_connections to 1000. That usually makes things worse. Here's why, and what to do instead.
Why PostgreSQL connections are expensive
Every PostgreSQL connection is a separate operating-system process. Each one takes memory (a few MB at rest, plus work_mem for every sort or hash it runs) and adds to the work of taking snapshots and managing locks. Past a few hundred active connections, throughput often goes down: CPUs spend their time context-switching and contending for locks instead of running queries.
A database server with 16 cores can only run about 16 queries at a time anyway. Having 1000 connections doesn't give you 1000 concurrent queries. It gives you 1000 processes queueing inside the kernel.
Find out who is using the slots
SELECT usename, application_name, client_addr, state, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2, 3, 4
ORDER BY count(*) DESC;
The common pattern: hundreds of connections in state idle. The application opened them and isn't using them. That's the key insight: you don't need more connections, you need fewer idle ones.
Do the math on app-side pools
Most frameworks keep a pool in each process. The total is multiplicative:
10 pods × 4 worker processes × pool size 20 = 800 connections
Every pod autoscale event makes it worse. Serverless functions are worse still: each concurrent invocation may open its own connection.
Start by shrinking each pool. A per-process pool of 5–10 is plenty for most web workloads, because requests hold a connection for milliseconds.
Put a pooler in front: PgBouncer
A connection pooler accepts thousands of cheap client connections and funnels them through a small set of real server connections:
; pgbouncer.ini
[databases]
app = host=10.0.0.5 port=5432 dbname=app
[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 30
With pool_mode = transaction, a server connection is lent to a client only for the length of one transaction, then returned. 5,000 app connections can share 30 PostgreSQL backends.
Transaction pooling has one catch: anything that relies on session state breaks, because consecutive transactions may run on different backends. That includes SET without LOCAL, session advisory locks, LISTEN, and temporary tables that outlive a transaction. PgBouncer 1.21+ supports protocol-level prepared statements (max_prepared_statements), so named prepared statements from most drivers now work.
Managed platforms often ship a pooler already (Supabase Supavisor, Neon, RDS Proxy, Azure's built-in PgBouncer). Check before you deploy your own.
Sizing: a starting formula
For the server-side pool, a well-known starting point is:
connections ≈ (CPU cores × 2) + effective disk spindles
On a 16-core box with SSDs that's roughly 30–40 active connections. Benchmark from there. Keep max_connections a little above the pooler's total, plus room for admin and replication connections:
SHOW max_connections; -- e.g. 100 is fine behind a pooler
SHOW superuser_reserved_connections;
Stop idle sessions from hoarding slots
Two timeouts close connections that aren't doing anything:
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
ALTER SYSTEM SET idle_session_timeout = '15min'; -- PostgreSQL 14+
SELECT pg_reload_conf();
Be careful with idle_session_timeout if clients connect directly with long-lived pools. It will close their connections, and the pool must handle reconnecting.
Checklist
- Count connections by application and state in
pg_stat_activity. - Multiply out your app-side pools and shrink them.
- Put PgBouncer (or your platform's pooler) in transaction mode in front.
- Keep
max_connectionsmodest and sized to the pooler, not to the number of clients.
Get the weekly commit
New database deep dives every week.
