PgBouncer explained: pooling modes, settings and pitfalls

Session, transaction and statement pooling, what breaks in transaction mode, key pgbouncer.ini settings, PgBouncer vs Pgpool-II and managed proxies, and production tips.

9 min read
On this page 8 sections
  1. Why Postgres needs a pooler
  2. Pooling modes
  3. What breaks in transaction mode
  4. Key settings
  5. PgBouncer vs Pgpool-II vs managed proxies
  6. Running it in production
  7. Key takeaways
  8. Frequently asked questions

PgBouncer is a lightweight connection pooler for PostgreSQL. Applications connect to it as if it were the database (on port 6432 by default), and it lends them one of a small number of real server connections, for a whole session, a single transaction or a single statement, depending on pool_mode. Transaction mode gives the big win, letting thousands of clients share a few dozen server connections, but it breaks session features such as SET, LISTEN and session advisory locks, so check your app before you switch.

Why Postgres needs a pooler

PostgreSQL runs one server process per connection, and the project deliberately leaves pooling out of the core server. Its wiki explains why too many connections hurt: past the point where CPU and disks are busy, extra concurrent transactions only add contention for locks, memory and CPU caches, so throughput falls. Even idle connections cost something. In a 2020 analysis on PostgreSQL 12, committer Andres Freund found that 10,000 idle connections roughly halved the throughput of 48 active ones. PostgreSQL 14 removed much of that bottleneck, but thousands of connections are still best avoided.

PgBouncer solves this cheaply. It needs about 2 kB of memory per client connection by default, supports most configuration changes without a restart, and hands out server connections only when a client actually needs one, which in transaction mode means only while a transaction is running.

Pooling modes

ModeA server connection is held forWhat worksUse it for
session (the default)The client's whole connectionEvery PostgreSQL featureApps that rely on session state, and clients that connect and disconnect often
transactionOne transactionMost features; the session-level ones listed below breakWeb apps and background workers: the usual production choice
statementOne statement; multi-statement transactions are refusedAutocommit-style queries onlyRare, specialised setups

Session mode saves the cost of connecting, but a server connection stays tied to a client for as long as the client stays connected, so it doesn't reduce the number of server connections your app holds. Transaction mode is what lets, say, 2,000 client connections run on 40 server connections: between transactions, a client holds nothing.

What breaks in transaction mode

In transaction mode, two consecutive transactions from the same client can run on different server connections. Anything that expects state to survive between transactions breaks. PgBouncer's feature map lists the incompatible features; here they are with workarounds:

FeatureIn transaction modeWhat to do instead
SET and RESETNever supportedUse SET LOCAL inside a transaction, or set defaults per role with ALTER ROLE ... SET
LISTENNever supported (NOTIFY works)Give listeners a direct or session-mode connection
Cursors WITH HOLDNever supportedKeep cursors inside one transaction; in Django, set DISABLE_SERVER_SIDE_CURSORS = True
SQL PREPARE and DEALLOCATENever supportedUse protocol-level prepared statements, which PgBouncer tracks
Temporary tables that outlive a transactionNever supported (ON COMMIT DROP works)Create and drop them within one transaction
LOADNever supportedLoad libraries through server configuration
Session-level advisory locksNever supportedUse transaction-level locks such as pg_advisory_xact_lock

Two details are easy to miss. First, protocol-level prepared statements, which many drivers use, have worked in transaction mode since PgBouncer 1.21 and are on by default since 1.24, with max_prepared_statements = 200. Second, a plain SET doesn't fail loudly; it leaks. The setting stays on the server connection and affects whichever client uses it next. PgBouncer tracks a set of startup parameters such as application_name and TimeZone, and version 1.26 extended tracking to every parameter PostgreSQL reports, including search_path on PostgreSQL 18. Anything else you SET still leaks.

Key settings

SettingDefaultWhat it does
pool_modesessionWhen a server connection returns to the pool
max_client_conn100Client connections PgBouncer accepts in total; raise the operating system's file-descriptor limit with it
default_pool_size20Server connections per user and database pair
min_pool_size0Server connections kept ready, so traffic returning after a quiet spell doesn't wait for new ones
reserve_pool_size and reserve_pool_timeout0 and 5 secondsExtra connections for a pool whose clients have waited longer than the timeout
max_db_connections and max_user_connections0 (unlimited)Caps per database and per user, across pools
query_wait_timeout120 secondsDisconnects a client that has waited this long for a server connection
server_idle_timeout and server_lifetime600 and 3,600 secondsClose idle and old server connections
max_prepared_statements200Prepared statements tracked per server connection in transaction mode

A starting configuration for a web app, with illustrative numbers for a 16-core database server:

[databases]
exams = host=10.0.1.20 port=5432 dbname=exams

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 4000
default_pool_size = 30
reserve_pool_size = 5
max_db_connections = 40
query_wait_timeout = 20

