Configuring PgBouncer for Read-Only Connection Pools
Problem statement: Your application connects to a PostgreSQL replica through PgBouncer, but writes are landing on the replica, sessions are leaking state across clients, or backend connections are exhausted β and you need a step-by-step path to a correct, production-hardened read-only pool configuration.
Symptom Identification
Look for these signals in production before concluding PgBouncer is misconfigured:
ERROR: cannot execute INSERT in a read-only transactionβ writes are reaching the replica. Eitherdefault_transaction_read_onlyis not enforced on the replica, or a service is bypassing PgBouncer and writing directly.SHOW POOLSshowswaiting > 0withactivepinned atdefault_pool_sizeβ the pool is saturated. Common cause:pool_mode = sessionholding backend connections hostage through idle ORM periods.- Stale query results or
state = idle in transactionrows inpg_stat_activityβserver_reset_queryis missing, letting session state leak between clients. FATAL: remaining connection slots are reserved for non-replication superuser connectionson the replica βdefault_pool_sizeis set too high relative tomax_connectionson the replica.- Replication lag spiking while read throughput is nominal β WAL replay is being starved; check
max_standby_streaming_delayand replica CPU.
Root Cause Analysis
These failures share three underlying causes:
1. PgBouncer does not enforce read-only semantics by default. It is a connection multiplexer, not a query router. Without explicit default_transaction_read_only = on at the replica level (or at the role level), write-capable application roles will succeed in executing DML on the replicaβs connection until the replica itself rejects the write.
2. pool_mode = session is inappropriate for ORM-driven workloads. Session mode pins one backend connection to one client TCP connection for the full lifetime of the session. Modern application servers open connection pools with idle connections that stay alive for minutes β this holds backend connections on the replica even when no queries are running, causing starvation under moderate concurrency. Transaction mode releases the backend connection immediately after each transaction, multiplexing many more clients over the same backend pool.
3. Missing server_reset_query allows state leakage. When PgBouncer reuses a backend connection for a new client, any session state from the previous client β SET overrides, advisory locks, temporary tables, prepared statements β is still present unless DISCARD ALL is issued between uses.
Understanding the connection pool architecture for read replicas and the broader connection routing and pooling strategies for your stack is a prerequisite before sizing these parameters.
Architecture: PgBouncer in the Read Path
The diagram below shows the recommended topology: a dedicated PgBouncer instance fronts one or more replicas, isolated from the primary write path.
Step-by-Step Resolution
Step 1: Validate replica readiness
Before routing any traffic through PgBouncer, confirm all target replicas are in hot_standby mode and streaming WAL without significant lag.
# Confirm the replica accepts connections
pg_isready -h replica-lb.internal -p 5432
# Check standby state and WAL positions
psql -h replica-lb.internal -c "
SELECT pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::INT AS lag_seconds;"Verify these settings in postgresql.conf on every replica before proceeding:
hot_standby = on
wal_level = replica
max_standby_streaming_delay = 30sIf replication lag is above your SLA threshold, do not route traffic to that replica yet.
Inline verification: pg_is_in_recovery() must return t. lag_seconds above 30 in steady state indicates a replica under WAL replay pressure.
Step 2: Define the read-only database alias
Add a dedicated alias in /etc/pgbouncer/pgbouncer.ini. The alias must point only to replica endpoints β never mix primary hostnames in this mapping.
PgBouncer does not natively distribute connections across a comma-separated host list. Use a DNS round-robin name or a TCP load balancer VIP as the host value to distribute load across multiple replicas. This keeps PgBouncerβs config stable when replica IPs change.
[databases]
# Read-only alias β resolves to replica LB only
appdb_ro = host=replica-lb.internal port=5432 dbname=appdbInline verification: psql -h 127.0.0.1 -p 6432 -d appdb_ro -U app_read_user -c "SELECT inet_server_addr();" should return a replica IP, not the primaryβs.
Step 3: Configure pool mode and connection limits
For stateless, high-throughput read workloads, pool_mode = transaction is the correct choice. It releases the backend connection immediately after each transaction commits or rolls back, multiplexing many more application clients over a bounded set of backend connections.
Size default_pool_size against the replicaβs max_connections:
max_safe_pool_size = (replica_max_connections - superuser_reserved_connections) / active_pgbouncer_instances
Setting default_pool_size above this ceiling triggers FATAL: remaining connection slots are reserved for non-replication superuser connections on the replica.
[pgbouncer]
pool_mode = transaction
max_client_conn = 500
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 300
client_idle_timeout = 0
server_lifetime = 3600For a full sizing reference, see the connection pool architecture for read replicas sizing matrix.
Inline verification: SHOW POOLS; in the PgBouncer admin console should show waiting = 0 under normal load. If waiting > 0 persists, default_pool_size is too low or server_idle_timeout is causing premature disconnections.
Step 4: Configure authentication for read-only roles
Create a dedicated userlist.txt containing only read-only PostgreSQL roles. Never reuse primary write credentials in this file.
Generate SCRAM-SHA-256 hashes from the primary:
SELECT rolpassword FROM pg_authid WHERE rolname = 'app_read_user';Write the result to /etc/pgbouncer/userlist.txt:
"app_read_user" "SCRAM-SHA-256$:$:"
"reporting_svc" "SCRAM-SHA-256$:$:" For legacy MD5 authentication: "username" "md5<hash>".
Restrict file permissions β PgBouncer will refuse to start if userlist.txt is world-readable:
chmod 600 /etc/pgbouncer/userlist.txt
chown pgbouncer:pgbouncer /etc/pgbouncer/userlist.txtInline verification: Attempt a connection as a role that does NOT appear in userlist.txt β it must fail with FATAL: password authentication failed.
Step 5: Enforce read-only isolation at the replica and proxy layer
Two complementary controls are needed β neither alone is sufficient.
On the replica (postgresql.conf):
-- Enforce globally on the replica
ALTER SYSTEM SET default_transaction_read_only = on;
SELECT pg_reload_conf();Setting this at the replica level is the authoritative control. It catches connections that bypass PgBouncer entirely (direct psql, monitoring agents, misconfigured ORMs).
On PgBouncer (for session state hygiene):
server_reset_query = DISCARD ALLDISCARD ALL clears prepared statements, session variables, temporary tables, and advisory locks between client sessions. Do not attempt to enforce default_transaction_read_only inside server_reset_query via SET β DISCARD ALL executes last and would undo it. Use the postgresql.conf approach instead.
Inline verification:
-- Through the PgBouncer endpoint β both must pass
SELECT pg_is_in_recovery(); -- must return t
SHOW default_transaction_read_only; -- must return onStep 6: Reload and validate end-to-end
Apply the configuration without dropping active client connections:
kill -HUP $(pgrep pgbouncer)
# Confirm reload
tail -n 20 /var/log/pgbouncer/pgbouncer.log | grep "reloading config"Run the full validation suite through the PgBouncer read endpoint:
-- Confirms you are connected to a replica
SELECT pg_is_in_recovery();
-- Confirms read-only enforcement is active
SHOW default_transaction_read_only;
-- Confirms which replica is serving the connection
SELECT inet_server_addr() AS backend_ip, current_user;
-- Confirm a write is correctly rejected
INSERT INTO pg_temp.test_write (id) VALUES (1);
-- Expected: ERROR: cannot execute INSERT in a read-only transactionCheck pool health in the PgBouncer admin console (connect to the pgbouncer database on port 6432):
SHOW POOLS;
-- active <= default_pool_size, waiting = 0, server_idle should be < default_pool_size
SHOW STATS;
-- avg_query_time rising sharply indicates replica CPU contentionConfiguration Snippet
Minimal production-ready pgbouncer.ini for a dedicated read pool:
[databases]
# Read-only alias β DNS name or LB VIP resolving to replicas only
appdb_ro = host=replica-lb.internal port=5432 dbname=appdb
[pgbouncer]
# Transaction mode: releases backend connection after every txn
pool_mode = transaction
# Auth file β read-only roles only, chmod 600
auth_file = /etc/pgbouncer/userlist.txt
auth_type = scram-sha-256
# Pool sizing β keep below replica max_connections budget
max_client_conn = 500
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
# Connection hygiene
server_reset_query = DISCARD ALL ; clears state between clients
server_idle_timeout = 300 ; reclaim idle backends after 5 min
server_lifetime = 3600 ; force-recycle backends hourly
client_idle_timeout = 0 ; let app decide client timeout
# Logging
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
listen_port = 6432
listen_addr = *
# Admin access (restrict in production)
admin_users = pgbouncer_adminVerification and Rollback
Confirm the fix is working
-- Monitor pool saturation in real time
SHOW POOLS;
-- Monitor actual backend connections on the replica
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
-- Confirm no idle-in-transaction leak
SELECT client_addr, state, wait_event_type, query_start
FROM pg_stat_activity
WHERE state = 'idle in transaction';
-- Monitor replication lag while load is running
SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::INT AS lag_seconds;Revert safely if something goes wrong
If routing instability or connection exhaustion occurs, revert to the last known-good configuration immediately:
cd /etc/pgbouncer/
git checkout HEAD -- pgbouncer.ini userlist.txt
kill -HUP $(pgrep pgbouncer)For an online restart that hands off sockets without dropping clients, use PgBouncerβs -R flag:
pgbouncer -R /etc/pgbouncer/pgbouncer.iniThe old process exits once existing clients disconnect naturally.
If the replica pool is unrecoverable, temporarily bypass it by routing application connections directly to the primary:
- Update environment variable:
DB_HOST=<primary_host>andDB_NAME=appdb(write alias) - Deploy the configuration change.
- Verify with
SELECT pg_is_in_recovery();β must returnfon the primary. - Once replica metrics and PgBouncer pool health stabilize, restore the read-only alias and redeploy.
Edge Cases and Gotchas
Cascading replication and hot_standby_feedback
When a replica replicates from another replica (cascading topology), hot_standby_feedback = on on the downstream replica propagates its oldest transaction snapshot back to the upstream. This prevents the upstream from vacuuming rows the downstream still needs, but it can cause table bloat on the primary if the downstream replica falls far behind. If you are pooling connections to a cascading replica, monitor pg_stat_replication on the upstream for slot lag in addition to WAL receive lag on the downstream.
SET statements and transaction mode
In pool_mode = transaction, PgBouncer releases the backend after each transaction, so any SET command applied within a transaction boundary is gone by the next query. This catches ORM drivers that issue SET search_path, SET application_name, or SET timezone at connection time β those settings will not persist between queries. The correct fix is to configure these at the role level (ALTER ROLE app_read_user SET search_path = myschema, public;) or in postgresql.conf. Never rely on session-level SET in transaction pooling mode.
Multi-region deployments and DNS TTL
In multi-region setups, replica-lb.internal may resolve to region-local replicas via split-horizon DNS. PgBouncer resolves the hostname at startup and caches it. If the replica behind the DNS name changes (failover, maintenance), PgBouncer will not re-resolve until the backend connection is recycled. Set server_lifetime short enough (e.g., 600β1800 seconds) to ensure stale cached IPs are periodically flushed. For zero-downtime replica rotation, prefer a VIP or a load balancer with active health checks over raw DNS.
FAQ
Why does PgBouncer transaction mode break SET statements?
In transaction mode, the backend connection is released after each transaction. Any SET or local GUC applied earlier is gone. Use postgresql.conf to set session parameters that must persist, or use ALTER ROLE ... SET to apply them at login time.
Can PgBouncer load-balance across multiple replicas?
Not natively. PgBouncer targets a single host per database alias. Use DNS round-robin, a TCP load balancer VIP, or run one PgBouncer instance per replica behind HAProxy to distribute connections. For avoiding connection exhaustion during replica failover, the HAProxy approach gives you active health checking.
What happens if server_reset_query is omitted?
PgBouncer reuses backend connections without clearing state. Session variables, advisory locks, temporary tables, and prepared statements from a previous client can bleed into the next one, causing incorrect query results or permission errors.
Related
β Back to Connection Pool Architecture for Read Replicas
- Avoiding Connection Exhaustion During Replica Failover β PgBouncer limits, circuit-breaker patterns, and TCP keepalive tuning to survive replica promotion events without cascading timeouts.
- Implementing Read/Write Splitting at the Proxy Layer β how proxy-layer tools route
SELECTvs DML at the protocol level, and where PgBouncer fits in a split-routing topology. - Detecting and Handling Replication Lag in Real Time β metrics and query patterns to monitor WAL replay lag on the replicas PgBouncer is pooling to.
- Connection Routing & Pooling Strategies β the parent section covering DNS-based routing, proxy-layer splitting, ORM middleware, and sticky session management.