Module 6: Advanced Connection Pooling
Pool sizing: formulas, async, and continuous monitoring
Capsule overview
You have the complete setup (capsules 02-06): SQLAlchemy 2.0 + asyncpg + PgBouncer transaction mode + statement_cache_size=0. It works. But the underlying operational question remains: how many connections to put in each pool?
pool_size=10, pool_size=50, pool_size=200 — they're all defensible numbers depending on context. This capsule teaches you to choose with criteria instead of copying.
You're going to learn:
- The classic HikariCP formula:
pool_size = ((core_count × 2) + effective_spindle_count). Its origin, what it assumes, and why it doesn't apply directly to async. - Why async changes the rules: connections in async don't compete for the client's CPU, they wait on I/O. The sync formula underestimates the optimal pool.
- Empirical methodology: how to measure your optimal pool under real load with
wrk+ monitoring. pg_stat_databaseandpg_stat_activity: PostgreSQL's views for continuous observability.- Periodic
SHOW POOLS: turning it into exportable metrics (Prometheus, Datadog).
By the end you'll be able to size pools with well-founded numbers, and you'll have observability to adjust when the load changes.
Mental model: waiters in a fast-service vs slow-service restaurant
Remember the hotel/concierge model. Now refine it with a detail: how many waiters (connections) are the right ones?
In a fast-service restaurant (short queries, async):
- Each table takes little time (queries of 5-50ms).
- The waiter serves many tables in parallel (a waiter is waiting for a customer to decide → they can serve another table in the meantime).
- More waiters = more tables served in parallel, up to a point.
In a slow-service restaurant (heavy queries, sync):
- Each table takes a long time (queries of 500ms+).
- The waiter is busy for a long while at each table (they can't parallelize easily because each customer action needs their attention).
- More waiters helps up to the point where the kitchen (the server's CPU) is the bottleneck.
Conclusion: the optimal number of waiters depends on how long each table takes and on the kitchen's capacity. For async, "each table takes little time" pushes the optimum up. For sync, "the waiter is blocked" pushes the optimum down.
This is exactly why the sync formula underestimates the optimal pool in async.
The classic HikariCP formula
HikariCP is the most-used connection pool in the Java world. Its author, Brett Wooldridge, based his sizing recommendation on empirical research with sync databases (PostgreSQL, MySQL, Oracle):
pool_size = ((core_count × 2) + effective_spindle_count)
Where:
core_count= CPU cores of the DB server (not the client).effective_spindle_count= number of physical disks (on SSD: 1 effective, on HDD: the real number, on arrays: it depends).
Example:
- PG server with 4 cores and SSD: pool_size = (4 × 2) + 1 = 9.
- PG server with 16 cores and SSD: pool_size = (16 × 2) + 1 = 33.
- PG server with 8 cores and an array of 8 HDDs: pool_size = (8 × 2) + 8 = 24.
Where the formula comes from
Wooldridge experimented with apps of typical enterprise load (Oracle Forms, batch processing) and found that:
- A pool too small → throughput limited by the number of concurrent queries.
- A pool too large → throughput decreases due to context switching, lock contention, and a saturated disk.
- There's an optimum around 2× cores (covers parallelism + a bit of margin).
Reasoning: most of a typical query's time is in CPU + disk I/O. The server processes those in parallel up to its physical limits. More connections in the pool don't add processing capacity — they add competition for the same resources.
Why the formula is correct for sync
In sync code:
# Sync: the client thread is blocked during the entire query
def get_user(id):
with engine.connect() as conn:
return conn.execute("SELECT * FROM users WHERE id = ?", id).fetchone()
# ← thread blocked here 50ms
The client thread is literally blocked during the round-trip. More connections allow more parallel threads, but each thread still spends CPU serializing the request, in context switching, deserializing the response.
If your sync app has 10 connections and all are running queries, you're probably saturating the PG server's 4-8 cores. More connections = more waiting, not more throughput.
Why the formula does NOT apply directly to async
In async code:
# Async: the "thread" doesn't block, it releases the event loop
async def get_user(id: int):
async with engine.connect() as conn:
return await conn.execute(text("SELECT * FROM users WHERE id = :id"), {"id": id}).fetchone()
# ← await → releases the event loop, another task can run
The client's event loop serves many connections simultaneously while they all wait on I/O. The client's CPU doesn't saturate.
What changes:
- The client can handle many more simultaneous connections without saturating.
- The PostgreSQL server is still the bottleneck, but now with short queries (<100ms), it processes much more throughput per core.
- The classic formula underestimates the optimum because it assumes client CPU saturation, which doesn't happen in async.
Empirical heuristic for async:
pool_size_async ≈ 4-10 × core_count of the PG server
Server with 8 cores → pool between 32 and 80 (vs ~17 the classic formula would give).
But: this is a heuristic, not an exact formula. Capsule 07 teaches you to measure empirically.
Empirical methodology: finding your optimum under real load
The universal rule: measure, don't assume. Steps:
Step 1: define the measurement workload
What load are you going to simulate? It must be representative of production:
# wrk with a typical endpoint
wrk -t4 -c100 -d60s http://localhost:8000/books/1
-t4: 4 threads in wrk.-c100: 100 concurrent connections from wrk.-d60s: 60 seconds of duration.
Adjust c to get close to the concurrency you expect in production.
Step 2: measure with different pool_size
You keep everything the same except pool_size. For each value, you measure:
- Throughput (req/s).
- Latency p50, p95, p99.
- Errors (especially
TimeoutError). - CPU on the DB server (
top,htop). - Real connections in use (
SHOW POOLSif you have PgBouncer, otherwisepg_stat_activity).
Step 3: tabulate and compare
Example of real measurements you might obtain:
pool_size | Throughput (req/s) | p50 (ms) | p95 (ms) | p99 (ms) | Errors | Server CPU (%) |
|---|---|---|---|---|---|---|
| 5 | 850 | 18 | 75 | 180 | 23 (timeout) | 35% |
| 10 | 1450 | 14 | 45 | 95 | 0 | 55% |
| 20 | 1820 | 12 | 38 | 78 | 0 | 75% |
| 30 | 1950 | 11 | 35 | 70 | 0 | 85% |
| 50 | 1920 | 13 | 42 | 88 | 0 | 92% |
| 80 | 1880 | 18 | 65 | 130 | 0 | 95% (saturated) |
Reading:
pool_size=5: insufficient, timeout errors, limited throughput.pool_size=10-20: improving fast, no errors.pool_size=30: optimal. Maximum throughput, best p95/p99 latency.pool_size>30: flat or decreasing throughput, latency worsens (CPU saturated on the server).
Step 4: choose with margin
The measured optimum is 30. Don't set exactly 30 — set 25-30 to have margin for peaks without going past the "sweet spot".
Practical rule: choose 80-90% of the value where you see the throughput peak. Above it, performance worsens. Below it, you leave capacity unused.
Sizing with PgBouncer in the middle
When you have PgBouncer, there are two pools you size independently:
Client pool (SQLAlchemy → PgBouncer)
Each FastAPI instance has its pool_size toward PgBouncer.
# Per FastAPI instance
engine = create_async_engine(
"postgresql+asyncpg://...@pgbouncer:6432/bookstore",
pool_size=30, # toward PgBouncer
max_overflow=0, # PgBouncer absorbs peaks
)
30= what you measured as optimal per instance.- No overflow because PgBouncer already acts as a buffer.
PgBouncer pool (PgBouncer → PostgreSQL)
# docker-compose.yml
pgbouncer:
environment:
DEFAULT_POOL_SIZE: "25" # toward PostgreSQL
MAX_CLIENT_CONN: "300" # accepts from the apps
How to choose:
default_pool_size= the measured optimum for PostgreSQL (e.g.: 25-30).max_client_conn=client_pool_size × num_instances × margin. E.g.: 30 × 4 instances × 2 = 240 → set 300.
Example of a complete calculation:
Setup:
- 4 FastAPI instances
- PG server with 8 cores, SSD
- Typical async workload
Calculation:
- Optimum measured in PG (with benchmark): a pool of 25-30 real connections.
- pool_size in SQLAlchemy (per instance): you can set 30 (PgBouncer multiplexes).
- Total potential client → PgBouncer: 4 × 30 = 120.
- PgBouncer → PG: default_pool_size = 25 (multiplexes the 120 over 25).
- PgBouncer max_client_conn: 4 × 30 × 1.5 (margin) = 180.
Configuration:
- SQLAlchemy: pool_size=30
- PgBouncer: DEFAULT_POOL_SIZE=25, MAX_CLIENT_CONN=180
Validate with SHOW POOLS
After applying, verify:
SHOW POOLS;
What you want to see under normal load:
cl_active=80, cl_waiting=0, sv_active=20, sv_idle=5
- 80 clients connected (of the 120 potential) → healthy.
- 0 waiting → you don't saturate.
- 20 active + 5 idle = 25 real connections (at the cap).
If you see:
cl_active=120, cl_waiting=15, sv_active=25, sv_idle=0
Saturated. Either raise default_pool_size (more real connections to PG, if PG can handle it) or lower the pressure on the client.
Continuous monitoring: the critical views
Configuring it well once isn't enough. Load changes, queries change, instances are added. You need continuous monitoring.
pg_stat_database
A view of aggregate stats per database in PostgreSQL:
SELECT
datname,
numbackends, -- current connections
xact_commit, -- committed transactions (cumulative)
xact_rollback, -- rolled-back transactions
blks_read, -- blocks read from disk
blks_hit, -- blocks from cache
tup_returned, -- tuples returned
tup_fetched, -- tuples fetched
deadlocks, -- detected deadlocks
temp_files, -- temp files created
temp_bytes, -- bytes in temp files
stats_reset -- last reset
FROM pg_stat_database
WHERE datname NOT IN ('template0', 'template1', 'postgres');
Key metrics for pooling:
numbackends: current number of connections to PostgreSQL. If it reachesmax_connections - 10, alarm.xact_commit + xact_rollbackper unit of time = transaction throughput.deadlocksgrowing: there are problematic locks (not strictly the pool, but a health indicator).
pg_stat_activity (you already know it from capsule 02)
Per connection, in real time:
SELECT
application_name,
state,
count(*) AS connections
FROM pg_stat_activity
WHERE datname = 'bookstore'
GROUP BY application_name, state
ORDER BY count DESC;
Example output:
application_name | state | connections
--------------------+---------------------+-------------
bookstore-pgbouncer| idle | 18
bookstore-pgbouncer| active | 4
bookstore-pgbouncer| idle in transaction | 1
Alarms:
idle in transaction> 0 sustained: leak.activeconstantly high: slow queries saturating.- Total > 80% of
max_connections: close to the cap.
Periodic SHOW POOLS (PgBouncer)
To metricize PgBouncer continuously, you can run SHOW POOLS every minute and export to Prometheus/Datadog:
# pgbouncer-metrics.sh — runs every minute via cron
#!/bin/bash
PGPASSWORD=bookstore psql -h pgbouncer -p 6432 -U bookstore pgbouncer -t -A -F, \
-c "SHOW POOLS;" | \
awk -F, -v ts=$(date +%s) '
NR > 0 {
printf "pgbouncer_cl_active{db=\"%s\"} %d %d\n", $1, $3, ts
printf "pgbouncer_cl_waiting{db=\"%s\"} %d %d\n", $1, $4, ts
printf "pgbouncer_sv_active{db=\"%s\"} %d %d\n", $1, $5, ts
printf "pgbouncer_sv_idle{db=\"%s\"} %d %d\n", $1, $6, ts
printf "pgbouncer_maxwait_us{db=\"%s\"} %d %d\n", $1, $11, ts
}
' >> /var/log/pgbouncer_metrics.prom
Reasonable alerts:
# prometheus-alerts.yaml
- alert: PgBouncerSaturating
expr: pgbouncer_cl_waiting > 5
for: 2m
annotations:
summary: "PgBouncer pool saturating: {{ $value }} clients waiting"
- alert: PgBouncerMaxWaitHigh
expr: pgbouncer_maxwait_us > 1000000 # 1 second
for: 1m
annotations:
summary: "PgBouncer maxwait > 1s, clients are queueing"
Ready-made exporters
To avoid writing scripts by hand:
prometheus-pgbouncer-exporter(Python): exposes PgBouncer metrics in Prometheus format.postgres_exporter(official Prometheus community): for PostgreSQL.pganalyze: a commercial SaaS that combines both + automatic analysis.
Continuous adjustment workflow
1. Initial setup with well-founded values (this capsule).
2. Active monitoring (SHOW POOLS, pg_stat_*).
3. When alerts fire or metrics degrade:
a. Identify whether it's client pool or PgBouncer saturation.
b. If client: raise pool_size in SQLAlchemy (or add instances).
c. If PgBouncer: raise default_pool_size (if PG can handle it) or add an extra PgBouncer.
4. Re-run the benchmark periodically (monthly, or after major changes).
5. Document the current values and when they changed.
Living documentation suggested in the repo:
# bookstore-api/docs/POOL_SIZING.md
## Current configuration (May 2026)
- FastAPI instances: 4 (k8s deployment)
- SQLAlchemy pool_size: 30
- PgBouncer DEFAULT_POOL_SIZE: 25
- PgBouncer MAX_CLIENT_CONN: 180
- PostgreSQL max_connections: 100
## Last empirical measurement
- Date: 2026-04-15
- Workload: 250 RPS sustained
- p95 latency: 38ms
- pool utilization: 60%
## Historical changes
- 2026-04-15: raise pool_size from 20 to 30 after a cl_waiting>5 alert.
- 2026-02-10: introduce PgBouncer with DEFAULT_POOL_SIZE=20.
- 2026-01-05: initial setup with pool_size=10 (insufficient, see post-mortem).
Why this matters in real work
1. Pool sizing is one of the most common senior questions. "How would you size your API's pool in production?" If you answer "pool_size=10 because I saw it in a blog", you come across as a junior. If you answer "I'd calculate with the HikariCP formula as a starting point, adjust with an empirical benchmark, monitor with SHOW POOLS continuously", you sound senior.
2. Distinguishing sync vs async sets you apart. Most developers copy the HikariCP formula without questioning. Knowing that async changes the rules (and why) is a senior level.
3. pg_stat_database and pg_stat_activity are table stakes in any serious debugging. A DBA or senior backend can read these views without googling. Any senior role assumes you know them.
4. PgBouncer metrics are what separates "I configured PgBouncer" from "I operate PgBouncer". Without alerts on cl_waiting, you don't know your pool is saturating until users report latency. With alerts, you prevent incidents.
5. Documenting pool sizing is a practice of mature teams. Any capacity change should be traceable. "We raised the pool from 20 to 30 on day X because Y" is info that needs to be in some markdown in the repo.
Traps and common mistakes
Mistake 1 (conceptual): applying the HikariCP formula literally in async
Symptom: "My PG server has 4 cores. HikariCP says pool=9. I set pool=9 in my async FastAPI. Throughput is low."
Why it happens: the formula assumes sync. In async, the client can handle many more connections without saturating. The real optimum may be 30-50.
How to distinguish: monitor the client's CPU (the app). If it's at 20% and there's still a queue in the pool, you can raise pool_size.
How to fix it: measure empirically. Start at 2 × pool_HikariCP (~18 in this case) and adjust based on the benchmark.
Mistake 2 (operational): not measuring, copying numbers from a post
Symptom: you copied pool_size=20 from a post and never verified whether it's optimal. In peaks you saturate. At low load, you waste resources.
Why it happens: "already configured, keeps working". Until it doesn't.
How to distinguish: when was the last time you measured with a benchmark? If the answer is "never", this is it.
How to fix it: run a benchmark quarterly or after significant changes (10x more traffic, a DB hardware change, a query refactor).
Mistake 3 (operational): not monitoring cl_waiting or maxwait
Symptom: the app started having high latency. Without a pool metric, you don't know whether it's a pool problem or a query problem.
Why it happens: you monitor HTTP latency, DB latency, throughput — but not SHOW POOLS.
How to distinguish: during the incident, can you say "the pool is/isn't saturated"? If not, you're missing this view.
How to fix it: integrate PgBouncer metrics into your monitoring stack. At minimum: a cl_waiting graph in Grafana/Datadog.
Mistake 4 (conceptual): raising default_pool_size without checking PG's max_connections
Symptom: "PgBouncer was telling me the pool was saturated. I raised default_pool_size from 25 to 200. Now PostgreSQL gives me 'too many connections'."
Why it happens: you forgot the absolute cap. PgBouncer can have default_pool_size=200 but if PostgreSQL max_connections=100, the next 100 fail.
How to distinguish: check max_connections in PG (SHOW max_connections). Your default_pool_size × num_dbs_pgbouncer + pg_reserved < max_connections.
How to fix it: raise max_connections in PostgreSQL (mindful of the RAM cost, capsule 02) before raising default_pool_size. Or add a second PgBouncer with another config.
Mistake 5 (conceptual): assuming more cores on the client = more pool
Symptom: "My app server has 16 cores. I set pool=32." But the DB server has 4 cores.
Why it happens: confusion between the client's cores and the DB server's cores. The HikariCP formula uses the DB server's, not the client's.
How to distinguish: "Which server are the cores I'm counting from?" If it's the FastAPI client's, it's wrong.
How to fix it: use the PostgreSQL server's cores as the base. The app server isn't a real constraint (async handles many connections per core).
Exercises
Exercise 1: apply the HikariCP formula to your current setup
For your bookstore setup (PG server with N cores, SSD), calculate pool_size_HikariCP and pool_size_async_initial (4× cores).
See solution
Example setup: PG server in docker, host with 4 cores, SSD.
HikariCP calculation (sync):
pool_size = (4 × 2) + 1 = 9
Initial async heuristic:
pool_size = 4 × 4 = 16
Practical recommendation to start:
pool_size = 16 # async, a reasonable starting point
If your app has a lot of expected throughput (>500 RPS), consider:
pool_size = 30 # more margin for heavy async
Validate with a benchmark (next exercise) before going to production.
Exercise 2: empirical benchmark of your optimal pool
Configure your FastAPI + asyncpg + PgBouncer app. For pool_size in [5, 10, 20, 30, 50], measure throughput and p95 with wrk. Identify the optimum.
See solution
1. Make sure PgBouncer is running (transaction mode, default_pool_size=50 so it isn't the bottleneck).
2. Benchmark script:
#!/bin/bash
# bench_pool_size.sh
RESULTS_FILE="pool_benchmark_results.csv"
echo "pool_size,throughput,p50_ms,p95_ms,p99_ms,errors" > $RESULTS_FILE
for SIZE in 5 10 20 30 50; do
echo "=== Testing pool_size=$SIZE ==="
# Edit app/db.py with the value (assumes you have an env variable)
POOL_SIZE=$SIZE uvicorn app.main:app --port 8000 > /tmp/app.log 2>&1 &
APP_PID=$!
sleep 5 # warmup
# Run wrk
OUTPUT=$(wrk -t4 -c100 -d60s --latency http://localhost:8000/books/1)
echo "$OUTPUT" > /tmp/wrk_$SIZE.txt
# Parse (simple format, adjust based on wrk's exact output)
THROUGHPUT=$(echo "$OUTPUT" | grep "Requests/sec" | awk '{print $2}')
P50=$(echo "$OUTPUT" | grep "50.000%" | awk '{print $2}')
P95=$(echo "$OUTPUT" | grep "95.000%" | awk '{print $2}')
P99=$(echo "$OUTPUT" | grep "99.000%" | awk '{print $2}')
ERRORS=$(echo "$OUTPUT" | grep "Non-2xx" | awk '{print $4}' || echo "0")
echo "$SIZE,$THROUGHPUT,$P50,$P95,$P99,$ERRORS" >> $RESULTS_FILE
# Kill the app
kill $APP_PID
sleep 2
done
echo ""
echo "=== Results ==="
column -t -s, $RESULTS_FILE
Example output:
pool_size throughput p50_ms p95_ms p99_ms errors
5 850 18 75 180 23
10 1450 14 45 95 0
20 1820 12 38 78 0
30 1950 11 35 70 0
50 1920 13 42 88 0
Analysis:
pool_size=5: insufficient. Limited throughput, timeout errors.pool_size=10: acceptable, no errors.pool_size=30: optimal. Best throughput and latency.pool_size=50: flat throughput, latency started to worsen (PG CPU saturating).
Final recommendation:
pool_size = 25 # 80% of the measured optimum (30), with margin
Validate under real load for 1 week before canonizing.
Exercise 3: active monitoring with SHOW POOLS
Create a bash script that logs SHOW POOLS every 5 seconds during a benchmark. Identify the exact moment when cl_waiting starts to grow.
See solution
1. Monitoring script:
#!/bin/bash
# monitor_pools.sh
LOG_FILE="pool_monitoring.log"
echo "timestamp,cl_active,cl_waiting,sv_active,sv_idle,maxwait_us" > $LOG_FILE
while true; do
TS=$(date +%s)
LINE=$(PGPASSWORD=bookstore psql -h localhost -p 6432 -U bookstore pgbouncer -t -A -F, -c "
SELECT cl_active, cl_waiting, sv_active, sv_idle, maxwait_us
FROM pgbouncer.pools
WHERE database = 'bookstore';
" 2>/dev/null)
echo "$TS,$LINE" >> $LOG_FILE
sleep 5
done
2. In another terminal, launch progressive load:
# Increasing load: 50, 100, 200, 400 simultaneous connections
for C in 50 100 200 400; do
echo "=== c=$C ==="
wrk -t4 -c$C -d30s http://localhost:8000/books/1
done
3. Stop the monitor (Ctrl+C) and analyze pool_monitoring.log:
# See where cl_waiting started to grow:
awk -F, '$3 > 0 {print}' pool_monitoring.log
Example output:
1714742345,75,3,25,0,500000
1714742350,80,8,25,0,1200000
1714742355,82,10,25,0,2100000
1714742360,80,12,25,0,3000000
Reading:
- Starting around
c=200,cl_waitingstarted to grow. maxwait_usgrew rapidly: clients waiting up to 3 seconds.sv_active=25(at thedefault_pool_sizecap) = saturated.
Diagnosis: PgBouncer is at its cap. Options:
- Raise
default_pool_size(if PG can handle it). - Optimize queries to release connections faster.
- Add more PostgreSQL instances (read replicas, sharding).
Exercise 4: identify a leak with pg_stat_activity
Your FastAPI app shows intermittent high latency. You connect to pg_stat_activity and see many connections in idle in transaction. Diagnose the problem and propose fixes.
See solution
1. See the overview:
SELECT
application_name,
state,
count(*) AS conns,
max(EXTRACT(EPOCH FROM (NOW() - state_change))) AS oldest_seconds
FROM pg_stat_activity
WHERE datname = 'bookstore'
GROUP BY application_name, state
ORDER BY conns DESC;
Example output:
application_name | state | conns | oldest_seconds
-------------------+---------------------+-------+----------------
bookstore-api | idle in transaction | 18 | 420
bookstore-api | idle | 5 | 8
bookstore-api | active | 2 | 0.3
Diagnosis: 18 connections in idle in transaction, the oldest from 7 minutes ago. That's a clear leak.
2. Identify the leaked queries:
SELECT pid, query, state_change,
EXTRACT(EPOCH FROM (NOW() - state_change)) AS idle_seconds
FROM pg_stat_activity
WHERE datname = 'bookstore'
AND state = 'idle in transaction'
ORDER BY state_change;
Output:
pid | query | state_change | idle_seconds
------+---------------------------------------------+------------------------+--------------
4123 | UPDATE orders SET status = 'paid' WHERE ... | 2026-05-02 10:23:00 | 420
4124 | INSERT INTO orders_log (order_id, ...) VAL | 2026-05-02 10:24:00 | 360
...
Pattern: all the leaks involve orders tables. There's an endpoint that touches orders and forgets to commit/rollback.
3. Temporary fix (while you look for the bug in the code):
-- Configure a timeout so idle in transaction connections close on their own:
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();
-- Kill the current leaks:
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'bookstore'
AND state = 'idle in transaction'
AND state_change < NOW() - INTERVAL '60 seconds';
4. Permanent fix: audit the code of the endpoint that touches orders. Typical pattern:
# Antipattern: forgotten commit
@router.post("/orders/{id}/pay")
async def pay_order(id: int, db: AsyncSession = Depends(get_db)):
order = await db.get(Order, id)
order.status = "paid"
# ← MISSING: await db.commit()
return order # ← the session closes without commit, stays idle in transaction
Fix:
@router.post("/orders/{id}/pay")
async def pay_order(id: int, db: AsyncSession = Depends(get_db)):
order = await db.get(Order, id)
order.status = "paid"
await db.commit() # ← FIX
return order
Or better with async with db.begin():
@router.post("/orders/{id}/pay")
async def pay_order(id: int, db: AsyncSession = Depends(get_db)):
async with db.begin():
order = await db.get(Order, id)
order.status = "paid"
# automatic COMMIT on exiting the with
return order
5. Systemic prevention: a linter or test that detects sessions without an explicit commit in the code.
Exercise 5: Prometheus alert for a saturating PgBouncer
Design a Prometheus alert that fires when PgBouncer has more than 5 clients waiting for 2 minutes.
See solution
1. Assume you have prometheus-pgbouncer-exporter running and exporting metrics like:
pgbouncer_pools_client_waiting_count{database="bookstore"}
pgbouncer_pools_client_active_count{database="bookstore"}
pgbouncer_pools_server_active_count{database="bookstore"}
2. Alert rule:
# prometheus/alerts.yaml
groups:
- name: pgbouncer
rules:
- alert: PgBouncerPoolSaturating
expr: pgbouncer_pools_client_waiting_count > 5
for: 2m
labels:
severity: warning
team: backend
annotations:
summary: "PgBouncer pool saturating: {{ $value }} clients waiting"
description: |
PgBouncer has {{ $value }} clients waiting for a connection on the
{{ $labels.database }} database for more than 2 minutes.
Possible causes:
- Throughput exceeds the current pool's capacity.
- Slow queries holding connections.
- Connection leak (idle in transaction).
Immediate actions:
1. Check SHOW POOLS to confirm.
2. Check pg_stat_activity for active queries.
3. Consider raising DEFAULT_POOL_SIZE or reducing the pressure.
- alert: PgBouncerHighWait
expr: pgbouncer_pools_max_wait_seconds > 1
for: 1m
labels:
severity: critical
annotations:
summary: "PgBouncer maxwait > 1s ({{ $value }}s)"
description: |
Some client has been waiting > 1 second for a connection in PgBouncer.
User latency is being affected.
3. Validate:
# Syntax
promtool check rules prometheus/alerts.yaml
# Querying at runtime
curl -s 'http://prometheus:9090/api/v1/query?query=pgbouncer_pools_client_waiting_count' | jq
4. Connect to Alertmanager so it fires via Slack/PagerDuty.
Result: when the pool starts to saturate, you receive an alert in 2 minutes. You have time to react before users report.
Summary and next step
In this capsule you:
- Internalized the classic HikariCP formula and why it doesn't apply directly to async.
- Learned the async heuristic (4-10× cores) and the empirical methodology to validate it.
- Measured your optimal pool under real load with
wrk+ monitoring. - Learned about PostgreSQL's critical views:
pg_stat_database,pg_stat_activity. - Configured continuous monitoring with
SHOW POOLSand alerts oncl_waitingandmaxwait. - Documented pool values with justification (a practice of mature teams).
Before moving on, you should be able to:
- Calculate a starting point for
pool_sizewith the formula and the async heuristic. - Design a benchmark to empirically validate your optimal pool.
- Read
SHOW POOLSand diagnose saturation immediately. - Configure Prometheus/equivalent alerts for PgBouncer in production.
Next capsule — the module project: tuning the bookstore's pool. You already have all the knowledge: pool fundamentals, SQLAlchemy tuning, asyncpg specifics, PgBouncer fundamentals, gotchas, and sizing. Capsule 08 asks you to apply it all in a consolidating project: docker-compose with FastAPI + PgBouncer + PostgreSQL, a reproducible problem (an untuned pool that saturates at 50 RPS), the application of the techniques, and a before/after benchmark measuring the quantified improvement. It's the capsule that validates that you learned, not just read.
Resources
- HikariCP — About Pool Sizing — the classic formula with justification.
- PostgreSQL 16 —
pg_stat_database— the official reference for the view. - PostgreSQL 16 —
pg_stat_activity— the official reference. - PgBouncer —
SHOW POOLSdocumentation — the semantics of each column. - prometheus-pgbouncer-exporter — a Prometheus exporter for PgBouncer metrics.
postgres_exporter(Prometheus) — the official community exporter for PostgreSQL.- Brandur Leach — "Postgres connection pooling" — the underlying architectural view.
- pganalyze — Connection scaling guide — an operational strategy at scale.
Module 6 — Database Performance & Query Tuning Guide