Module 6: Advanced Connection Pooling
PgBouncer fundamentals: the external pool and its three modes
Capsule overview
Up to here the pool lives inside your app: SQLAlchemy manages connections, asyncpg talks to PostgreSQL directly. It works perfectly up to a point. Then it hits walls:
- 4 FastAPI instances ×
pool_size=20= 80 potential connections. Ifmax_connections=100in PostgreSQL, you're already at the limit (capsule 02). - Each real connection costs ~10-30MB of RAM in PostgreSQL. Raising
max_connectionsdoesn't scale (capsule 02). - Your pool's idle connections aren't leveraged globally — instance A can have 15 idle while B needs 5 more and doesn't have them.
PgBouncer solves this. It's an external, lightweight process that sits between your app and PostgreSQL:
[App: 100 client connections] → [PgBouncer] → [25 real connections to PostgreSQL]
The clients see 100 connections available. PostgreSQL only sees 25. PgBouncer does the multiplexing.
This capsule teaches you:
- Architecture of PgBouncer: how it interposes itself, what problem it solves.
- The three pooling modes (session, transaction, statement): what each one does, what features break, when each one is appropriate.
- Minimal setup with docker-compose for your bookstore.
- Critical administrative commands:
SHOW POOLS,SHOW STATS,SHOW CLIENTS,RELOAD. - An explicit decision matrix: given your case, which mode to choose?
By the end you'll have PgBouncer running locally, connect through it, and understand what consequences each chosen mode has.
Mental model: the hotel concierge
Remember the model from capsule 02: PostgreSQL is a hotel with a limited 100 rooms. Each guest (connection) consumes a room.
PgBouncer is the hotel's concierge:
- The visitors (client apps) arrive at the concierge, not at the hotel directly.
- The concierge has a large waiting room (can handle many simultaneous clients).
- When the concierge needs a real room, they request it from the hotel.
- When the guest no longer needs the room, the concierge returns it so another can use it.
The hotel only sees "the concierge occupied X real rooms" — it doesn't care how many visitors there are on the concierge's side.
The three PgBouncer modes are three different concierge policies:
- Session pooling: the concierge assigns a room to the visitor for their entire stay. If 100 visitors arrive at the same time, they need 100 rooms. It multiplexes nothing.
- Transaction pooling: the concierge assigns a room only during the transaction (a check-in for a specific operation). When it finishes, they release it. Another visitor can enter the same room seconds later. It multiplexes efficiently.
- Statement pooling: the concierge changes the room for each statement. Maximally efficient but it breaks anything that needs continuity between statements (like a transaction).
The architecture: where PgBouncer sits
┌─────────────────────────────────────────────────────────┐
│ 4 FastAPI instances │
│ pool_size=20 each │
│ → Up to 80 connections to the "next step" │
└─────────────────────────────────────────────────────────┘
↓ (TCP, port 6432)
┌─────────────────────────────────────────────────────────┐
│ PgBouncer (lightweight process, ~10MB RAM total) │
│ max_client_conn=200 (can accept up to 200 clients) │
│ default_pool_size=25 (max real connections to PG) │
└─────────────────────────────────────────────────────────┘
↓ (TCP, port 5432)
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ max_connections=100 │
│ Only sees 25 connections from PgBouncer │
└─────────────────────────────────────────────────────────┘
Benefits:
- Your app can scale to more instances without exhausting PostgreSQL's
max_connections. You go from 4 instances to 20 without touching the DB. - Real connections to PostgreSQL are fewer → less RAM consumed on the DB server → more available for
shared_buffersand queries. - Pooling is global, not per instance. If instance A doesn't use its 20 connections, B can leverage them.
- Fast reconnect on the client side. PgBouncer keeps real connections to PostgreSQL always warm; if an app instance restarts, it doesn't pay the full handshake.
Costs:
- One more piece in your infra. You have to monitor it, deploy it, maintain it.
- Very small additional latency (~0.1-1ms per proxy hop if it's on the same host).
- Some PostgreSQL features break depending on the mode (the critical point of the next sections).
The three pooling modes
This is the heart of the capsule. Each mode has a radically different behavior and breaks different things.
Mode 1: Session pooling
Behavior: the real connection to PostgreSQL is assigned to the client when it connects and isn't released until the client disconnects. If your app opens a connection and keeps it in its pool for 1 hour, that real connection is dedicated to your app during that hour.
Client A connects → PgBouncer opens real connection #5 to PG, assigns it to A.
Client A runs queries for 30 minutes.
Client A closes the connection → PgBouncer releases real connection #5 (can assign it to B).
Compatibility: 100%. It works exactly like a direct connection to PostgreSQL. All the features:
- Prepared statements ✅
- LISTEN/NOTIFY ✅
- SET LOCAL ✅
- Advisory locks ✅
- Cursors WITH HOLD ✅
- Temp tables ✅
When to use:
- Apps with long-lived connections that keep session state (uncommon in FastAPI, common in desktop applications or legacy connectors).
- If your app needs features that break in transaction mode (LISTEN/NOTIFY, non-parameterizable prepared statements, etc.).
- Initial migration: start with session mode while you adopt PgBouncer, then evaluate transaction mode.
When NOT to use:
- High-throughput async apps (FastAPI with asyncpg). The client keeps idle connections between requests, so session mode doesn't multiplex — it's like having PgBouncer without its benefits.
Mode 2: Transaction pooling
Behavior: the real connection is assigned to the client only during the transaction. When the client does a COMMIT (or ROLLBACK, or finishes without an explicit transaction), PgBouncer releases the real connection so another client can use it.
Client A starts a transaction (BEGIN) → PgBouncer assigns it real connection #5.
Client A runs queries.
Client A does COMMIT → PgBouncer releases #5, makes it available.
Client B starts a transaction → PgBouncer assigns it #5 (the same real connection).
Maximum multiplexing: 100 clients idle between transactions can share 25 real connections while only 5-10 are in active transactions.
Compatibility: PARTIAL. These features break:
- ❌ Prepared statements (more detail below, it's the asyncpg gotcha).
- ❌ LISTEN/NOTIFY (depends on a persistent session).
- ❌ SET LOCAL ... persistent (only lasts the current transaction).
- ❌ Cursors WITH HOLD (need a session).
- ❌ Temp tables persistent between transactions.
- ❌ Some session advisory locks (
pg_advisory_locknotpg_advisory_xact_lock). - ❌ Session variables (
SET application_name = ...only lasts the transaction).
These do work:
- ✅ Simple queries with parameters.
- ✅ Complete transactions (BEGIN ... COMMIT).
- ✅ SET LOCAL inside the transaction.
- ✅ Transaction advisory locks (
pg_advisory_xact_lock). - ✅ Temp tables that live only in the transaction.
When to use:
- Recommended default for async FastAPI. It's the mode that makes the most of PgBouncer.
- High-throughput apps without a need for persistent session features.
- When you can pay the cost of adapting your code (capsule 06:
statement_cache_size=0).
When NOT to use:
- Apps that depend on LISTEN/NOTIFY (a realtime notification system over PostgreSQL).
- Apps with persistent session variable SETs (uncommon, some legacy integrations).
- If you can't disable asyncpg's statement cache (rare, almost always you can).
Mode 3: Statement pooling
Behavior: the real connection is assigned per each individual statement. When the statement finishes, the connection returns to the pool.
Client A: SELECT 1 → PgBouncer gives it #5, executes, releases #5.
Client A: SELECT 2 → PgBouncer gives it #7, executes, releases #7.
Compatibility: BROKEN. Only individual stateless queries work:
- ❌ Multi-statement transactions (BEGIN ... statement ... COMMIT).
- ❌ Everything that breaks transaction mode also breaks here.
- ❌ Prepared statements just like transaction.
When to use:
- Almost never. Very specific cases of pure read loads without transactions.
- Some cache integrations (Redis-style usage of PostgreSQL).
When NOT to use:
- 99% of cases. If your code has any
BEGIN ... COMMIT, it's not for you.
Decision matrix: which mode to choose
| Your case | Recommended mode | Why |
|---|---|---|
| Typical async FastAPI (most cases) | Transaction | Maximum multiplexing, known and manageable gotchas. |
| App that uses LISTEN/NOTIFY | Session | Transaction breaks it without a workaround. |
| App that uses SET for a persistent session | Session | Transaction only allows SET LOCAL in a transaction. |
| App with long-lived connections (desktop, legacy) | Session | No pool-switching overhead. |
| Initial migration (test PgBouncer without breaking changes) | Session first | Migrate to Transaction after validating. |
| Pure-reads app without transactions | Statement (rare) or Transaction | Statement only if you validated you don't use transactions. |
App needs prepared statements but you can't set statement_cache_size=0 | Session | Workaround impossible. |
General heuristic: start in transaction mode (it's what Supabase, AWS RDS Proxy, Neon, and most providers recommend). If you find a feature you need and it breaks, before switching to session mode, evaluate whether you can refactor the app — most of the breakages have workarounds.
Minimal setup with docker-compose
The bookstore already has PostgreSQL in a container. We're going to add PgBouncer.
# docker-compose.yml
version: "3.9"
services:
bookstore-pg:
image: postgres:16
environment:
POSTGRES_USER: bookstore
POSTGRES_PASSWORD: bookstore
POSTGRES_DB: bookstore
ports:
- "5432:5432"
volumes:
- bookstore_data:/var/lib/postgresql/data
- ./pg-config/postgresql.conf:/etc/postgresql/postgresql.conf
command: postgres -c config_file=/etc/postgresql/postgresql.conf
pgbouncer:
image: edoburu/pgbouncer:1.22.1
environment:
DB_USER: bookstore
DB_PASSWORD: bookstore
DB_HOST: bookstore-pg
DB_PORT: "5432"
DB_NAME: bookstore
POOL_MODE: transaction # session | transaction | statement
MAX_CLIENT_CONN: "200"
DEFAULT_POOL_SIZE: "25"
RESERVE_POOL_SIZE: "5"
RESERVE_POOL_TIMEOUT: "3"
AUTH_TYPE: scram-sha-256
ADMIN_USERS: bookstore
STATS_USERS: bookstore
ports:
- "6432:5432"
depends_on:
- bookstore-pg
volumes:
bookstore_data:
Bring it up:
docker compose up -d
docker compose ps
# Should show bookstore-pg and pgbouncer running
Connect to PgBouncer (port 6432):
psql -h localhost -p 6432 -U bookstore -d bookstore
# Password: bookstore
Internally PgBouncer opens the real connection to PostgreSQL (port 5432 within the Docker network). You never connect directly to PostgreSQL — always via PgBouncer.
Change your app's endpoint:
# Before
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:5432/bookstore"
# After, pointing at PgBouncer
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:6432/bookstore"
Your app now talks to PgBouncer. PgBouncer talks to PostgreSQL.
⚠️ Important: if you set POOL_MODE: transaction in the docker-compose, you need the statement_cache_size=0 fix in asyncpg (capsule 06) or your app will break with prepared statements.
Important docker-compose parameters
MAX_CLIENT_CONN: how many clients (apps) it can accept simultaneously. 200 means your 4 FastAPI instances with pool_size=20 each (= 80 total) fit with margin.
DEFAULT_POOL_SIZE: how many real connections it opens to PostgreSQL per database. If you only have bookstore, 25 means a maximum of 25 backend processes in PG.
RESERVE_POOL_SIZE: extra connections it can open under sustained pressure. Default 0; setting it to 5-10 gives margin for peaks.
POOL_MODE: the mode (session/transaction/statement). By default it's session — you have to change it explicitly if you want transaction.
AUTH_TYPE: the authentication protocol. scram-sha-256 is the modern one (PostgreSQL 14+).
Administrative commands: the PgBouncer console
PgBouncer exposes a virtual "database" called pgbouncer for administration. Connect as a user in ADMIN_USERS:
psql -h localhost -p 6432 -U bookstore pgbouncer
# Password: bookstore
Once inside, it's not a normal PostgreSQL — they're special commands.
SHOW POOLS
Shows the state of each pool (one per DB):
SHOW POOLS;
Example output:
database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait | maxwait_us | pool_mode
-----------+----------+-----------+------------+-----------+---------+---------+-----------+----------+---------+------------+-------------
bookstore | bookstore| 15 | 0 | 3 | 7 | 0 | 0 | 0 | 0 | 0 | transaction
pgbouncer | pgbouncer| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | statement
How to read it:
cl_active: clients with an active session connected to PgBouncer (at this moment, 15 client connections). This is what your app opened toward PgBouncer.cl_waiting: clients waiting for a real connection (because the pool is saturated). If this is > 0 sustainedly, raisedefault_pool_size.sv_active: real connections to PostgreSQL that are executing something at this moment (3 active queries).sv_idle: real connections to PG idle (7 ready to assign).sv_used: real connections that were used but haven't been reset (transition).sv_tested: in a health check process.sv_login: in an authentication process.maxwait: seconds the oldest client incl_waitinghas been waiting. If this grows, there's saturation.
Quick diagnosis:
cl_active=15, sv_active=3, sv_idle=7 → Healthy. The pool has margin (10 available).
cl_active=80, cl_waiting=20, sv_active=25, sv_idle=0 → SATURATED. Raise default_pool_size.
cl_active=15, sv_active=3, sv_idle=22 → Over-sized. Consider lowering default_pool_size.
SHOW STATS
Cumulative metrics since PgBouncer started:
SHOW STATS;
database | total_xact_count | total_query_count | total_received | total_sent | total_xact_time | total_query_time | total_wait_time | avg_xact_time | avg_query_time
-----------+------------------+-------------------+----------------+------------+-----------------+------------------+-----------------+---------------+-----------------
bookstore | 1234567 | 5678901 | 1234567890 | 9876543210 | 12345678 | 9876543 | 123 | 500 | 80
What matters:
total_query_count: total queries processed. Useful for throughput tracking.avg_query_time: average time per query (microseconds). If it grows, there are slow queries.total_wait_time/avg_wait_time: time clients spent waiting for a connection. If it grows, the pool is insufficient.
SHOW CLIENTS
Detailed list of connected clients:
SHOW CLIENTS;
Useful for identifying which specific client is saturating. Each row shows IP, current query, connected time, etc.
RELOAD
Reloads the configuration without restarting PgBouncer (without losing connections):
RELOAD;
Useful after editing pgbouncer.ini (or environment variables in the container).
PAUSE / RESUME
For maintenance:
PAUSE; -- stops accepting new queries, waits for in-progress ones to finish
-- you do your maintenance (PG restart, switchover, etc.)
RESUME; -- goes back to processing
Useful for zero-downtime PostgreSQL restarts.
Why this matters in real work
1. PgBouncer is the de facto standard. Supabase, RDS Proxy, Neon, Crunchy Bridge, Postgres.app — they all use PgBouncer (or compatible forks) internally. Knowing it is a prerequisite for working with any modern managed PostgreSQL.
2. The mode choice defines which features are available. "Enabling PgBouncer" isn't atomic — the mode decision changes how you write code. Knowing what breaks in transaction mode avoids "we added PgBouncer and the app blew up" in the next sprint.
3. SHOW POOLS is debugging table stakes in production. When there's an incident, opening psql to the PgBouncer console and running SHOW POOLS is one of the first three steps. If you don't understand the output, you can't diagnose.
4. Multiplexing changes your capacity calculation. Before PgBouncer: pool_size × instances < max_connections. After: pool_size × instances < MAX_CLIENT_CONN (much higher). PostgreSQL only sees default_pool_size. This lets you scale horizontally without worrying about the server's cap.
5. The decision matrix saves you incidents. "The notifications team added LISTEN/NOTIFY and now the app doesn't receive events." If you enabled transaction mode without knowing that LISTEN/NOTIFY breaks, that's your next postmortem.
Traps and common mistakes
Mistake 1 (conceptual): assuming that "all modes are interchangeable"
Symptom: "I enabled transaction mode and my app broke. I'll go back to session mode and that's it."
Why it happens: session mode doesn't leverage PgBouncer — it's like not having it. If you drop to session because transaction broke your LISTEN/NOTIFY, you lost PgBouncer's benefit but you're still paying the overhead of having it.
How to distinguish: if your pool_size (in SQLAlchemy) no longer saturates max_connections (PostgreSQL) thanks to PgBouncer, you're fine. If you're still at the limit with session mode, PgBouncer isn't helping.
How to fix it: identify which feature specifically broke you. For LISTEN/NOTIFY: use a separate direct connection (without PgBouncer) only for the listeners. For prepared statements: capsule 06. There's almost always a workaround.
Mistake 2 (operational): not monitoring cl_waiting and maxwait
Symptom: "PgBouncer was fine until one day the app started being very slow. With no info on what was happening."
Why it happens: without a periodic SHOW POOLS (or exported metrics), you don't know the pool is saturated until users report latency.
How to distinguish: run SHOW POOLS. If cl_waiting > 0 or maxwait > 1 second for minutes, there's saturation.
How to fix it: either raise default_pool_size, or lower the load (faster queries, refactor), or add more PostgreSQL capacity. Set alerts on cl_waiting > 5 to detect before it's a problem.
Mistake 3 (conceptual): mixing SQLAlchemy's pool_size with PgBouncer's default_pool_size
Symptom: "I raised default_pool_size to 100 but my app still doesn't scale."
Why it happens: default_pool_size is the limit of PgBouncer toward PostgreSQL. If your app has pool_size=20 in SQLAlchemy, it's still limited to 20 connections per instance toward PgBouncer, regardless of PgBouncer's limit toward PG.
How to distinguish: if you raise default_pool_size and SHOW POOLS doesn't show more sv_active, the bottleneck isn't there.
How to fix it: understand the two pools as independent layers:
pool_size(SQLAlchemy) = client layer.default_pool_size(PgBouncer) = multiplexed layer.
To scale throughput, raise both proportionally. Capsule 07 covers the detailed sizing.
Mistake 4 (operational): pointing your app at PostgreSQL directly "for a quick test" and forgetting to go back to PgBouncer
Symptom: "Staging was pointed at PostgreSQL directly. When they promoted to production, the 4 instances exhausted max_connections and everything broke."
Why it happens: a common trap in setups with several environments. You changed the endpoint in staging for debugging, an automatic deploy carried the config to production.
How to distinguish: review your DATABASE_URL endpoint in production. It should end at PgBouncer's port (typically 6432), not PG's (5432).
How to fix it: keep the URLs per environment explicit and differentiated. Consider a startup check that validates "I'm talking to PgBouncer, not PG directly".
Mistake 5 (conceptual): using BEGIN; SET LOCAL ...; <queries>; COMMIT; expecting SET to persist between transactions
Symptom: "I put SET LOCAL search_path = 'tenant_42' at the start of each request. In transaction mode, the SET doesn't persist between queries."
Why it happens: SET LOCAL only lasts the current transaction. In transaction mode, each BEGIN ... COMMIT can use a different real connection — what SET LOCAL did in the previous one doesn't apply to the next.
How to distinguish: if your app uses SET LOCAL outside the transaction where it applies the queries, this breaks in transaction mode.
How to fix it: two options:
- Switch to session mode (you lose multiplexing).
- Apply the SET LOCAL inside each transaction where you need it. Refactor to:
async with SessionLocal() as session:
async with session.begin(): # explicit transaction
await session.execute(text("SET LOCAL search_path TO :tenant"), {"tenant": "tenant_42"})
# your queries here, inside the same transaction
result = await session.scalar(...)
# COMMIT here; the SET LOCAL ended.
More verbose but compatible with transaction mode.
Exercises
Exercise 1: bring up PgBouncer with docker-compose and connect
Configure the capsule's docker-compose.yml. Bring up the services. Connect to PgBouncer with psql and verify that you can run queries.
See solution
1. Create the docker-compose.yml with the content of the capsule's minimal setup.
2. Bring it up:
docker compose up -d
docker compose ps
Expected output:
NAME STATUS PORTS
bookstore-pg Up 5 seconds 0.0.0.0:5432->5432/tcp
pgbouncer Up 4 seconds 0.0.0.0:6432->5432/tcp
3. Connect to PgBouncer (port 6432, not 5432):
psql -h localhost -p 6432 -U bookstore -d bookstore
Password: bookstore.
4. Verify that it works:
SELECT current_database(), current_user, version();
It should respond with PostgreSQL's info (because PgBouncer is transparent).
5. Verify from the PgBouncer admin side:
psql -h localhost -p 6432 -U bookstore pgbouncer
SHOW POOLS;
SHOW DATABASES;
SHOW DATABASES should show your bookstore DB configured.
6. Shut down:
docker compose down
Exercise 2: experiment with the three modes
Change PgBouncer's mode (POOL_MODE) between session, transaction, and statement. For each one, try:
a) A simple query (SELECT 1).
b) A transaction (BEGIN; SELECT 1; COMMIT;).
c) LISTEN test_channel; followed by a SELECT pg_sleep(2) from another session that does NOTIFY test_channel, 'hi';.
Observe what works in each mode.
See solution
Base setup: docker-compose with an adjustable POOL_MODE. Change the value, restart the PgBouncer container.
# Change POOL_MODE in docker-compose.yml to "session"
docker compose restart pgbouncer
# Connect and test:
psql -h localhost -p 6432 -U bookstore bookstore
Tests:
a) SELECT 1:
SELECT 1;
- Session: ✅ works.
- Transaction: ✅ works.
- Statement: ✅ works.
b) Simple transaction:
BEGIN;
SELECT 1;
COMMIT;
- Session: ✅ works.
- Transaction: ✅ works.
- Statement: ❌ fails. Statement mode doesn't allow multi-statement transactions:
ERROR: cannot insert multiple commands into a prepared statement
(Or similar cryptic behaviors.)
c) LISTEN/NOTIFY:
You need two sessions: one listener, one notifier.
Session 1 (listener):
LISTEN test_channel;
SELECT pg_sleep(60); -- keeps the session open
Session 2 (notifier):
NOTIFY test_channel, 'hi from notifier';
- Session: ✅ the listener receives
NOTIFICATION: test_channel, payload "hi from notifier". - Transaction: ❌ the listener does NOT receive anything. The underlying real connection changed between BEGIN/COMMIT, so the LISTEN stayed on a connection that's no longer assigned to the client.
- Statement: ❌ similar to the previous one, only more extreme.
Operational conclusion:
- Session: 100% compatible. You lose multiplexing.
- Transaction: gains multiplexing, breaks LISTEN/NOTIFY (among other things — capsule 06 covers prepared statements).
- Statement: breaks almost everything. A very specific case, rare to need.
Exercise 3: identify saturation with SHOW POOLS
Configure PgBouncer with default_pool_size=2 and max_client_conn=100. Connect 5 simultaneous clients that run SELECT pg_sleep(10). Observe SHOW POOLS during the experiment.
See solution
1. Change DEFAULT_POOL_SIZE: "2" in docker-compose and restart:
docker compose restart pgbouncer
2. Script to connect 5 simultaneous clients:
# saturate.sh
for i in {1..5}; do
PGPASSWORD=bookstore psql -h localhost -p 6432 -U bookstore -d bookstore \
-c "SELECT pg_sleep(10), $i AS client" &
done
wait
3. In another terminal, while it runs, monitor SHOW POOLS:
while true; do
echo "--- $(date +%H:%M:%S) ---"
PGPASSWORD=bookstore psql -h localhost -p 6432 -U bookstore pgbouncer \
-c "SHOW POOLS;" 2>/dev/null | head -5
sleep 2
done
4. Launch the saturation script:
chmod +x saturate.sh && ./saturate.sh &
Expected observation:
--- 14:30:00 ---
database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait | pool_mode
-----------+-----------+-----------+------------+-----------+---------+---------+-------------
bookstore | bookstore | 5 | 3 | 2 | 0 | 4 | transaction
Reading:
- 5 clients connected to PgBouncer (
cl_active=5). - 2 clients got a real connection and are running queries (
sv_active=2). - 3 clients are waiting (
cl_waiting=3). - The one waiting longest has been waiting 4 seconds (
maxwait=4).
After 10 seconds (the first two queries finish):
cl_active | cl_waiting | sv_active | sv_idle | maxwait
-----------+------------+-----------+---------+---------
3 | 1 | 2 | 0 | 8
The next two clients got a connection. The last one is still waiting.
Lesson: cl_waiting > 0 for minutes = insufficient pool. Raise default_pool_size or reduce slow queries.
Exercise 4: switch your app from PG directly to PgBouncer
Take your bookstore FastAPI app (from capsule 04) and change it to point at PgBouncer instead of PostgreSQL directly. Verify that it still works with POOL_MODE: session.
See solution
1. Change the endpoint in app/db.py:
# Before
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:5432/bookstore"
# After (port 6432 = PgBouncer)
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:6432/bookstore"
2. In docker-compose.yml, make sure POOL_MODE: session (100% compatible):
pgbouncer:
environment:
POOL_MODE: session # ← compatible with prepared statements
3. Restart PgBouncer:
docker compose restart pgbouncer
4. Bring up your app:
uvicorn app.main:app --reload
5. Make a request:
curl http://localhost:8000/books/1
6. Verify it went through PgBouncer:
PGPASSWORD=bookstore psql -h localhost -p 6432 -U bookstore pgbouncer \
-c "SHOW POOLS;"
It should show cl_active >= 1 for your bookstore database.
7. Verify application_name in PostgreSQL:
-- Connect to PG directly (port 5432) to verify
psql -h localhost -p 5432 -U bookstore bookstore
SELECT pid, application_name, client_addr, client_port
FROM pg_stat_activity
WHERE datname = 'bookstore';
Expected output:
pid | application_name | client_addr | client_port
-------+-------------------+-------------+-------------
12345 | bookstore-api | 172.18.0.1 | 56234 ← your app via PgBouncer
PgBouncer rewrites application_name and the connection "comes from PgBouncer" not from your app directly.
If everything works, you just migrated your app from "talks to PG directly" to "talks to PgBouncer". Without code changes (only config). In capsule 06 you're going to change to transaction mode.
Exercise 5: practical decision matrix
For each case, decide which PgBouncer mode you'd recommend and why:
a) An e-commerce REST API with SQLAlchemy 2.0 async, high throughput.
b) A realtime notification system that uses LISTEN/NOTIFY as an internal bus.
c) A legacy Java application that keeps 50 long-lived connections all day.
d) A batch processing worker that runs individual queries without transactions.
e) A multitenant API that uses SET LOCAL search_path at the start of each request.
See solution
a) E-commerce REST API, SQLAlchemy async, high throughput:
→ Transaction mode. It's the canonical case. Apply the statement_cache_size=0 fix for asyncpg (capsule 06). Leverages multiplexing to the maximum.
b) Realtime notification system with LISTEN/NOTIFY:
→ Session mode for the listeners. Transaction mode for the rest of the app if you have traditional REST endpoints separately.
Recommended setup: two PgBouncer pools on different ports:
- Port 6432: transaction mode for REST.
- Port 6433: session mode for listeners.
Or: listeners connected directly to PostgreSQL (without PgBouncer) on a long-lived connection, and the rest via PgBouncer transaction.
c) Legacy Java application with 50 long-lived connections:
→ Session mode. No multiplexing but the app wouldn't leverage transaction mode (the connections aren't released between transactions, they're long-lived). Session mode is transparent. If they refactor the app later, evaluate migrating to transaction.
d) Batch worker with individual queries without transactions:
→ Transaction mode or statement mode (rare).
If the worker really doesn't use explicit BEGIN/COMMIT, statement mode could give marginally more efficiency. In practice, transaction mode is enough and less risky (each query is an "implicit transaction" for PostgreSQL).
e) Multitenant API with SET LOCAL search_path:
→ Transaction mode with care.
SET LOCAL must be applied inside each transaction where it applies:
async with session.begin(): # explicit transaction
await session.execute(text("SET LOCAL search_path TO :tenant"), {"tenant": tenant_id})
# your queries
# COMMIT
If the app puts SET search_path (without LOCAL) expecting it to persist between queries, transaction mode breaks it. A mandatory refactor or use session mode.
General conclusion: transaction mode is the reasonable default. The exceptions (LISTEN/NOTIFY, SET without LOCAL) are a refactor or parallel use of session mode for specific cases.
Summary and next step
In this capsule you:
- Built the mental model of PgBouncer as a "concierge" between app and PostgreSQL.
- Learned the three modes (session/transaction/statement), what each one does and what features break.
- Learned about the decision matrix with concrete criteria.
- Brought up PgBouncer locally with docker-compose.
- Practiced administrative commands:
SHOW POOLS,SHOW STATS,SHOW CLIENTS,RELOAD. - Migrated your app from PostgreSQL directly to PgBouncer (in session mode, an intermediate step).
Before moving on, you should be able to:
- Explain the difference between the three modes in less than 2 minutes.
- Know what features break in transaction mode (LISTEN/NOTIFY, prepared statements, SET without LOCAL, etc.).
- Diagnose saturation with
SHOW POOLS(which columns to look at). - Bring up PgBouncer in docker-compose and connect via port 6432.
Next capsule — PgBouncer + asyncpg gotchas. You already have PgBouncer running (in session mode, where everything works). In the next one we go to the real recommended mode (transaction) and face the bug that always appears when doing it: asyncpg's prepared statements break randomly. You're going to see the exact error, understand why it happens, apply the statement_cache_size=0 fix, and review other minor gotchas that appear when introducing transaction mode. It's the capsule that saves you the subtlest production incident of the whole module.
Resources
- PgBouncer official documentation — the canonical reference.
- PgBouncer pooling modes — the specific section on the 3 modes.
- Supabase — Connection pooling with Supavisor — a real case with PgBouncer-like.
- AWS RDS Proxy — a managed version that uses the same paradigm.
- edoburu/pgbouncer Docker image — the image we use in docker-compose.
- Crunchy Data — PgBouncer best practices — operation at scale.
- PgBouncer admin console reference — all the
SHOWcommands.
Module 6 — Database Performance & Query Tuning Guide