PgBouncer and PostgreSQL Connection Pooling
There is a specific kind of 3 a.m. that belongs exclusively to database engineers: the kind where your monitoring fires because pg_stat_activity shows 487 connections and max_connections is 500, your app servers are queuing requests, and you are watching maxwait in PgBouncer climb past 30 seconds while trying not to wake your entire team. If you have not lived that experience, this post is your inoculation. If you have, this is the reference you wish you had beforehand.
Connection pooling is not a nice-to-have optimization in PostgreSQL. For almost any application beyond a single-server hobby project, it is a structural necessity. This post covers why that is, how PgBouncer works and how to configure it correctly, what breaks in transaction pooling mode (and why you should use it anyway), how to monitor a running pooler, and how PgBouncer compares to its alternatives. We will go deep on the operational details — this is not a ten-minute getting-started guide.
The current stable release as of this writing is PgBouncer 1.25.2 (released May 2026), which includes security fixes for several CVEs plus the major features added in 1.24 and 1.25: per-user and per-database connection limits, LDAP authentication, direct TLS support for PostgreSQL 17 clients, a transaction_timeout setting, and significantly improved SCRAM authentication performance.
The PostgreSQL Connection Model and Why It Breaks at Scale
PostgreSQL uses a process-per-connection model. This is not threads, not async I/O on a single event loop — each client connection causes the PostgreSQL postmaster to fork() a new backend process. This design has real advantages: process isolation means a crashing backend does not corrupt other sessions, and the memory model is simple. But it has a hard cost floor per connection that does not disappear regardless of what the connection is doing.
Each backend process consumes roughly 5 to 10 MB of resident memory for its working set, stack, and shared memory attachments. That is before it touches any data. On a database server with 32 GB RAM, you can support perhaps 2,000 to 3,000 connections from a memory standpoint alone — but you will find performance degrading badly long before that. Every active backend participates in lock table scans, checkpoint coordination, and vacuum signaling. At some threshold — typically somewhere between 200 and 500 connections depending on workload — the operating system context-switching overhead and kernel scheduler pressure start eating into query throughput measurably. More connections, slower queries, for the same amount of actual work.
Connection establishment itself costs time. A new connection requires a TCP handshake, TLS negotiation if configured, PostgreSQL authentication (password hashing, pg_hba.conf matching), backend process fork and initialization, and loading of session-level state. This typically takes between 1 and 5 milliseconds on local network. For a request handler that runs a 2ms query, spending 3ms to establish the connection is a 150% overhead tax. Multiply this across thousands of requests per second from application servers that open a fresh connection for each request — the common pattern in PHP applications and some serverless environments — and you are spending the majority of your database time on connection overhead rather than query execution.
The max_connections parameter in postgresql.conf sets the hard ceiling. The default is 100. When the limit is reached, new connection attempts receive this response immediately:
FATAL: sorry, too many clients already
That error propagates up to your application as an exception, your framework may or may not retry it, and in the worst case it triggers a cascade: all your app servers start trying to reconnect simultaneously, each attempt fails, they retry with backoff, backoff timers expire roughly simultaneously, and you get a connection storm — hundreds of reconnect attempts hitting PostgreSQL in a tight window, each failing because the others are occupying the slots.
The naive fix is to raise max_connections. This is almost always wrong. Each increment to max_connections increases the size of PostgreSQL’s shared lock table (which scales with the maximum possible connections, not current connections), increases memory reservations for per-connection data structures, and widens the blast radius of the scheduler pressure described above. A PostgreSQL instance configured with max_connections = 1000 and seeing 800 active connections will often perform worse than the same instance with max_connections = 200 and a well-configured pooler keeping server-side connections at 50. The resource that matters is the number of backend processes PostgreSQL is actually running, not the number of clients waiting in a pooler queue.
The idle connection problem is particularly insidious because it is invisible in normal operation. Consider a Django application deployed with 20 app server processes, each using Django’s default persistent connection behavior (CONN_MAX_AGE set to a positive value). Each app server process maintains its own connection to PostgreSQL. That is 20 connections from your app tier. Fine. Now you scale the application to 10 hosts, each running the app in a gunicorn server with 4 worker processes. That is 80 connections, most of which are idle at any given time — your database is doing nothing, but PostgreSQL has forked 80 backend processes, each consuming memory and table slots. Scale to 30 hosts and now you have 240 idle connections before a single user has made a request. This is the double-dip cost of persistent connections without a pooler.
PgBouncer Architecture
PgBouncer is a single-process, event-driven connection pooler written in C. It sits between your application and PostgreSQL, presenting itself to the application as a PostgreSQL server and presenting itself to PostgreSQL as a client. Its job is to maintain a small pool of real PostgreSQL connections and multiplex a much larger number of application connections across them.
Application Layer
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ App Srv 1│ │ App Srv 2│ │ App Srv 3│ │ App Srv 4│
│ (conn 1) │ │ (conn 2) │ │ (conn 3) │ │ (conn 4) │
│ (conn 5) │ │ (conn 6) │ │ (conn 7) │ │ (conn 8) │
│ (conn 9) │ │ (conn 10)│ │ (conn 11)│ │ (conn 12)│
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
└──────────────┴──────────────┴──────────────┘
│
┌────────▼────────┐
│ PgBouncer │
│ (port 6432) │
│ │
│ client pool: │
│ 100 virtual │
│ connections │
│ │
│ server pool: │
│ 10 real │
│ connections │
└────────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌──────▼──┐ ┌──────▼──┐ ┌──────▼──┐
│PG Srv │ │PG Srv │ │PG Srv │
│Backend 1│ │Backend 2│ │Backend 3│
└─────────┘ └─────────┘ └─────────┘
PostgreSQL Server
The key distinction: connections from application servers to PgBouncer are virtual — they are TCP connections that PgBouncer accepts and manages using libevent. Connections from PgBouncer to PostgreSQL are real backend processes. PgBouncer assigns a real server connection to a virtual client connection when the client needs to execute a query, and returns that server connection to the pool when the client is done with it (when exactly “done” means depends on the pooling mode). A client that is idle — connected but not actively running a query or transaction — holds no server connection and costs almost nothing.
Authentication in PgBouncer is worth understanding carefully because it has two layers. PgBouncer authenticates clients using its own credential store, configured via auth_type in pgbouncer.ini. It then uses its own credentials to connect to PostgreSQL as the proxied user. The supported auth_type values are:
trust: accept any connection without password (LAN-only environments)plain: accept plaintext passwords (only over TLS)md5: traditional PostgreSQL MD5 password hashingscram-sha-256: modern SCRAM authentication (recommended)hba: use PostgreSQL-style HBA rules for per-client decisionsldap: LDAP authentication (added in 1.25.0)pam: PAM authentication
Passwords are stored in a userlist.txt file in the format "username" "password_or_hash", or PgBouncer can be configured with auth_query to delegate password lookups to PostgreSQL itself via a query against pg_shadow — useful when you want a single source of truth for credentials.
The PgBouncer admin console is a pseudo-database named pgbouncer. You connect to it like any other database and issue plain-SQL-style commands to inspect and control the pooler. Access requires a user listed in the admin_users parameter.
|
|
The Three Pooling Modes
This is where most PgBouncer discussions either gloss over the tradeoffs or bury the important parts. The pooling mode is the single most consequential configuration decision, and choosing the wrong one causes subtle, hard-to-reproduce bugs.
Session Pooling
In session mode, a server connection is assigned to a client when the client connects and is released back to the pool only when the client disconnects. From the application’s perspective, this is functionally identical to a direct connection — all session-level state (temporary tables, advisory locks, prepared statements, SET variables) persists for the duration of the client connection. The only benefit is that PgBouncer limits the total number of server connections even when many clients are connected — idle clients do not hold server connections if there are not enough to go around, they simply wait.
Session mode is the least valuable mode. It does not allow the reuse of server connections across multiple application requests unless the application explicitly disconnects and reconnects between logical “jobs.” Most connection pooling frameworks already do this kind of connection reuse internally, making PgBouncer in session mode largely redundant. Use it only as a transitional measure while you work toward transaction mode.
Transaction Pooling
In transaction mode, a server connection is assigned to a client for the duration of a single transaction — BEGIN through COMMIT or ROLLBACK. The moment a transaction ends, the server connection is returned to the pool and becomes available for any other waiting client. A client that sends an autocommit query (no explicit BEGIN) gets a server connection for just that single statement.
This is the mode that makes PgBouncer genuinely powerful. A server pool of 20 connections can efficiently serve hundreds of concurrent clients because most clients spend the vast majority of their time between transactions — waiting for the application to process results, making HTTP requests to other services, waiting for a job queue item. The fraction of time any client is actually in a transaction is typically small, and that is the only time they need a server connection.
Transaction mode is what you should almost always be running in production. It requires accepting some real tradeoffs, covered below.
Statement Pooling
Statement mode assigns a server connection per individual SQL statement. Even within an explicit transaction, each statement may be executed on a different server connection. This means multi-statement transactions are completely broken — a BEGIN on connection A, followed by an INSERT routed to connection B, followed by a COMMIT on connection A commits nothing on connection B. Statement mode is essentially unusable for any real application that uses transactions, which is all of them. It exists as a curiosity and is documented primarily to explain why you should not use it.
Mode Comparison: When Server Connections Are Held
The following diagram shows a sequence of client operations and when a server connection is occupied in each mode. The client performs: connect, run query 1, run query 2 inside a transaction, disconnect.
Client operations:
[CONNECT] [Q1] [BEGIN] [Q2a] [Q2b] [COMMIT] [DISCONNECT]
Session mode:
Server conn: ├──────────────────────────────────────────────┤
(held entire client session)
Transaction mode:
Server conn: ├──┤ ├──────────────────┤
Q1 BEGIN..COMMIT block
(held only during each transaction/autocommit)
Statement mode:
Server conn: ├──┤ ├───┤ ├───┤ ├─────┤
(held per individual statement only)
What Breaks in Transaction Pooling Mode
This section is not optional reading. Transaction pooling is extremely effective but it silently breaks a set of PostgreSQL features because those features rely on session-level state persisting between transactions. In transaction mode, each transaction may execute on a different server connection, so session state is not reliable. Here is the complete accounting:
SET statements: SET search_path = myschema, SET timezone = 'America/New_York', SET statement_timeout = 5000 — all of these set session-level variables that reset when the server connection returns to the pool. PgBouncer runs server_reset_query (DISCARD ALL by default) on server connections before returning them, which explicitly clears all session variables, temporary tables, prepared statements, and advisory locks. Any application that issues SET and expects it to persist is broken under transaction pooling.
Named prepared statements: PostgreSQL’s wire-protocol prepared statements (PREPARE foo AS SELECT ..., or the binary protocol’s Parse/Describe/Execute flow) are session-scoped. In transaction mode, a prepared statement created on connection A may not exist on connection B. This is the most common source of cryptic errors when adding PgBouncer to an existing application — ORMs and drivers that transparently use prepared statements start failing with “prepared statement does not exist.”
Advisory locks: pg_advisory_lock() and pg_advisory_xact_lock() have different scope. Transaction-scoped advisory locks (pg_advisory_xact_lock) work fine in transaction pooling because they release with the transaction. Session-scoped advisory locks (pg_advisory_lock) do not — they are released when the server connection returns to the pool, not when the application calls pg_advisory_unlock(). Application code that uses session-scoped advisory locks for distributed coordination will silently lose its locks.
LISTEN / NOTIFY: The LISTEN command subscribes a connection to a notification channel. Since PgBouncer routes transactions to arbitrary server connections, there is no guarantee that the connection that receives a NOTIFY is the connection your application is listening on. LISTEN/NOTIFY requires a dedicated, persistent connection outside of the PgBouncer pool. Many applications run a separate direct connection for pub/sub.
WITH HOLD cursors: A DECLARE ... WITH HOLD cursor persists past a transaction commit and lives until the end of the session. Since transaction pooling does not guarantee the same server connection for the next transaction, these cursors are lost. Regular (non-hold) cursors are fine because they are transaction-scoped.
Temporary tables: CREATE TEMPORARY TABLE creates a table that exists for the current session. Under transaction pooling, a temporary table created in one transaction may not exist in the next transaction if PgBouncer assigned a different server connection. Note that DISCARD ALL (the default server_reset_query) also explicitly drops temporary tables as part of its cleanup.
Two-phase commit: PREPARE TRANSACTION is a session-level operation that persists a transaction for later COMMIT PREPARED or ROLLBACK PREPARED. This is incompatible with transaction pooling mode.
| Feature | Session Mode | Transaction Mode | Statement Mode |
|---|---|---|---|
| SET variables persist | Yes | No | No |
| Named prepared statements | Yes | No* | No |
| Session advisory locks | Yes | No | No |
| Transaction advisory locks | Yes | Yes | No |
| LISTEN / NOTIFY | Yes | No | No |
| WITH HOLD cursors | Yes | No | No |
| Temporary tables | Yes | No | No |
| Two-phase commit | Yes | No | No |
| Multi-statement transactions | Yes | Yes | No |
| Regular transactions | Yes | Yes | No |
*PgBouncer 1.21+ added experimental prepared statement tracking in transaction mode. As of 1.25.x, max_prepared_statements can be set to a non-zero value to enable PgBouncer’s protocol-level prepared statement caching, which intercepts Parse/Bind/Execute protocol messages and re-prepares statements as needed on whichever server connection is assigned. This partially resolves the ORM prepared statement problem but adds overhead and has limitations under high concurrency. Verify behavior under your specific ORM version before relying on it.
pgbouncer.ini Configuration
PgBouncer is configured through a single INI file, typically at /etc/pgbouncer/pgbouncer.ini. The file has two mandatory sections: [databases] which maps client-visible database names to real PostgreSQL databases, and [pgbouncer] which configures the pooler itself.
Here is a complete, production-grade configuration for a Django or FastAPI application with annotations:
|
|
userlist.txt stores credentials. For SCRAM-SHA-256, store the SCRAM verifier from PostgreSQL directly:
|
|
Copy the full SCRAM verifier string (starts with SCRAM-SHA-256$) into userlist.txt:
"appuser" "SCRAM-SHA-256$4096:...base64data...=:...verifier..."
"pgbouncer" "SCRAM-SHA-256$4096:..."
For MD5 (legacy), the hash is md5 + MD5(“password” + “username”):
|
|
Monitoring with SHOW Commands
Connect to the PgBouncer admin console from any host that is allowed in admin_users:
|
|
SHOW POOLS
SHOW POOLS is the primary operational view. Run it when investigating slow queries, connection wait times, or pool exhaustion.
|
|
Example output (formatted):
database | user | cl_active | cl_waiting | cl_cancel_req | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait | maxwait_us | pool_mode
----------+---------+-----------+------------+---------------+-----------+---------+---------+-----------+----------+---------+------------+-----------
appdb | appuser | 15 | 0 | 0 | 12 | 8| 0 | 0 | 0 | 0 | 0 | transaction
pgbouncer| pgbouncer| 1 | 0 | 0 | 0 | 0| 0 | 0 | 0 | 0 | 0 | statement
Field-by-field breakdown:
| Column | Meaning |
|---|---|
database |
The client-facing database name (as in [databases] section) |
user |
The PostgreSQL user for this pool |
cl_active |
Client connections currently assigned to a server connection and actively querying or in a transaction |
cl_waiting |
Client connections waiting for a free server connection — this should normally be 0 or near 0 |
cl_cancel_req |
Clients that have sent a cancel request (usually due to statement timeout) |
sv_active |
Server connections currently assigned to a client and in use |
sv_idle |
Server connections in the pool, idle and ready for assignment |
sv_used |
Server connections that were recently used, have been checked back in, and are waiting for the server_check_delay before being marked idle |
sv_tested |
Server connections currently being checked with server_check_query |
sv_login |
Server connections currently being established (in the login phase) |
maxwait |
The critical alert metric. The number of seconds the longest-waiting client has been queued for a server connection. Any value above 1 second indicates pool exhaustion. Alert on maxwait > 1. |
maxwait_us |
Same as maxwait but in microseconds, for finer resolution |
pool_mode |
The pooling mode for this pool |
When you see cl_waiting > 0 and maxwait climbing, you have a pool exhaustion event. The immediate options are: increase default_pool_size for this database/user pair (if PostgreSQL has headroom in max_connections), kill long-running transactions that are holding server connections, or reduce connection demand from the application.
SHOW CLIENTS
|
|
Shows each connected client with its address, state (active, used, waiting, idle), current query if active, and how long it has been waiting. Useful for identifying which specific application servers are accumulating waiting connections.
SHOW SERVERS
|
|
Shows each actual PostgreSQL backend connection PgBouncer has open — state (active, idle, used, tested, login), how long it has been connected, and which client it is currently assigned to. Use this to verify that your default_pool_size is being utilized as expected.
SHOW STATS
|
|
Per-database aggregates: total requests, bytes sent/received, total and average query/transaction/wait time. The avg_query_time and avg_wait_time columns (in microseconds) are useful for trending — a rising avg_wait_time indicates growing pool pressure before it manifests as maxwait alerts.
SHOW CONFIG
|
|
Displays all current configuration values with their types and whether they can be changed at runtime. Many parameters can be updated without a restart using SET:
|
|
PAUSE and RESUME for Maintenance
One of PgBouncer’s most operationally useful features: PAUSE drains all active server connections and stops accepting new queries, queuing clients until RESUME is called. This enables zero-downtime PostgreSQL maintenance:
|
|
Clients see elevated latency during the pause window but do not receive errors (as long as client_login_timeout is not exceeded). This is the correct way to perform a PostgreSQL primary failover when PgBouncer is in front.
Prometheus Metrics
The pgbouncer_exporter (available as a standalone binary or Docker image at prometheuscommunity/pgbouncer-exporter) scrapes all SHOW command output and exposes it as Prometheus metrics. Key metrics to alert on:
pgbouncer_pools_client_waiting_connections > 0— pool pressurepgbouncer_pools_server_idle_connections == 0 AND pgbouncer_pools_client_active_connections > 0— no idle connections availablepgbouncer_pools_maxwait_seconds > 1— clients waiting too longpgbouncer_pools_server_active_connections / default_pool_size > 0.9— pool near saturation
Running PgBouncer
Installation
On Debian/Ubuntu, the package is in the main repository:
|
|
The package installs configuration to /etc/pgbouncer/pgbouncer.ini and the userlist to /etc/pgbouncer/userlist.txt. The systemd unit runs PgBouncer as the postgres user by default.
PgBouncer 1.25.0 added support for Type=notify-reload in the systemd unit (requires systemd 253+), which allows systemctl reload pgbouncer to trigger a config reload cleanly.
Docker Compose Sidecar
Running PgBouncer as a sidecar in a Docker Compose stack is the standard pattern for containerized deployments:
|
|
Health Checks
pg_isready works against PgBouncer the same as PostgreSQL:
|
|
For a deeper check that actually exercises the connection:
|
|
Connection Pooling with ORMs and Frameworks
The Double-Pooling Problem
Every major ORM and database driver has its own connection pooling layer. SQLAlchemy has QueuePool. Django has CONN_MAX_AGE. node-postgres has a Pool class. When you put PgBouncer in front of these, you have two layers of pooling, and the ORM’s pool holds actual server-side connections in PgBouncer’s server pool. The ORM pool is the client, PgBouncer is the server — but the connections the ORM holds are real PostgreSQL connections from PgBouncer’s perspective.
This is not inherently wrong, but it means you need to size both pools correctly and understand their interaction.
Django
Django’s CONN_MAX_AGE setting controls how long a database connection is reused between requests. The default is 0 (close after each request). A positive value keeps connections open, implemented as a per-thread connection cache.
When using PgBouncer in transaction mode, the correct Django configuration is:
|
|
Setting CONN_MAX_AGE = 0 means Django opens a new connection to PgBouncer for each request and closes it when the request ends. From PgBouncer’s perspective, clients connect and disconnect rapidly, but PgBouncer’s server-side connections to PostgreSQL persist. This is the intended pattern — PgBouncer does the pooling, Django does not.
If you set CONN_MAX_AGE to a positive value with transaction pooling, each Django thread holds a persistent connection to PgBouncer, which in turn holds that client’s server connection for the duration of any active transaction. This works but is less efficient: you are relying on Django’s connection count (threads × hosts) staying within PgBouncer’s max_client_conn, and PgBouncer’s transaction pooling still works for individual transactions within the persistent connection.
Django’s DatabaseWrapper does not use named prepared statements at the protocol level, so prepared statement conflicts are not an issue.
SQLAlchemy
SQLAlchemy’s connection pool interacts with PgBouncer in two important ways:
|
|
Prepared statements are the most common source of SQLAlchemy + PgBouncer problems. psycopg2 uses the PREPARE/EXECUTE protocol under the hood for statements executed more than prepare_threshold times (default: 5). When transaction pooling routes subsequent executions of that prepared statement to a different server connection, you get:
ProgrammingError: (psycopg2.errors.InvalidSqlStatementName)
prepared statement "s1" does not exist
The fix is to disable prepared statements at the driver level:
|
|
For serverless or very short-lived processes where connection setup cost is negligible, NullPool tells SQLAlchemy to not pool at all — it opens and closes a connection for each context manager block, and PgBouncer handles all the real pooling:
|
|
node-postgres (pg)
|
|
Node’s pg driver does not use named prepared statements by default in pool mode, so prepared statement conflicts are generally not an issue. However, if you use client.query() in statement mode with $1 parameters, the driver uses protocol-level parameters without preparing, which is safe.
GORM (Go)
|
|
GORM uses pgx as its underlying driver by default (gorm v2). pgx does use named prepared statements. For PgBouncer transaction mode compatibility:
|
|
PgBouncer vs Pgpool-II vs Supavisor vs RDS Proxy
These are not interchangeable tools solving the same problem in different ways — they occupy different positions on the complexity/capability spectrum, and choosing among them requires clarity about what you actually need.
PgBouncer has one job: connection pooling. It does it extremely well. The codebase is small, the configuration surface is manageable, and its event-driven single-process architecture means it adds approximately 50-100 microseconds of overhead per query with minimal CPU and memory cost. It handles tens of thousands of client connections on modest hardware. Its limitations are those described throughout this post: no query routing, no read/write splitting, no high-availability management, no built-in clustering.
Pgpool-II is a middleware layer with connection pooling as one of several features. It also provides: automatic load balancing of read queries across a primary and multiple replicas, query result caching, watchdog-based high availability with automatic failover, and parallel query execution across multiple PostgreSQL servers. The tradeoff is significant complexity. Pgpool-II uses a process-per-connection model itself (not event-driven), which limits its scalability and increases its per-connection overhead. Configuration is substantially more complex than PgBouncer. If you need read replica load balancing and are not already using a dedicated proxy layer (HAProxy, Patroni with built-in load balancing, or your cloud provider’s endpoint), Pgpool-II is worth evaluating. If you only need connection pooling, it is overkill that will cause you maintenance burden without commensurate benefit.
Supavisor is Supabase’s open-source Elixir-based connection pooler, initially developed for Supabase’s managed PostgreSQL platform and open-sourced in 2023. It is designed for multi-tenant SaaS deployments — the connection string encodes the tenant identifier, and Supavisor routes to the correct PostgreSQL instance. It supports both session and transaction pooling, exposes native Prometheus metrics, and is built on the BEAM VM, which handles massive concurrency efficiently. As of the current release, Supavisor has matured significantly and is worth serious consideration for new deployments, particularly in multi-tenant architectures or cloud-native environments where you want built-in observability. Note that as of early 2025, Supabase deprecated session mode on port 6543 in their hosted offering (port 6543 is transaction-only; session mode uses direct connections on port 5432). Supavisor’s self-hosted version retains both modes. Active development continues at github.com/supabase/supavisor.
RDS Proxy is AWS’s managed connection pooler, available for RDS PostgreSQL and Aurora PostgreSQL. It integrates with IAM authentication, AWS Secrets Manager, and provides automatic failover handling. The appeal is zero operational overhead — you configure it in the RDS console and point your application at the proxy endpoint. The cost is real: RDS Proxy is charged per vCPU of the underlying database, which can add meaningfully to your RDS bill. It also adds latency compared to PgBouncer running on the same host as your application. It only supports session and transaction modes, and has some limitations around the types of PostgreSQL features it supports. For AWS shops with compliance requirements around credential management (IAM auth, automatic rotation via Secrets Manager) and tolerance for the cost, it is a reasonable managed solution.
| PgBouncer 1.25 | Pgpool-II 4.x | Supavisor | RDS Proxy | |
|---|---|---|---|---|
| Language / Runtime | C | C | Elixir / BEAM | Managed (AWS) |
| Session mode | Yes | Yes | Yes | Yes |
| Transaction mode | Yes | Yes | Yes | Yes |
| Statement mode | Yes | No | No | No |
| Read replica balancing | No | Yes | No | No |
| Query result caching | No | Yes | No | No |
| Multi-tenant routing | No | No | Yes | No |
| Automatic failover | No | Yes (watchdog) | No | Yes |
| Prometheus native | Via exporter | No | Yes | Via CloudWatch |
| Prepared stmt support | Partial (1.21+) | Yes | Yes | Limited |
| Configuration complexity | Low | High | Medium | Low (console) |
| Per-query overhead | ~50-100 µs | ~200-500 µs | ~100-200 µs | ~1ms+ |
| Self-hosted | Yes | Yes | Yes | No |
| Best use case | General purpose | HA + read balancing | Multi-tenant SaaS | AWS managed |
Troubleshooting Common Issues
“sorry, too many clients already” — This comes from PostgreSQL, not PgBouncer. PgBouncer has exceeded max_connections on the PostgreSQL server. Check sv_active + sv_idle + sv_login across all pools with SHOW POOLS. The sum should be well below PostgreSQL’s max_connections. Also account for connections from monitoring tools, migration runners, and other processes that connect directly to PostgreSQL outside of PgBouncer.
“no more connections allowed (max_client_conn)” — This comes from PgBouncer. You have hit the max_client_conn ceiling. Check if your application is opening connections correctly and not leaking them. If your client count is genuinely that high, increase max_client_conn — it is cheap in PgBouncer (no process fork), but verify you are not masking an application connection leak.
maxwait growing — clients stuck in queue — Pool exhausted: all default_pool_size server connections are in use and new clients are waiting. Diagnose which connections are holding the pool: SHOW SERVERS to see which server connections are active, then check pg_stat_activity on PostgreSQL to see what those backends are doing. Common culprits: slow queries holding server connections longer than expected, lock waits, or missing COMMIT/ROLLBACK in error paths. Short term: increase default_pool_size if PostgreSQL has headroom. Long term: fix the slow queries.
“prepared statement X does not exist” — Your ORM or driver is using protocol-level prepared statements, and transaction pooling is routing subsequent executions to a different server connection that does not have the prepared statement. Fix: disable prepared statement caching at the driver level (see ORM section above), or enable PgBouncer’s built-in prepared statement support via max_prepared_statements. PgBouncer 1.24 turned on prepared statement support by default, so if you upgraded without testing this interaction, verify your driver configuration.
Authentication failures after updating userlist.txt — PgBouncer caches credentials at startup. After editing userlist.txt, send RELOAD via the admin console or systemctl reload pgbouncer. Check that the password format matches auth_type — a SCRAM verifier in userlist.txt with auth_type = md5 will fail.
Connections not being returned to pool / pool permanently exhausted — Long-running transactions that never commit or roll back hold server connections indefinitely. In transaction mode, a client that opens a BEGIN and then goes idle (network partition, application hang, forgotten transaction in code) holds a server connection until the connection is closed or the transaction times out. Use transaction_timeout (added in PgBouncer 1.25.0) and idle_transaction_timeout in PostgreSQL itself to bound how long an open transaction can sit idle.
Stale connections after NAT/firewall timeout — A connection through an AWS security group or a corporate NAT gateway can be silently closed by the network device after the idle timeout (typically 350-900 seconds in AWS). Neither PgBouncer nor PostgreSQL knows the connection is dead. The next attempt to use that connection fails with a cryptic socket error. Fixes: set tcp_keepalive = 1 in PgBouncer with tcp_keepidle shorter than the NAT timeout, and set server_idle_timeout in PgBouncer to also be shorter than the NAT timeout. Both mechanisms serve the same goal by different means — keepalives detect dead connections actively, idle timeout recycles them proactively.
SCRAM authentication performance — If you see high CPU on SCRAM authentication with many short-lived connections, PgBouncer 1.25.0 added a scram_iterations configuration option that reduces the PBKDF2 iteration count. The default of 4096 is the PostgreSQL default; reducing it to 1000 or even 100 is a reasonable tradeoff if authentication is a CPU bottleneck in your specific environment. Note that this only applies to new passwords set after changing the setting.
Sizing and Operational Recommendations
There is no universal formula for default_pool_size, but the following reasoning produces a good starting point for most OLTP applications:
Identify the maximum sustainable query throughput you need. For a typical web application running 5ms average query time, a single PostgreSQL connection can handle roughly 200 queries per second (1000ms / 5ms). If you need to handle 2,000 queries per second, you need at least 10 server connections actively running queries at all times. Add 50% headroom for spikes: default_pool_size = 15. Add reserve_pool_size = 5 for burst protection.
Monitor SHOW STATS avg_query_time and SHOW POOLS maxwait over time. If maxwait is consistently near zero and sv_idle is always large, you may be able to reduce default_pool_size. If maxwait is regularly above 100ms, increase it.
Keep the total server connections across all pools well below PostgreSQL’s max_connections. A reasonable rule: sum(default_pool_size across all pools) + sum(reserve_pool_size) + 20 (for direct connections) < max_connections. The 20 is headroom for monitoring, migrations, psql debugging sessions, and anything else that connects directly.
PgBouncer itself is not a single point of failure in the sense that if it crashes, your application is down — but it does need to be highly available in production. Run two PgBouncer instances behind a virtual IP (Keepalived/VRRP) or use a load balancer in front of multiple PgBouncer instances. PgBouncer’s statelessness (server connections are re-established on startup) makes active-passive failover straightforward. In Kubernetes, running PgBouncer as a sidecar in each application pod eliminates the single point of failure concern entirely at the cost of more total server connections to PostgreSQL.
Connection pooling solves a real structural problem in PostgreSQL, but it is not a substitute for query optimization. A pool full of slow queries is a pool full of connections — pooling buys you headroom to handle connection count, but if each of those connections is running a 10-second query that should take 10 milliseconds, no amount of pooling tuning will make your database perform well. PgBouncer gives you the data to see this: rising avg_query_time in SHOW STATS is the signal that query performance, not connection management, is the bottleneck.
Comments