Connection Pooling with PgBouncer
Learn to manage PostgreSQL connections efficiently with PgBouncer. This lesson covers pooling modes, configuration, and practical setup steps to reduce overhead and scale your app.
Focus: use connection pooling with pgbouncer
Every new PostgreSQL connection isn't just a socket — it's a full backend process forking, allocating memory, and negotiating the protocol. If your app churns through hundreds of connections a second, your database spends more time spawning processes than executing queries. This lesson shows you how to use connection pooling with PgBouncer to slash that overhead, handle thousands of clients with a handful of connections, and keep your PostgreSQL instance fast and stable.
The problem this lesson solves
PostgreSQL is a process-per-connection database. Each psql or application library session maps to a separate OS process that holds a slice of RAM, shares buffers, and consumes CPU just to stay alive. Under load, that becomes a death spiral:
- A web app with 50 app instances opens 20 connections each → 1,000 PostgreSQL processes.
- Each idle connection still consumes memory and lock-manager slots.
- Under a traffic spike, the server hits
max_connections(default 100) and starts rejecting new sessions withFATAL: sorry, too many clients already. - Even with low traffic, connection setup/teardown (TCP handshake + auth + process spawn) adds 50–100ms per new session.
That's the pain: you can't simply raise max_connections because each slot costs memory, and contention spikes. The standard fix is a connection pooler — a proxy that sits between your app and PostgreSQL and multiplexes many client connections onto a small set of real server connections.
Core concept / mental model
Think of PgBouncer as a connection valet at a busy restaurant. Thousands of customers (client connections) ring the bell, but only a dozen tables (real PostgreSQL connections) exist. The valet takes your order, passes it to a free table, and if none are free, you wait in a queue. When a table frees up, the valet seats you — you never see the kitchen directly.
The key numbers to remember:
- Clients: the connections your app opens to PgBouncer (can be thousands).
- Servers: the real connections PgBouncer holds open to PostgreSQL (much smaller).
- Pool size: the max number of server connections per user/database.
PgBouncer implements three pooling modes:
| Mode | Behavior | Best for |
|---|---|---|
session |
A client keeps its server connection for the entire session | Interactive tools, long-lived logical connections |
transaction |
A server connection is only held for the duration of a single transaction; released afterward | Web apps, OLTP — default choice |
statement |
Releases connection after each statement | Very short queries, but isolates transactions poorly |
In transaction mode, the pooler releases a server connection back to the pool as soon as COMMIT or ROLLBACK finishes — so a single server connection can serve hundreds of clients per second.
How it works step by step
Setting up PgBouncer involves four moving parts. Follow this logical order:
- Install PgBouncer on the same machine as your app (or a dedicated proxy host).
- Create a configuration file (
pgbouncer.ini) with connection details and pool sizing. - Create a userlist file with credentials that PgBouncer will use to connect to PostgreSQL.
- Start PgBouncer, then point your app at PgBouncer's port (usually
6432) instead of PostgreSQL's5432.
Under the hood, PgBouncer listens on its own port and forwards queries from clients to a cached set of backend connections. When a client disconnects, PgBouncer keeps the server connection alive for the next client — hence "pooling."
The flow looks like this in a transaction pool:
- Client opens a connection to PgBouncer (fast, light).
- Client sends
BEGIN;→ PgBouncer checks out a server connection from the pool and forwards the query. - Client sends
COMMIT;→ PgBouncer forwards it, then returns the server connection to the pool. - If all server connections are busy, the client waits in PgBouncer's queue until one becomes free.
Hands-on walkthrough
Let's get PgBouncer running. The examples assume Ubuntu/Debian, PostgreSQL 14+, and Python 3.10+ for the client test.
1. Install PgBouncer
# On Debian/Ubuntu
sudo apt update
sudo apt install -y pgbouncer
# On RHEL/CentOS
# sudo yum install pgbouncer
# Or with Docker
# docker run -d --name pgbouncer -p 6432:6432 edoburu/pgbouncer
2. Create configuration file
Create /etc/pgbouncer/pgbouncer.ini:
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
logfile = /var/log/pgbouncer/pgbouncer.log
pidfile = /var/run/pgbouncer/pgbouncer.pid
Key directives:
pool_mode:transactionis ideal for web apps.default_pool_size: how many server connections per (user, database) pair. Start at 20 even if you have 100 max_connections.max_client_conn: max incoming client connections to PgBouncer.reserve_pool_size: extra connections to allocate when the pool is exhausted — a safety valve.
3. Create auth file
Create /etc/pgbouncer/userlist.txt with the same credentials as your PostgreSQL user:
"postgres" "secretpassword"
"appuser" "apppassword"
4. Start PgBouncer and test
sudo systemctl restart pgbouncer
sudo systemctl status pgbouncer
Verify you can connect via PgBouncer:
psql -h 127.0.0.1 -p 6432 -U appuser -d appdb -c "SELECT 1;"
Expected output:
?column?
----------
1
(1 row)
5. Test with a Python script
Here's how your app connects now — instead of port 5432, use 6432:
import psycopg2
def get_conn():
return psycopg2.connect(
host="127.0.0.1",
port="6432", # PgBouncer
dbname="appdb",
user="appuser",
password="apppassword"
)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM users")
print(cur.fetchone())
Output: (1000,) — a normal result, but now your app uses a pooled connection.
Pro tip: In transaction mode, never hold a connection open across user interaction — every transaction should be short. PgBouncer will still work but may block other clients if you keep long-running idle transactions.
Compare options / when to choose what
You have several pooling solutions. Here's a comparison:
| Option | Pros | Cons | Use case |
|---|---|---|---|
| PgBouncer | Lightweight, supports transaction pooling, battle-tested | No SQL parsing, doesn't handle failover itself | Production OLTP with many app instances |
| Pgpool-II | More features: load balancing, replication | Heavier, more config, can introduce complexity | When you need read/write splitting |
| Application-side pooling (e.g., SQLAlchemy pool) | Simple, no extra daemon | Each app still opens many processes; doesn't reduce backend connections if pool size large | Small apps, single instance |
| Built-in connection pooling (PostgreSQL 17+) | No extra component | Still session-based; new feature, less mature | Very new projects, session persistence |
When to choose PgBouncer:
- You have a web app with many app instances (Docker, Kubernetes).
- You hit
too many clientserrors. - You want to keep
max_connectionslow (100–300) for stability.
When NOT to use PgBouncer:
- Your workload uses heavy
NOTIFY/LISTEN— PgBouncer can break event notifications in transaction mode. - You need prepared statements across transactions (PgBouncer can disable them, but it's tricky).
- You run a tiny prototype with 5 connections — not worth the extra hop.
Troubleshooting & edge cases
FATAL: sorry, too many clients already— This is from PostgreSQL, meaning PgBouncer's pool is too small relative to concurrent transactions. Increasedefault_pool_sizeormax_client_conncarefully, checkSHOW POOLS;andSHOW STATS;inpsql -p 6432to see queue lengths.ERROR: prepared statement "" already exists— In transaction mode, prepared statements don't persist across transactions. UseDEALLOCATE ALLon connection close, or disable prepared statements in your driver (e.g., setprepared_statements=Falsein psycopg2).NOTICE: transaction blocks not allowed in statement mode— You've setpool_mode=statementbut your app uses multi-statement transactions. Switch totransactionmode.- Authentication failures — Ensure the user in
userlist.txtmatches exactly the PostgreSQL role, and thatauth_typematches the PostgreSQLpg_hba.conf(e.g.,md5vsscram-sha-256). PgBouncer also supportsauth_type = scram-sha-256. - PgBouncer not accepting connections — Check
listen_addris correct (0.0.0.0 binds all interfaces), and ensure no firewall blocking port 6432. Also check the log file for startup errors.
What you learned & what's next
You now understand the core problem: PostgreSQL's process-per-connection model is expensive and limiting. You can use connection pooling with PgBouncer to multiplex thousands of client connections onto a small pool of backend connections using transaction pooling mode. You've configured pgbouncer.ini, set authentication, and pointed your app at port 6432. You also know how to compare PgBouncer with other pooling strategies and troubleshoot common issues like too many clients and prepared statement conflicts.
Next lesson in this PostgreSQL track covers read replicas and load balancing — how to scale reads across multiple PostgreSQL instances. Connection pooling is the foundation; Read replicas extend your architecture horizontally.
Ready to apply this? In the next exercise, you'll set up PgBouncer for a Flask app and see how your query latency drops under concurrency.
Practice recap
Now practice: install PgBouncer on your local PostgreSQL instance, configure it in transaction mode with a pool size of 10, and write a Python script that opens 50 concurrent connections to check for errors. Use SHOW POOLS in psql to observe how many client connections are waiting. Then experiment by changing pool_mode to session and note the difference in latency under load.
Common mistakes
- Setting
pool_mode = sessionby default — you miss out on the biggest performance gain; usetransactionfor web apps. - Creating userlist.txt with incorrect passwords, then wondering why auth fails — PgBouncer may use a different auth method than PostgreSQL.
- Using PgBouncer with long-running transactions or
NOTIFY/LISTENwithout understanding the side effects — transactions hold pool slots, and notifications may be lost. - Forgetting to update your app's port from 5432 to 6432 — connections still go directly to PostgreSQL, bypassing the pool.
Variations
- Use
pgbouncer.inienvironment variables or Docker secrets to manage credentials in containerized deployments. - Consider Pgpool-II when you also need read/write splitting and built-in replication management.
- Try PostgreSQL 17's built-in connection pooling if you want to avoid an extra component (though still in active development).
Real-world use cases
- A large e-commerce site with dozens of app instances connecting during Black Friday — PgBouncer keeps PostgreSQL from collapsing under connection storms.
- A microservices architecture where each service has its own pool size per database, preventing one noisy service from exhausting connections.
- A SaaS background job worker that opens and closes thousands of connections during batch processing — PgBouncer amortizes the cost of connection setup.
Key takeaways
- PostgreSQL uses one process per connection; pooling reduces memory and CPU overhead dramatically.
- PgBouncer in transaction mode gives the biggest win for OLTP web apps.
- Understanding
default_pool_size,max_client_conn, andreserve_pool_sizeis critical for sizing. - Always point your app at port 6432, not 5432, after PgBouncer setup.
- Troubleshoot pool exhaustion with
SHOW POOLSand Tune the pool settings. - PgBouncer is not a replacement for query tuning — it scales connections, not slow queries.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.