Guide

Connection Routing with PgBouncer

Part 4 of 5By Amith Kumar6 min read
Part 4 of 5PostgreSQL Replication and HA: A Hands-On Guide
  1. 1Streaming Replication: A Live Standby
  2. 2Synchronous Replication and Slots
  3. 3Failover and Promotion
  4. 4Connection Routing with PgBouncer
  5. 5Backups and Point-in-Time Recovery
Verified 22 Jul 2026 · Ubuntu 24.04 LTS · PostgreSQL 16.14

The layer between your app and two databases

PostgreSQL connection routing is the job of sending each query to the right server: writes to the primary, reads to the standby, over pooled connections that a busy app can reuse. This chapter puts PgBouncer in front of a primary and standby on a live Ubuntu 24.04 server and proves each pool lands on the server you expect.

Key takeaways
  • PgBouncer sits between the app and PostgreSQL, so hundreds of client connections ride on a handful of backend ones.
  • A second logical database in PgBouncer can point at the standby, giving you a read pool and a write pool on one port.
  • Transaction pooling assigns a backend only for the length of a transaction, which packs many clients onto few connections.
  • The app decides which pool to use, so a read-write split is a routing choice, not database magic.

PostgreSQL runs one process per connection, so a few hundred idle connections waste real memory. A pooler solves that by keeping a small set of backend connections and handing them to clients as needed. With a primary and a standby, the pooler also becomes the natural place to split traffic: point one pool at the primary for writes and another at the standby for reads, and the app just chooses a database name.

Prerequisites

Before you start
  • The streaming primary on 5432 and standby on 5433 from the earlier chapters, on Ubuntu 24.04 LTS.
  • PgBouncer installed and listening on 127.0.0.1:6432 with an auth file for the app roles.
  • An app_user role for writes and a readonly role for reads, present on both clusters.
  • Sudo and psql access, plus the PgBouncer admin console reachable as an admin user.

PostgreSQL connection routing with a pooler

The trick is two logical databases in PgBouncer that point at different clusters. The [databases] section maps a name the app connects to onto a real host and port.

[databases]
shopdb    = host=127.0.0.1 port=5432 dbname=shopdb
shopdb_ro = host=127.0.0.1 port=5433 dbname=shopdb

Reload PgBouncer and both names are live on the one port, 6432. Now prove where each lands by asking each connection whether it is on a primary or a standby, and which backend port answered.

psql "host=127.0.0.1 port=6432 dbname=shopdb user=app_user" \
  -c "SELECT pg_is_in_recovery(), inet_server_port();"
psql "host=127.0.0.1 port=6432 dbname=shopdb_ro user=readonly" \
  -c "SELECT pg_is_in_recovery(), inet_server_port();"
Through PgBouncer on port 6432 the write pool answers on backend 5432 not in recovery, and the read pool answers on backend 5433 in recovery, a working read write split

The write pool answers f on backend port 5432, the primary. The read pool answers t on backend port 5433, the standby. Same client port, two destinations. Your app writes through shopdb and reads through shopdb_ro, and neither connection knows or cares that a pooler sits in the middle.

Checkpoint

Through port 6432 the write pool should report f on backend 5432 and the read pool t on backend 5433. If both answer the same backend, the two [databases] lines point at one port, so fix the port and reload PgBouncer before you route real traffic.

Read the pool from the admin console

PgBouncer exposes its own state through a pseudo-database called pgbouncer. Connect to it as an admin user and ask what it is doing.

psql "host=127.0.0.1 port=6432 dbname=pgbouncer user=app_user" -c "SHOW POOLS;"
The PgBouncer admin console listing both logical databases pointing at 5432 and 5433, with one transaction-mode pool each holding a single idle backend connection

SHOW DATABASES lists both logical databases and the backend each points at, 5432 and 5433. SHOW POOLS shows one pool per database, both in transaction mode, each with a single idle server connection ready. That single sv_idle connection is the whole point: many clients, one backend link per pool. The counts hold steady because transaction pooling hands the backend back the instant each transaction ends.

Prove the reuse with pool stats

Numbers make it concrete. Run a burst of queries through each pool, then read the counters.

psql "host=127.0.0.1 port=6432 dbname=pgbouncer user=app_user" -c "SHOW STATS;"
PgBouncer SHOW STATS reporting dozens of client transactions and queries per logical database served over a single pooled backend connection each

SHOW STATS reports the query and transaction counts per logical database: 24 on the write pool, 10 on the read pool in this run, all served over one pooled backend connection each. That is dozens of client transactions carried on a single backend link, which is exactly the memory saving a pooler exists for. On a small VPS this is the difference between surviving a traffic spike and running out of connections.

Going further: a VIP so failover is invisible

Routing solves reads and writes, but not the address change when the primary fails over. Today the write pool names 5432 by host and port. In production you point that write pool at a virtual IP that floats between servers, moved by keepalived or your failover tool to whichever node is currently the primary. When a promotion happens, the VIP moves, the pool reconnects, and the app never learns a new address. The pooler routes, the VIP follows the primary, and failover becomes a blip instead of an outage.

Troubleshooting routing

A pool authenticates but every query is read-only. The write pool is pointed at the standby by mistake. Check the [databases] line and confirm the primary's port, then reload PgBouncer.

Clients wait and never get a backend. The pool size is too small for the load, or backends are stuck in long transactions. Raise default_pool_size carefully, and make sure the app commits promptly so transaction pooling can recycle connections.

A prepared-statement error appears after moving to transaction pooling. Transaction mode does not keep server-side state between transactions. Use protocol-level prepared statements supported by recent PgBouncer, or disable server-side prepares in the client.

Frequently asked questions

Does PgBouncer decide reads versus writes on its own?

No. PgBouncer routes by the database name the client connects to, not by inspecting the query. The read-write split is the app choosing the read pool or the write pool. Tools that parse SQL to route exist, but the simple and predictable approach is app-chosen pools.

What does transaction pooling actually change?

A backend connection is assigned only for the duration of a transaction, then returned to the pool, so many short client transactions share a few backends. It packs connections tightly but drops features that rely on session state, such as session-level prepared statements.

How does a client find the primary after failover?

Point the write pool at a virtual IP that your failover tool moves to the current primary. When a standby is promoted, the VIP follows it, the pool reconnects to the same address, and the application does not need any config change.

Can I run PgBouncer on the app server or the database server?

Either works. On the app server it cuts connection setup across the network; on the database server it centralizes limits. Many setups run one near the app for pooling and rely on a VIP in front of the databases for failover.

What you have, and what comes next

You have PgBouncer routing writes to the primary and reads to the standby on one port, with pool stats proving many client transactions ride on a single backend each. You also have the concept that makes failover invisible to clients: a VIP the primary role follows.

Replication protects you from a dead server, but not from a bad statement or a dropped table, which a standby copies faithfully within a second. The last chapter is the safety net that catches human error: base backups, WAL archiving, and rewinding the clock to a moment before the mistake. See Backups and Point-in-Time Recovery.


Run HA Postgres on slices

Run highly-available Postgres on slices

A standby and failover need more than one box. Spin up a couple of slices, set up streaming replication and PITR from this guide, and survive a node dying.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot