Hi there —

Welcome to Issue #6 of Vacuum & Analyze. New topic, and this one pairs with something I’ve been building on YouTube — a full series on connection pooling that goes deeper with diagrams and live benchmarks than a newsletter issue can. If today’s issue is useful, the video series is the extended cut.

A service goes from 200 to 800 application instances during a traffic spike. Someone raises max_connections from 300 to 1500 so nothing gets refused. Connections stop getting rejected — and query latency across the board gets worse. CPU usage climbs, but the box isn’t actually doing more useful work. The instinct that “more connections should mean more throughput” turns out to be exactly backwards here.

Misconception #1: “More connections means more concurrent work gets done”

Each Postgres connection is a full OS process, not a lightweight thread. Beyond a certain number of active connections — roughly in the range of 2-4x your CPU core count, depending on workload — additional connections don’t add throughput. They add contention: more processes competing for the same CPU cores, the same lock table, the same buffer pool. Past that point, each additional connection makes existing connections slower, because Postgres is spending more of its time on context switching and lock coordination instead of running queries.

This is why raising max_connections during a traffic spike often makes things worse, not better. It removes the “connection refused” symptom, but the underlying problem — too much concurrent work for the hardware to run efficiently — doesn’t go away. It just moves from “some requests get rejected immediately” to “all requests get slower.”

The number that actually matters isn’t how many connections can exist. It’s how many are active (running a query) at once. A database can comfortably hold thousands of idle connections open. It can’t comfortably run thousands of queries at once on typical hardware — and that’s the number pooling is meant to control.

Misconception #2: “A connection pooler just queues connections until Postgres can take them”

This undersells what a pooler like PgBouncer actually does, and glosses over the part that causes real production incidents: pooling mode.

Session pooling assigns a Postgres connection to a client for the lifetime of that client’s session — closest to connecting directly, safest for compatibility, least reduction in connection count.

Transaction pooling — the mode people reach for because it multiplexes far more aggressively — only holds the Postgres connection for the duration of a single transaction, then returns it to the pool for another client to use. This is where the throughput gains come from, and it’s also where session-level features quietly break: prepared statements, session-level SET commands, advisory locks, and LISTEN/NOTIFY can all misbehave, because the client’s next transaction might land on a completely different underlying Postgres connection than its last one.

Switching to transaction pooling without auditing for these patterns is how teams end up with an intermittent bug that only shows up under load, tied to whichever client happened to get rotated onto a connection that doesn’t have the session state it expects.

The diagnostic that actually matters

Check how many connections are actually active versus just idle:

SELECT
state,
count(*)
FROM pg_stat_activity
GROUP BY state
ORDER BY count(*) DESC;

A large idle count relative to active is normal and fine with a pooler in front — that’s exactly the case pooling is built for. A large active count approaching or exceeding roughly 2-4x your core count is the actual signal that you’re past the point where more concurrency helps, regardless of what max_connections is set to.

Then check whether anything in your application is relying on session-level state that would break under transaction pooling:

SELECT query, count(*)
FROM pg_stat_activity
WHERE query ILIKE '%SET %' OR query ILIKE '%LISTEN%' OR query ILIKE '%pg_advisory%'
GROUP BY query
ORDER BY count(*) DESC
LIMIT 20;

Any real traffic showing up here is worth tracing back to its application code before flipping a pooler from session to transaction mode.

The practical shape of a fix

  • Size the actual concurrency target around cores, not around client count. A reasonable starting pool size is often close to (core count × 2) + effective_spindle_count, then tuned empirically — not the raw number of application instances that might connect.
  • Put a pooler in front of Postgres rather than raising max_connections to absorb client growth. Client count and useful concurrency are different numbers; a pooler is what translates between them.
  • Default to session pooling unless you’ve specifically audited for transaction-pooling incompatibilities. The throughput gain from transaction pooling is real, but it’s not free, and “we’ll just try it and see” is how the intermittent bugs happen.
  • Re-check this whenever your deployment topology changes — autoscaling application instances is exactly the kind of change that quietly multiplies client-side connection count without anyone updating the pooling configuration to match.

The mental model

max_connections answers “how many clients can be connected at once,” which is almost never the number that matters for performance. The number that matters is how many queries are running at the same time, and that number should be sized to your hardware, not your client fleet. A pooler is what sits between those two numbers — but it comes with a mode choice that trades compatibility for throughput, and that tradeoff deserves a deliberate decision, not a default.

If you want the deeper version of this — actual benchmark numbers, PgBouncer config walkthroughs, and the specific failure modes on video — the connection pooling series is live on YouTube, with new parts landing through the next few weeks.

Reply and tell me: session or transaction pooling, and have you ever chased a bug that turned out to be pooling mode? I read every reply.

— Gio

Leave a Reply