Blog/Engineering/PgBouncer vs Built-in Connection Pooling: What Actually Matters

PgBouncer vs Built-in Connection Pooling: What Actually Matters

D
Daniel Ortiz
November 4, 20258 min read

Why this question comes up

Every time PostgreSQL releases improvements to its connection handling, articles appear claiming that PgBouncer is now unnecessary. And every time, the nuance gets lost. Let's be specific about what "connection pooling" means, where the bottleneck actually is, and when each approach is appropriate.

The core problem: PostgreSQL connections are expensive

Each PostgreSQL connection is a separate OS process (or thread on Windows). Spawning a connection involves forking a process, allocating shared memory segments, loading catalog caches, and establishing authentication. On a busy server, this overhead is significant — typically 1–5ms per new connection on a warm server, much more on a cold one.

More critically: at high connection counts, the overhead of managing thousands of idle connections degrades performance even when those connections aren't doing anything. PostgreSQL's lock manager and buffer pool don't scale linearly with connection count.

The sweet spot for most workloads is 100–300 active database connections, with peak concurrency well below that. Applications that spawn a connection per request — common in PHP, Python, and naive Node.js apps — can easily exceed this with moderate traffic.

What PgBouncer does

PgBouncer sits between your application and PostgreSQL and multiplexes many application connections onto a smaller pool of actual database connections. In transaction mode (the recommended default for most applications), a database connection is held only for the duration of a transaction, then returned to the pool.

This means 1,000 application connections can share a pool of, say, 20 database connections — as long as each transaction is short. For OLTP workloads, this is almost always the case.

The result: your PostgreSQL server sees 20 connections instead of 1,000. Memory usage, lock contention, and scheduler overhead drop dramatically.

What PostgreSQL's built-in pooling does

PostgreSQL does not have connection pooling in the traditional sense. What it does have:

  • connection_pooler_enabled in some managed Postgres offerings (this is actually external poolers configured for you)
  • Connection limits per role (CONNECTION LIMIT)
  • In PG17: improved connection scaling with max_connections handling

There is no built-in mechanism equivalent to PgBouncer's transaction-mode pooling. The improvements in PostgreSQL 17 are meaningful — they reduce the per-connection overhead and improve scalability at high connection counts — but they don't eliminate the fundamental issue that each connection is a process with real overhead.

When you don't need PgBouncer

If your application uses a proper connection pool in the application layer (HikariCP for Java, pool for Python's asyncpg, ActiveRecord's pool configuration for Rails) and your max concurrency is well under 200 connections, PgBouncer adds complexity without meaningful benefit.

Serverless functions, where each invocation may open a new connection, are a different story — PgBouncer (or a purpose-built serverless pooler like Supavisor) is nearly always necessary.

Our configuration at Aureus

We run PgBouncer in front of all customer clusters in transaction mode with pool sizes tuned to the cluster tier. For Cluster S: pool size of 25. Cluster M: 50. Cluster L: 100–200 depending on workload profile.

We also configure PgBouncer's server_idle_timeout conservatively to avoid holding idle connections and max_client_conn to reject connections gracefully rather than hang when the pool is exhausted. The latter is the most common misconfiguration we see when teams set up PgBouncer themselves.

If you're unsure whether your current connection configuration is optimal, pg_stat_activity grouped by state and wait_event is usually the right starting point.