Pools exist per user and database pair, so the server connections PostgreSQL sees are the sum of all pools, not one pool's size. Giving each workload its own database user, such as web, workers and reports, gives each its own pool, and max_db_connections caps the total. A shorter query_wait_timeout than the default makes overload fail quickly rather than leaving students staring at a spinner for two minutes.

For the pool size itself, start near twice the database server's CPU cores in total across pools, the rule of thumb on the PostgreSQL wiki, then load test. If SHOW POOLS shows clients waiting while database CPU is low, the pool is too small; if CPU is pegged and latency climbs as you add connections, it is too big. Sizing a pool with Little's Law walks through the arithmetic.

PgBouncer vs Pgpool-II vs managed proxies

OptionHow it poolsWhat else it doesWatch out for
PgBouncerShared pools in session, transaction or statement modeLittle else: authentication, TLS and an admin consoleSingle-threaded, and the transaction-mode limits above
Pgpool-IIEach of num_init_children child processes (32 by default) caches up to max_pool connections (4 by default); clients beyond the child count wait in a queueRead load balancing, automatic failover, and a watchdog for its own high availabilityHeavier to run; caches aren't shared between child processes
Newer poolers such as Odyssey and PgDogTransaction pooling; Odyssey is multi-threadedPgDog adds read load balancing and shardingYounger projects with smaller communities
Amazon RDS ProxyReuses connections at transaction boundaries unless a session is pinnedManaged, highly available proxy for RDS and AuroraFor PostgreSQL, SET, PREPARE, temporary tables, cursors, LISTEN, session advisory locks and a DISCARD ALL reset query all pin sessions
Built-in poolers from cloud providersAzure's flexible server offers an optional built-in PgBouncer on port 6432, in transaction mode by default; Google Cloud SQL Enterprise Plus offers managed pooling in transaction or session modeManaged upgradesFewer settings exposed, and feature or tier restrictions

For most Django, Rails or Node apps on PostgreSQL, PgBouncer in transaction mode is a sensible default. Consider Pgpool-II when you want failover and the routing of reads to read replicas in the same component, and a managed proxy when you would rather not run the pooler yourself.

Running it in production

  • Choose where it runs. Next to each app server, it adds no network hop and no shared point of failure, but every copy has its own pools, so connection counts multiply again. As a central tier, it caps the total, but it must be highly available: run at least two instances behind a load balancer or DNS name, and divide the connection budget between them. On the database host, it is simplest, but it competes with PostgreSQL for CPU.

  • Use more than one core. PgBouncer is single-threaded. On Linux, so_reuseport lets several PgBouncer processes listen on the same port, with the kernel spreading connections between them. The same option enables rolling restarts: PgBouncer 1.26 removed the old online-restart mode in favour of it.

  • Watch the admin console. Connect to the special pgbouncer database and run SHOW POOLS. A non-zero cl_waiting, and a maxwait that keeps rising, mean clients are queueing for server connections. Alert on both.

  • Secure it like a database. Use scram-sha-256 authentication and TLS, and keep it patched: version 1.26.0, released in September 2026, fixed three security vulnerabilities.

  • Tell the app it is behind a pooler. Disable server-side cursors in Django, move SET calls into transactions or role defaults, and give any LISTEN worker its own direct connection.

New to the idea? Start with what connection pooling is. If the database is already refusing connections, see Postgres too many connections. The PgBouncer configuration reference documents every setting mentioned here.

Key takeaways

  • PgBouncer sits between the app and PostgreSQL and lends out a small number of server connections.

  • Transaction mode gives the biggest reduction in server connections; session mode only saves connection set-up.

  • Transaction mode breaks SET, LISTEN, held cursors, SQL PREPARE and session advisory locks, and a stray SET leaks to other clients.

  • Pools are per user and database pair; cap the total with max_db_connections and give each workload its own user.

  • It is single-threaded and can be a single point of failure, so run several processes and more than one instance.

Frequently asked questions

Why use PgBouncer?

Because PostgreSQL uses one server process per connection and slows down when thousands of connections pile up. PgBouncer lets many application processes share a small, fixed number of real connections, avoids the cost of opening new ones, and queues clients when the database is busy instead of overloading it. It uses very little memory per connection and is simple to run.

Should I use PgBouncer?

Use it once your app runs on more than a handful of processes or servers, uses autoscaling, or is near max_connections. You may not need it for a single small server with an in-app pool. Before switching to transaction mode, check your code for session features such as SET, LISTEN, held cursors and session advisory locks. On a managed database, compare it with your provider's built-in pooler or proxy.

What is PgBouncer in PostgreSQL?

PgBouncer is a separate, open-source program, not part of PostgreSQL itself, that acts as a connection pooler in front of a PostgreSQL server. Clients connect to PgBouncer using the normal PostgreSQL protocol, and PgBouncer forwards their queries over a pool of connections it keeps open to the server. It offers session, transaction and statement pooling modes, and an admin console for monitoring pools.

Share this article

Looking for something else?

Talk to Us