Unit 1.2 · Databases in Production: PostgreSQL
Connections, Pooling, and Query Performance
Why 'too many connections' happens, PgBouncer and RDS Proxy, reading EXPLAIN ANALYZE, indexes that help (and hurt), pg_stat_statements, and lock contention.
Start here
The mental model
Each database connection is an expensive, dedicated assistant. Twenty app pods × a pool of 20 connections each is 400 assistants, most of them idle, all using memory. A POOLER is a receptionist: thousands of clients talk to it, and it hands each query to one of a small team of real connections only while the query runs.
For performance, the database is almost never 'just slow'. It is doing too much work for a specific query, usually reading millions of rows to return ten. EXPLAIN shows you the work; an index usually removes it.
Go deeper
How it works inside
01Connection math and poolers
Total connections = pods × pool size per pod (+ migrations, cron jobs, and humans). Autoscaling makes it worse: when the HPA scales from 10 to 40 pods during a traffic spike, connections quadruple exactly when the database is busiest (SRE course, cascading failure).
PGBOUNCER in TRANSACTION mode assigns a server connection per transaction and returns it afterwards, so 2,000 client connections can share 50 real ones. The catch: session state (prepared statements in older drivers, SET commands, advisory locks, LISTEN) doesn't survive between transactions. RDS PROXY is the managed equivalent, and it also speeds up failover. Rule of thumb for the real pool: a few connections per CPU core of the database is usually the throughput sweet spot; more just adds contention.
02Reading a query plan
EXPLAIN ANALYZE runs the query and shows each step with estimated vs actual rows and time. Read it from the innermost, most-indented node outward. The red flags: Seq Scan on a big table with a selective filter (Rows Removed by Filter: 4999990), a large gap between estimated and actual rows (stale statistics, so run ANALYZE), Sort Method: external merge Disk (work_mem too small), and nested loops over large inputs.
03Indexes
A B-tree index turns 'scan 5 million rows' into 'walk a tree of ~4 levels'. Index columns you FILTER, JOIN, or ORDER BY on, with the most selective and equality columns first in composite indexes ((customer_id, created_at) serves WHERE customer_id = ? ORDER BY created_at DESC). Partial indexes (WHERE status = 'pending') and covering indexes (INCLUDE) help specific hot queries. Other types: GIN for JSONB and full-text, BRIN for huge append-only time-series tables.
Indexes aren't free: each one slows every write and uses disk and cache. Unused indexes show idx_scan = 0 in pg_stat_user_indexes. On a live table, always CREATE INDEX CONCURRENTLY, because a plain CREATE INDEX blocks writes for the whole build.
04pg_stat_statements and locks
The pg_stat_statements extension aggregates every normalised query with its call count, total and mean time, and rows. Sorting by total_exec_time finds what the database spends its life on, which is often a fast query called a million times rather than one slow query.
LOCKS: most schema changes take an ACCESS EXCLUSIVE lock. An ALTER TABLE waiting behind a long SELECT then blocks every new query behind itself, and a 2-second change becomes a full outage. Always set lock_timeout (e.g. SET lock_timeout = '3s') before DDL in production, and retry.
Do it
Hands-on lab
- 1
Create a table big enough to be slow
Five million orders across 100,000 customers.
terminal$ psql postgresql://postgres:pw@localhost/shop <<'SQL'create table orders(id bigserial primary key, customer_id int, status text, total numeric, created_at timestamptz);insert into orders(customer_id,status,total,created_at)select (random()*100000)::int, (array['paid','pending','shipped'])[1+(random()*2)::int], random()*200, now() - random()*interval '365 days'from generate_series(1,5000000);analyze orders;SQL── expected output ──INSERT 0 5000000ANALYZE - 2
Read the plan for a slow query
The page 'my recent orders' runs this. Note
Parallel Seq Scanand the rows removed by the filter.terminal$ psql postgresql://postgres:pw@localhost/shop -c "explain analyze select * from orders where customer_id = 4242 order by created_at desc limit 10"── expected output ──Limit (cost=... rows=10) (actual time=212.4..214.9 rows=10 loops=1)-> Gather Merge (actual time=212.4..214.8 rows=10 loops=1)-> Sort (actual time=196.1..196.1 rows=6 loops=3)Sort Key: created_at DESC-> Parallel Seq Scan on orders (actual time=0.9..195.8 rows=17 loops=3)Filter: (customer_id = 4242)Rows Removed by Filter: 1666650Execution Time: 215.1 ms - 3
Add the right index, concurrently
A composite index matching both the filter and the sort makes the query read exactly 10 index entries: about 1,000× faster.
terminal$ psql postgresql://postgres:pw@localhost/shop -c 'create index concurrently orders_customer_created_idx on orders (customer_id, created_at desc)'psql postgresql://postgres:pw@localhost/shop -c "explain analyze select * from orders where customer_id = 4242 order by created_at desc limit 10"── expected output ──Limit (actual time=0.041..0.066 rows=10 loops=1)-> Index Scan using orders_customer_created_idx on orders (actual time=0.040..0.063 rows=10 loops=1)Index Cond: (customer_id = 4242)Execution Time: 0.089 ms - 4
Find the top queries with pg_stat_statements
Enable the extension (it needs
shared_preload_libraries, already enabled on RDS), then rank queries by total time.terminal$ docker exec pg bash -c "echo \"shared_preload_libraries='pg_stat_statements'\" >> /var/lib/postgresql/data/postgresql.conf" && docker restart pgpsql postgresql://postgres:pw@localhost/shop -c 'create extension pg_stat_statements'# ...run your app or pgbench for a while, then:psql postgresql://postgres:pw@localhost/shop -c "select calls, round(total_exec_time) total_ms, round(mean_exec_time,2) mean_ms, left(query,50) from pg_stat_statements order by total_exec_time desc limit 3"── expected output ──calls | total_ms | mean_ms | left--------+----------+---------+----------------------------------------------------912044 | 184022 | 0.20 | select * from carts where session_id = $11204 | 96110 | 79.83 | select * from orders where status = $1 order by cr48 | 30211 | 629.40 | select count(*) from orders where created_at > $1 - 5
Put PgBouncer in front
A minimal PgBouncer config in transaction mode. Apps connect to port 6432 instead of 5432.
SHOW POOLSon the admin console shows clients waiting (cl_waiting), which is the metric to alert on.pgbouncer.iniwhole fileini [databases] shop = host=pg port=5432 dbname=shop [pgbouncer] listen_addr = 0.0.0.0 listen_port = 6432 auth_type = scram-sha-256 auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction max_client_conn = 2000 default_pool_size = 40 server_idle_timeout = 60
Operate it
Knobs that matter
| Setting | Default | What it does | When to change it |
|---|---|---|---|
| pool_mode (PgBouncer) | session | When a server connection is returned to the pool. | transaction for web apps; session only if you rely on session features. |
| default_pool_size (PgBouncer) | 20 | Server connections per user/database pair. | Start around 2–4 × DB vCPUs and adjust using cl_waiting and DB CPU. |
| lock_timeout | 0 (off) | Max time to wait for a lock. | Set to a few seconds in migration sessions so DDL fails fast instead of queueing everyone. |
| random_page_cost | 4 | Planner's cost for random I/O. | Lower to ~1.1 on SSD/EBS so the planner uses indexes appropriately. |
| autovacuum_vacuum_scale_factor | 0.2 | Fraction of a table that must change before autovacuum. | Lower (0.01–0.05) per table on huge, hot tables so vacuum runs before bloat builds. |
3am practice
Failure drills
Each drill is a real failure mode. Read the scenario and the output, decide what's wrong, then reveal the diagnosis.
Drill #1
FATAL: too many connections during a traffic spike
Black Friday: the HPA scales the API from 12 to 48 pods. Minutes later, new pods crash-loop.
Drill #2
A two-second migration takes the site down
A deploy runs ALTER TABLE orders ADD COLUMN note text while an analyst's 10-minute report is running.
The bigger picture
Connects to
System Design · Indexes
B-Trees, composite and covering indexes, selectivity — how queries actually get fast.
System Design · Thread Pools
ExecutorService, ThreadPoolExecutor, its queue, worker threads and rejection policy — the operational half of concurrency.
SRE · Cascading failure
Connection storms are a classic way one overloaded tier topples the rest.
Kubernetes · HPA & VPA
Why autoscaling the app can overload the database.
Observability · Where is the time?
Proving the database is the slow hop before tuning it.
Prove it
Interview questions
The database is slow. How do you investigate?
Why use PgBouncer, and what breaks in transaction mode?