Postgres Advanced Bootcamp
A self-paced online course for engineers who already write good SQL and now need to know what the server does with it: how tuples live on disk, how the planner chooses, why a migration took a lock, and how to keep a cluster up under load. Work through it in order, on your own schedule, and pick up where you left off.
1 · Advanced Bootcamp Scope & Prerequisites
Who this is for
Senior application engineers who own a Postgres-backed service, DevOps and SRE engineers who run Postgres fleets, and DBA-adjacent developers who are the de facto database person on their team. Everyone has shipped production SQL. Nobody needs to be told what a JOIN is.
Target outcomes
When you finish the course, you will be able to do the following on a live system without notes:
Read an EXPLAIN (ANALYZE, BUFFERS) plan, find the node whose row estimate is wrong, and explain the statistic or query shape that caused it.
Change a 500M-row table with no outage, choosing the right lock level, using lock_timeout, NOT VALID and CONCURRENTLY, and expand/contract.
Read pg_locks, break a lock queue, size a PgBouncer pool, and remove a deadlock by changing lock ordering.
Measure bloat, tune autovacuum per table, prevent XID wraparound, and explain each byte of an 8 KB heap page.
Pick between B-tree, GIN, GiST, BRIN, SP-GiST and HNSW for a workload and justify it with measured write and read costs.
Build streaming and logical replication, reason about RPO and RTO, and decide when partitioning or sharding is worth the cost.
Core assumptions
The course skips these entirely. You should already be fluent in:
- Relational modeling and normal forms (1NF to BCNF), and when to denormalize on purpose.
- All join types (inner, outer, semi and anti via
EXISTS/NOT EXISTS),GROUP BY,HAVING, subqueries and non-recursive CTEs. - Basic B-tree indexing: what an index is, why column order matters, and why
WHERE lower(email) = ...skips a plain index onemail. - Transactions and the four ANSI isolation levels at a conceptual level.
- Comfort on a Linux shell,
psql, Docker Compose and Git.
Pre-work self-check
Answer these before you start. If you cannot answer at least four of the six, do the pre-reading first (PostgreSQL docs chapters 13 "Concurrency Control" and 14 "Performance Tips").
- What does
EXPLAINprint thatEXPLAIN ANALYZEadds to, and what side effect doesANALYZEhave on aDELETE? - Why might Postgres choose a sequential scan even though a matching index exists?
- What is the difference between
READ COMMITTEDandREPEATABLE READin Postgres specifically? - Write a query using
ROW_NUMBER()to return the latest order per customer. - What happens to existing rows when you run
ALTER TABLE t ADD COLUMN c int DEFAULT 0on PG11 or later? - Name two things
VACUUMdoes besides reclaiming dead space.
Your learning path
There are no dates and no deadlines. Take the steps in order, because each module builds on the ones before it, and do each lab right after its module while the material is fresh. Full time, that is roughly a week. At one step per week alongside a job, it is about six weeks.
| Step | Study | Practice | Typical effort |
|---|---|---|---|
| 1 | Module 1 · Storage internals, MVCC, WAL | Lab A · Bloat factory and autovacuum tuning | ~4 h + 3 h |
| 2 | Module 2 · Analytical SQL, RLS, custom aggregates | Lab B · Multi-tenant RLS behind PgBouncer | ~3 h + 2.5 h |
| 3 | Module 3 · Indexing strategy, GIN/GiST/BRIN/pgvector | Lab C · Index tournament on 50M rows | ~3 h + 3 h |
| 4 | Module 4 · Planner mechanics and plan reading | Lab D · Plan surgery on ten broken queries | ~3 h + 3 h |
| 5 | Module 5 · Locking, pooling, migrations, replication, partitioning | Labs E and F | ~4 h + 5 h |
| 6 | Capstone · Black Friday at Ledgerline | Solo incident simulation and postmortem | ~3 to 4 h |
How to study with this page
- Read, then run. Every SQL block is meant to be run in your own sandbox (set up in section 3). Reading without running misses most of the value.
- Check yourself. Each module ends with a quiz that marks your answers and explains them. Aim for at least 4 of 5 before moving on.
- Track your progress. Mark each module and lab complete when you finish it. Progress and quiz answers are saved in this browser, and the sidebar shows where to continue.
- Keep evidence. Every lab lists the evidence to collect. Keep it in a notes file or repository; it is how you check your own work, and it makes a useful portfolio.
2 · Module-by-Module Advanced Breakdown
Each module lists its learning objectives, the core content, demo SQL to run in your sandbox, the pitfalls people most often get wrong, and a quiz. Version-specific behavior is tagged.
Architecture Deep Dive & Storage Internals
MVCC, the write-ahead log, page layout, TOAST, bloat and the autovacuum machinery. Everything later in the course rests on this.
Learning objectives
- Trace a single
UPDATEfrom client to disk: shared buffers, WAL record, heap tuple versions, index entries, checkpoint. - Read raw tuple headers with
pageinspectand explainxmin,xmax,ctidand infomask hint bits. - Predict when an update is HOT and when it creates new index entries.
- Compute when autovacuum will trigger on a given table and change that per table.
- Explain transaction ID wraparound, freezing, and what to do when
age(datfrozenxid)climbs.
1.1 Process and memory architecture
Postgres is a multi-process server. The postmaster forks one backend per client connection. Background processes include the checkpointer, background writer, WAL writer, autovacuum launcher and its workers, the stats subsystem (shared memory since PG15), the archiver, WAL senders and WAL receivers. PG18 adds asynchronous I/O, controlled by io_method (worker by default, io_uring on supported Linux builds, or sync), which mostly helps sequential scans, bitmap heap scans and vacuum.
Shared memory holds shared_buffers (the buffer pool of 8 KB pages), WAL buffers, the lock table, and the proc array used to build snapshots. Per-backend memory includes work_mem (per sort or hash node, per worker, not per query) and maintenance_work_mem for VACUUM, CREATE INDEX and friends.
GetSnapshotData. Thousands of idle connections hurt even when idle. This is why Module 5 covers pooling.1.2 MVCC: tuple versions and snapshots
Postgres never updates a row in place. An UPDATE writes a new tuple version and stamps the old one's xmax with the updating transaction's ID. A DELETE only sets xmax. Readers decide visibility from their snapshot: the xmin horizon, the xmax horizon, and the list of transactions in progress when the snapshot was taken.
- Tuple header (23 bytes, padded to 24):
t_xmin,t_xmax,t_cid,t_ctid(pointer to the newer version, or to itself),t_infomaskandt_infomask2(hint bits such asHEAP_XMIN_COMMITTED, the HOT flags, the attribute count),t_hoff, then the null bitmap. - Snapshots: under
READ COMMITTEDeach statement takes a new snapshot. UnderREPEATABLE READandSERIALIZABLE, one snapshot is used for the whole transaction.SERIALIZABLEadds SSI predicate locks (visible asSIReadLockinpg_locks) and can abort with SQLSTATE40001. - Hint bits: the first reader after commit sets hint bits on the tuple, dirtying the page. This is why a plain
SELECTright after a bulk load can generate writes, and WAL whenwal_log_hintsor data checksums are on. PG18initdbenables data checksums by default. - Commit log: transaction status lives in
pg_xact(CLOG); subtransactions (eachSAVEPOINT, and each PL/pgSQLEXCEPTIONblock entry) live inpg_subtrans. More than 64 subtransactions in one transaction overflows the per-backend cache and can slow every snapshot on the server.
CREATE EXTENSION pageinspect;
CREATE TABLE mvcc_demo (id int PRIMARY KEY, v text);
INSERT INTO mvcc_demo VALUES (1, 'a');
UPDATE mvcc_demo SET v = 'b' WHERE id = 1;
SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid,
(t_infomask2 & 16384) > 0 AS hot_updated,
(t_infomask2 & 32768) > 0 AS heap_only
FROM heap_page_items(get_raw_page('mvcc_demo', 0));
-- lp 1: old version, t_xmax = updating xid, t_ctid = (0,2), hot_updated = true
-- lp 2: new version, t_xmin = updating xid, heap_only = true
1.3 Page layout
Every heap and B-tree file is a sequence of 8 KB pages (the BLCKSZ compile-time default). A heap page has:
| Region | Size | Contents |
|---|---|---|
| Page header | 24 bytes | pd_lsn (LSN of last change), checksum, flags, pd_lower, pd_upper, pd_special, pd_prune_xid |
| Line pointers (ItemId) | 4 bytes each | Grow forward from the header. Offset, length and state (LP_NORMAL, LP_REDIRECT, LP_DEAD, LP_UNUSED). |
| Free space | variable | Between pd_lower and pd_upper |
| Tuples | variable | Grow backward from the end of the page |
| Special space | 0 for heap | Used by index access methods (for example, B-tree sibling links) |
Each relation also has a free space map (_fsm fork) and a visibility map (_vm fork, 2 bits per page: all-visible and all-frozen). The visibility map is what makes index-only scans skip the heap and lets vacuum skip pages.
Column order matters for space because of alignment padding. A table of (bool, bigint, bool, bigint) wastes 14 bytes per row compared to (bigint, bigint, bool, bool).
1.4 HOT updates and fillfactor
A Heap-Only Tuple update happens when no indexed column changes and the new version fits on the same page. No new index entries are created, and the old version can be pruned during normal page access without vacuum. HOT is the single most effective defence against index bloat on update-heavy tables.
- Lower
fillfactor(for example 70 to 90) on hot update tables to leave room on each page. - Watch
n_tup_hot_upd / n_tup_updinpg_stat_user_tables.n_tup_newpage_upd(PG16+) counts updates that had to move to another page. - Adding an index on a frequently updated column (such as
updated_at) silently disables HOT for those updates. BRIN indexes are the exception: since PG16, updates that only touch BRIN-indexed columns can still be HOT.
1.5 TOAST
A heap tuple must fit on a page, so values larger than about 2 KB are compressed and, if still too large, moved out of line into the table's TOAST relation in chunks of about 2 KB. The heap keeps an 18-byte pointer.
- Trigger: when a row exceeds
TOAST_TUPLE_THRESHOLD(about 2 KB), Postgres compresses or moves its widest columns until the row is undertoast_tuple_target(default about 2 KB, settable per table). - Strategies per column:
PLAIN(never),MAIN(compress, move out only as a last resort),EXTERNAL(move out, no compression, so fast substring access),EXTENDED(default: compress then move). - Compression:
pglzorlz4(default_toast_compression, or per column withALTER TABLE ... SET COMPRESSION lz4). LZ4 is much faster for both writes and reads. - Updating any column of a row rewrites the heap tuple, but an unchanged TOASTed value is not copied. Updating a big
jsonbdocument by one key rewrites the whole TOASTed value, which is a common source of WAL volume and TOAST bloat.
SELECT c.relname, t.relname AS toast_table,
pg_size_pretty(pg_relation_size(c.oid)) AS heap,
pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast
FROM pg_class c JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE c.relname = 'documents';
1.6 Write-ahead logging
Every change is written to WAL before the data page is written. A commit is durable once its WAL is flushed (with synchronous_commit = on). Data pages are written lazily by the background writer, backends, and checkpoints.
- LSN: a 64-bit byte position in the WAL stream. Every page header stores the LSN of its last change.
pg_current_wal_lsn()andpg_wal_lsn_diff()measure WAL rate and replica lag. - Segments: 16 MB files in
pg_wal/by default (initdb --wal-segsizeto change). - Checkpoints: triggered by
checkpoint_timeout(default 5 min) ormax_wal_size(default 1 GB). Spread overcheckpoint_completion_target(default 0.9). Watchpg_stat_checkpointer(PG17+, previously inpg_stat_bgwriter): many requested checkpoints meansmax_wal_sizeis too small. - Full-page writes: the first change to a page after a checkpoint logs the whole 8 KB page to protect against torn writes. Frequent checkpoints therefore inflate WAL volume.
wal_compression(lz4orzstd) shrinks these images. wal_level:minimal,replica(default, needed for physical replication and PITR),logical(adds data for logical decoding).synchronous_commit:off,local,remote_write,on,remote_apply. Settingoffrisks losing the last few hundred milliseconds of commits on a crash but never corrupts data.- PG17 WAL summarization (
summarize_wal) enables incremental backups withpg_basebackup --incrementalandpg_combinebackup.pg_walinspect(PG15+) lets you inspect WAL records in SQL.
1.7 Bloat and vacuum
Dead tuples stay on the page until vacuum (or HOT pruning) removes them. Vacuum can only remove a tuple that is dead to every snapshot, so the oldest of these holds back cleanup for the whole cluster:
- A long-running transaction, or one left
idle in transaction. - An abandoned replication slot (
pg_replication_slots.xminorcatalog_xmin). - A standby with
hot_standby_feedback = onrunning a long query. - A forgotten prepared transaction (
pg_prepared_xacts).
What plain VACUUM does: prunes dead tuples, removes their index entries, marks line pointers reusable, updates the free space map and visibility map, freezes old tuples, and truncates empty pages at the end of the table (which needs a brief ACCESS EXCLUSIVE lock; disable with vacuum_truncate). It does not return space to the OS from the middle of a table. VACUUM FULL and CLUSTER rewrite the table under ACCESS EXCLUSIVE. pg_repack and pg_squeeze rewrite online.
PG17 vacuum stores dead TIDs in a radix-tree TidStore, removing the old 1 GB memory cap and usually finishing index cleanup in a single pass.
Measuring bloat
-- Estimate (fast, uses stats)
SELECT relname, n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum, autovacuum_count
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20;
-- Exact (scans the table)
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('orders'); -- dead_tuple_percent, free_percent
SELECT * FROM pgstatindex('orders_pkey'); -- avg_leaf_density, leaf_fragmentation
1.8 Autovacuum architecture
The autovacuum launcher wakes every autovacuum_naptime (1 min) per database and starts up to autovacuum_max_workers workers (default 3). PG18 autovacuum_worker_slots (default 16) reserves slots so autovacuum_max_workers can be raised with a reload instead of a restart.
A table is vacuumed when:
n_dead_tup > autovacuum_vacuum_threshold (50)
+ autovacuum_vacuum_scale_factor (0.2) × reltuples
-- PG18: capped at autovacuum_vacuum_max_threshold (100,000,000)
n_ins_since_vacuum > autovacuum_vacuum_insert_threshold (1000)
+ autovacuum_vacuum_insert_scale_factor (0.2) × reltuples -- PG13+
-- and analyzed when changes > 50 + 0.1 × reltuples
With defaults, a 1-billion-row table waits for 200 million dead rows before vacuum starts. That is why large tables need per-table settings:
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.0,
autovacuum_vacuum_threshold = 100000,
autovacuum_vacuum_cost_limit = 2000, -- default inherits vacuum_cost_limit = 200
autovacuum_vacuum_cost_delay = 1 -- ms; default 2
);
Throttling: each worker accumulates cost (page hit, miss, dirty) and sleeps autovacuum_vacuum_cost_delay after reaching the cost limit. The limit is shared across all running workers, so adding workers without raising the limit does not make vacuum faster. Monitor progress in pg_stat_progress_vacuum.
1.9 Freezing and XID wraparound
Transaction IDs are 32-bit and compared modulo 232, so any tuple older than about 2 billion transactions would appear to be in the future. Freezing marks old tuples as visible to everyone.
| Setting | Default | Meaning |
|---|---|---|
vacuum_freeze_min_age | 50M | Tuples older than this get frozen when vacuum visits their page |
vacuum_freeze_table_age | 150M | Above this table age, vacuum scans all non-frozen pages (aggressive vacuum) |
autovacuum_freeze_max_age | 200M | Forces an anti-wraparound autovacuum even if autovacuum is off for the table |
vacuum_failsafe_age | 1.6B | Vacuum drops throttling and skips index cleanup to finish freezing fast (PG14+) |
At about 3 million XIDs before wraparound, the server stops assigning new XIDs and refuses writes. Recovery then needs a manual VACUUM in the affected database. MultiXact IDs (used for shared row locks) have a parallel set of settings and can wrap independently. PG18 adds eager freezing of all-visible pages during normal vacuums (vacuum_max_eager_freeze_failure_rate), which spreads freezing work out and makes the eventual aggressive vacuum cheaper.
SELECT datname, age(datfrozenxid) AS xid_age,
mxid_age(datminmxid) AS mxid_age
FROM pg_database ORDER BY xid_age DESC;
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class WHERE relkind IN ('r','m','t')
ORDER BY 2 DESC LIMIT 10;
VACUUM FULL to fix wraparound is the wrong tool. It takes an ACCESS EXCLUSIVE lock and rewrites everything. A plain VACUUM (FREEZE, VERBOSE) is what you need, after you have found and removed whatever is holding back the xmin horizon.Module 1 quiz
-
A table has an index only on
id. You runUPDATE t SET status = 'paid' WHERE id = 42and the page has free space. What happens?Show answer and explanation
B. No indexed column changed and the page has room, so this is a HOT update. The old version stays until pruning removes it. Postgres never updates rows in place, and the change is always WAL-logged.
-
A 2-billion-row table uses default autovacuum settings on PG17. Roughly how many dead tuples accumulate before autovacuum vacuums it?
Show answer and explanation
C. 50 + 0.2 × 2,000,000,000 ≈ 400 million. On PG18 the new
autovacuum_vacuum_max_thresholdcaps this at 100 million by default, which is why D would be correct on PG18. -
n_dead_tupkeeps rising on a busy table even though autovacuum runs every few minutes and finishes. What is the most likely cause?Show answer and explanation
D. Vacuum can only remove tuples dead to every snapshot. Check
pg_stat_activity.backend_xmin,pg_replication_slotsandpg_prepared_xacts. VACUUM VERBOSE reports the "removable cutoff" and how many dead tuples it could not remove. -
Why does checkpointing too often increase WAL volume?
Show answer and explanation
A. With
full_page_writes = on, every page touched for the first time after a checkpoint is logged in full to guard against torn pages. More checkpoints mean more first touches. -
Which TOAST storage strategy gives the fastest
substring()on a largetextcolumn?Show answer and explanation
C.
EXTERNALstores out of line without compression, so Postgres fetches only the chunks covering the requested range. Compressed values must be decompressed from the start. -
What does the visibility map's all-visible bit enable?
Show answer and explanation
B. If a page is all-visible, an index-only scan can trust the index without checking tuple visibility on the heap, and a non-aggressive vacuum can skip it. "Heap Fetches" in EXPLAIN shows how often this failed.
Complex Execution & Analytical SQL
Window frames, recursive queries, LATERAL, row-level security and custom aggregates, taught with an eye on how each one executes.
Learning objectives
- Choose between
ROWS,RANGEandGROUPSframes and predict their results with ties. - Write recursive CTEs that terminate safely, with
SEARCHandCYCLEclauses. - Use
LATERALfor top-N-per-group and explain the nested loop it produces. - Implement multi-tenant isolation with RLS that survives connection pooling and does not wreck plans.
- Build a parallel-safe custom aggregate with a combine function.
2.1 Window functions in depth
Window functions run after WHERE, GROUP BY and HAVING, and before ORDER BY and LIMIT. Each distinct window definition generally needs its own sort, so define windows with a shared WINDOW clause and matching PARTITION BY/ORDER BY to let the executor reuse one sort.
- Frame modes:
ROWScounts physical rows.RANGEuses value offsets on the singleORDER BYcolumn and treats peers (ties) as one unit.GROUPScounts peer groups. - Default frame with
ORDER BYisRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. With ties, a "running total" includes all peers of the current row, which surprises people. - Exclusion:
EXCLUDE CURRENT ROW | GROUP | TIES | NO OTHERS. - Useful functions:
lag/leadwith defaults,first_value/last_value/nth_value(mind the frame),ntile,percent_rank,cume_dist, and ordered-set aggregates likepercentile_cont(0.95) WITHIN GROUP (ORDER BY ...). - Performance: since PG15, the planner can stop early for
row_number(),rank(),dense_rank()andcount(*)when the outer query filters with a monotonic condition such asrn <= 3(Run Condition in EXPLAIN).
-- 7-day rolling revenue by calendar date, gaps in data handled correctly
SELECT day, revenue,
sum(revenue) OVER w7 AS rolling_7d,
revenue - lag(revenue) OVER (ORDER BY day) AS dod_change
FROM daily_revenue
WINDOW w7 AS (ORDER BY day RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW);
-- Sessionization: new session after 30 min of inactivity
SELECT user_id, ts,
sum(new_session) OVER (PARTITION BY user_id ORDER BY ts) AS session_no
FROM (
SELECT user_id, ts,
(ts - lag(ts) OVER (PARTITION BY user_id ORDER BY ts) > INTERVAL '30 min'
OR lag(ts) OVER (PARTITION BY user_id ORDER BY ts) IS NULL)::int AS new_session
FROM events
) s;
2.2 Recursive CTEs
A recursive CTE has a non-recursive term, UNION or UNION ALL, and a recursive term that references the CTE once. Execution is iterative: a working table feeds each round until a round produces no rows.
UNIONremoves duplicates at each step and can stop cycles in simple graphs, but it costs a hash on every iteration.- PG14+ adds
SEARCH DEPTH FIRST BY/BREADTH FIRST BYto produce an ordering column, andCYCLE ... SET is_cycle USING pathto detect cycles without hand-written path arrays. - Always add a depth guard for user-controlled data. A runaway recursion fills
temp_file_limitor memory. - Since PG12, non-recursive CTEs are inlined unless referenced more than once or marked
MATERIALIZED. Recursive CTEs are always materialized.
WITH RECURSIVE org AS (
SELECT id, manager_id, name, 1 AS depth
FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.manager_id, e.name, o.depth + 1
FROM employees e JOIN org o ON e.manager_id = o.id
WHERE o.depth < 20
)
SEARCH DEPTH FIRST BY name SET ordercol
CYCLE id SET is_cycle USING path
SELECT repeat(' ', depth - 1) || name AS tree FROM org ORDER BY ordercol;
2.3 LATERAL joins
LATERAL lets a subquery or set-returning function in FROM reference columns of earlier items. It is a correlated subquery that can return many rows and many columns. The planner usually executes it as a nested loop, so it is ideal when the outer side is small and the inner side has a supporting index.
-- Top 3 most recent orders per customer, using index (customer_id, created_at DESC)
SELECT c.id, c.name, o.id AS order_id, o.created_at, o.total
FROM customers c
CROSS JOIN LATERAL (
SELECT id, created_at, total
FROM orders
WHERE orders.customer_id = c.id
ORDER BY created_at DESC
LIMIT 3
) o
WHERE c.segment = 'enterprise';
Compare this against the window version (row_number() ... WHERE rn <= 3). The window version reads every order and sorts. The LATERAL version does three index probes per customer. Crossover depends on the ratio of customers to orders, and you measure it in Lab D.
2.4 Row-level security
RLS adds policy predicates to every query on a table. Policies are PERMISSIVE (OR-ed together, the default) or RESTRICTIVE (AND-ed). USING filters rows that can be seen or targeted; WITH CHECK validates new rows from INSERT and UPDATE.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- In the app, per transaction (safe with PgBouncer transaction pooling):
BEGIN;
SELECT set_config('app.tenant_id', '0b6c...', true); -- true = local to this transaction
SELECT * FROM invoices WHERE status = 'open';
COMMIT;
- Superusers and roles with
BYPASSRLSskip policies. Table owners skip them unlessFORCEis set. - Use
SET LOCALorset_config(..., true), never plainSET, when a pooler may hand the connection to another tenant. - Performance: the policy predicate must be indexable. Lead composite indexes with
tenant_id. Functions called in policies should beSTABLEand, if they appear inside user-supplied predicates,LEAKPROOF, otherwise the planner cannot push user quals below the security barrier and may lose index usage. - Views run with the view owner's permissions and bypass the caller's RLS unless created
WITH (security_invoker = true)(PG15+). - Foreign key checks and unique constraints are not filtered by RLS, so they can leak the existence of other tenants' rows. Use tenant-scoped composite keys.
2.5 Custom aggregate functions
An aggregate is a state transition function (SFUNC) over a state type (STYPE), with an optional final function. Adding COMBINEFUNC and PARALLEL = SAFE lets the planner use partial aggregation in parallel workers and in partition-wise aggregation. Adding MSFUNC/MINVFUNC enables efficient moving-window evaluation.
-- Weighted average: sum(value*weight) / sum(weight)
CREATE FUNCTION wavg_sfunc(state numeric[], val numeric, w numeric)
RETURNS numeric[] LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
$$ SELECT ARRAY[state[1] + val * w, state[2] + w] $$;
CREATE FUNCTION wavg_combine(a numeric[], b numeric[])
RETURNS numeric[] LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
$$ SELECT ARRAY[a[1] + b[1], a[2] + b[2]] $$;
CREATE FUNCTION wavg_final(state numeric[])
RETURNS numeric LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
$$ SELECT CASE WHEN state[2] = 0 THEN NULL ELSE state[1] / state[2] END $$;
CREATE AGGREGATE wavg(numeric, numeric) (
SFUNC = wavg_sfunc, STYPE = numeric[], INITCOND = '{0,0}',
COMBINEFUNC = wavg_combine, FINALFUNC = wavg_final, PARALLEL = SAFE
);
SELECT product_id, wavg(price, qty) FROM order_items GROUP BY product_id;
For hot paths, write the transition function in C or use an existing extension. SQL-language transition functions are called once per row and are noticeably slower than built-ins.
2.6 Other modern SQL to cover briefly
MERGE(PG15), withRETURNINGandmerge_action()(PG17), andWHEN NOT MATCHED BY SOURCE(PG17).JSON_TABLE,JSON_EXISTS,JSON_QUERY,JSON_VALUE(PG17).- PG18
OLDandNEWinRETURNINGforINSERT/UPDATE/DELETE/MERGE; virtual generated columns (now the default kind);uuidv7(); temporalPRIMARY KEY/UNIQUE ... WITHOUT OVERLAPSandPERIODforeign keys. GROUPING SETS,ROLLUP,CUBE,FILTER (WHERE ...)on aggregates, andDISTINCT ON.
Module 2 quiz
-
Ordered values are 10, 20, 20, 30. What does
sum(v) OVER (ORDER BY v)return for the first row with value 20?Show answer and explanation
B. The default frame is
RANGE ... CURRENT ROW, which includes all peers. Both 20s are in the frame: 10 + 20 + 20 = 50. WithROWSit would be 30. -
Your app uses PgBouncer in transaction mode and RLS keyed on
current_setting('app.tenant_id'). Which way of setting the tenant is safe?Show answer and explanation
D. In transaction pooling, the server connection goes to another client after
COMMIT. Session-level settings would leak to that client. Transaction-local settings reset at commit. -
What is required for a custom aggregate to be computed with parallel partial aggregation?
Show answer and explanation
A. Each worker produces a partial state, and the leader merges them with the combine function. Moving-aggregate functions are for window frames, not parallelism.
-
When is
CROSS JOIN LATERAL (... ORDER BY ... LIMIT 3)usually faster thanrow_number()filtered torn <= 3?Show answer and explanation
C. LATERAL becomes a nested loop of cheap index probes that stop after 3 rows. The window approach scans and sorts all inner rows, which wins only when most rows are needed anyway.
-
A table owner queries their own RLS-enabled table and sees every tenant's rows. Why?
Show answer and explanation
B. Owners, superusers and
BYPASSRLSroles skip policies.FORCEcovers the owner. Applications should connect as a non-owner role anyway.
Elite Indexing & Specialized Strategies
Every index speeds some reads and slows every write. This module is about choosing the index whose trade fits the workload, and proving it with numbers.
Learning objectives
- Design multi-column B-tree indexes from the query's equality, range and sort columns.
- Use partial, expression and covering indexes to shrink index size and enable index-only scans.
- Match workloads to GIN, GiST, SP-GiST, BRIN and pgvector's HNSW and IVFFlat, and tune each.
- Find unused, duplicate and bloated indexes and drop or rebuild them without downtime.
3.1 B-tree design rules
- Column order: equality columns first, then the range or sort column. An index on
(tenant_id, status, created_at)servesWHERE tenant_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 50with no sort. - PG18 Skip scan: a B-tree can now be used when the leading column has no condition, if it has few distinct values. Leading-column order still matters a lot for high-cardinality columns.
- Deduplication (PG13+) stores duplicate keys once with a posting list, shrinking low-cardinality indexes considerably.
- Bottom-up deletion (PG14+) removes version-churn entries before a page split, slowing index bloat from non-HOT updates.
- Sort direction only matters for mixed-direction sorts:
ORDER BY a ASC, b DESCneeds an index declared(a, b DESC). UNIQUE NULLS NOT DISTINCT(PG15+) treats NULLs as equal for uniqueness.
3.2 Partial, expression and covering indexes
-- Partial: index only the 2% of rows the hot query touches
CREATE INDEX CONCURRENTLY orders_pending_idx
ON orders (created_at) WHERE status = 'pending';
-- Expression: the query must use the identical expression
CREATE INDEX CONCURRENTLY users_email_lower_idx ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower($1);
-- Covering: INCLUDE payload columns to enable an index-only scan
CREATE INDEX CONCURRENTLY orders_cust_cover_idx
ON orders (customer_id, created_at DESC) INCLUDE (total, status);
-- Partial unique: one active subscription per account
CREATE UNIQUE INDEX one_active_sub
ON subscriptions (account_id) WHERE ended_at IS NULL;
- A partial index is only used when the planner can prove the query's
WHEREimplies the index predicate. With a parameterizedstatus = $1, a generic plan cannot prove it. - Expression indexes get their own statistics after
ANALYZE, which can fix bad estimates on computed predicates even if the index is rarely scanned. INCLUDEcolumns are stored only in leaf pages and are not part of the key, so they cannot be used for searching or ordering.- Index-only scans still visit the heap for pages that are not all-visible. Vacuum frequency directly affects index-only scan speed.
3.3 Specialized access methods
| Method | Best for | Operators / opclasses | Trade-offs |
|---|---|---|---|
| GIN | Values containing many elements: jsonb, arrays, full-text tsvector, trigram search | jsonb_ops (?, ?|, ?&, @>, jsonpath), jsonb_path_ops (@> and jsonpath only, smaller and faster), gin_trgm_ops for LIKE '%x%' | Slow to update; uses a pending list (fastupdate, gin_pending_list_limit) that makes some inserts and reads spiky. PG18 parallel GIN builds. |
| GiST | Overlap and nearest-neighbor: ranges, geometry (PostGIS), exclusion constraints, KNN ORDER BY <-> | &&, @>, <@, <->; btree_gist for scalar columns in exclusion constraints | Lossy, rechecks needed; slower builds than B-tree. Supports index-only scans for some opclasses. |
| SP-GiST | Non-balanced partitioned data: IP addresses (inet), points, text prefixes | quad-tree, k-d tree, radix tree opclasses | Great for skewed distributions; narrower operator support. |
| BRIN | Huge append-only tables where values correlate with physical order (timestamps, serial IDs) | minmax, minmax_multi (PG14+, tolerates outliers), bloom | Tiny (kilobytes for terabytes), cheap to maintain, but always lossy (bitmap scan + recheck). Useless if data is not physically correlated. Tune pages_per_range (default 128); set autosummarize = on. |
| Hash | Equality-only on long keys | = | WAL-logged since PG10. Rarely better than B-tree; no uniqueness or ordering. |
-- jsonb containment with the smaller opclass
CREATE INDEX events_payload_gin ON events USING gin (payload jsonb_path_ops);
SELECT * FROM events WHERE payload @> '{"type":"checkout","country":"DE"}';
-- No double-booking: exclusion constraint on a range
CREATE EXTENSION btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
-- 2 TB append-only metrics table
CREATE INDEX metrics_ts_brin ON metrics USING brin (ts) WITH (pages_per_range = 32, autosummarize = on);
SELECT correlation FROM pg_stats WHERE tablename = 'metrics' AND attname = 'ts'; -- want ≈ 1.0
3.4 pgvector for AI workloads
pgvector (0.8.x at the time of writing) adds vector, halfvec (16-bit floats), sparsevec and bit types, with distance operators <-> (L2), <#> (negative inner product), <=> (cosine), <+> (L1), and <~>/<%> (Hamming/Jaccard for bit).
| HNSW | IVFFlat | |
|---|---|---|
| Structure | Multi-layer proximity graph | k-means clusters (lists) with an inverted file |
| Build | Slower, memory-hungry (fit in maintenance_work_mem); can build on an empty table | Fast; needs representative data present before building |
| Recall/speed | Better recall-latency trade-off | Good, degrades as data drifts from the trained centroids |
| Build knobs | m (16), ef_construction (64) | lists (start at rows/1000 up to 1M rows, √rows above) |
| Query knobs | hnsw.ef_search (40) | ivfflat.probes (1; try √lists) |
| Max indexed dims | 2,000 for vector, 4,000 for halfvec, 64,000 for bit | |
CREATE EXTENSION vector;
CREATE TABLE doc_chunks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL,
body text NOT NULL,
embedding halfvec(1536) NOT NULL
);
CREATE INDEX ON doc_chunks USING hnsw (embedding halfvec_cosine_ops)
WITH (m = 16, ef_construction = 128);
CREATE INDEX ON doc_chunks (tenant_id);
-- Filtered ANN search: let the index keep scanning until enough rows pass the filter
SET hnsw.ef_search = 100;
SET hnsw.iterative_scan = relaxed_order; -- pgvector 0.8+
SELECT id, body, embedding <=> $1 AS distance
FROM doc_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
- Approximate indexes plus a
WHEREfilter can return fewer thanLIMITrows, because the filter is applied after the index returns its candidates. Iterative scans (0.8+), partial indexes per tenant or category, or partitioning by tenant are the fixes. - Measure recall against an exact scan (
SET enable_indexscan = off) on a sample of real queries. Do not tuneef_searchblind. halfvechalves storage and index size with negligible recall loss for most embedding models. Binary quantization withbitplus re-ranking is the next step down.- Hybrid search: combine a full-text
ts_rankresult set and a vector result set with reciprocal rank fusion in SQL.
3.5 Index hygiene
-- Unused indexes (check every replica too: stats are per node)
SELECT s.relname, s.indexrelname, s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0 AND NOT i.indisunique
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- Rebuild a bloated index online
REINDEX INDEX CONCURRENTLY orders_cust_cover_idx;
-- Test an index without building it
CREATE EXTENSION hypopg;
SELECT * FROM hypopg_create_index('CREATE INDEX ON orders (status, created_at)');
EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20;
CREATE INDEX CONCURRENTLY that fails leaves an INVALID index that still slows every write. Check pg_index.indisvalid after any failed build and drop it.Module 3 quiz
-
Query:
WHERE tenant_id = $1 AND created_at >= now() - interval '7 days' ORDER BY created_at DESC LIMIT 20. Which index fits best?Show answer and explanation
C. Equality column first, then the range/sort column. The scan starts at the tenant's newest entry and walks backward, stopping after 20 rows with no sort node.
-
A 3 TB
metricstable is insert-only, ordered byts, and queried by time range. Which index minimizes storage and write overhead?Show answer and explanation
A. Physical order matches
ts, so per-range min/max summaries are tight. The index is a tiny fraction of a B-tree's size, and range queries skip non-matching block ranges. -
Why might
EXPLAINshow an Index Only Scan with a high "Heap Fetches" count?Show answer and explanation
D. For pages not all-visible, the executor must check the heap for tuple visibility. Vacuum sets the bits. Tuning autovacuum on insert-heavy tables (insert thresholds) fixes this.
-
An HNSW query with
WHERE category = 'legal' ... LIMIT 10returns only 3 rows, though thousands of legal documents exist. What is the best first fix?Show answer and explanation
B. The index returns
ef_searchcandidates and the filter is applied afterward. Iterative scans keep pulling candidates until the limit is satisfied. A partial index restricts the graph to the category. -
Which GIN opclass should you choose for a
jsonbcolumn queried only with@>containment?Show answer and explanation
C.
jsonb_path_opshashes full paths, producing a smaller and faster index for containment, at the cost of not supporting key-existence operators.
Query Optimization & Query Planner Mechanics
How the cost-based optimizer turns SQL into a plan tree, how to read what it chose, and how to correct it when its estimates are wrong.
Learning objectives
- Read
EXPLAIN (ANALYZE, BUFFERS)bottom-up and find the first node where estimated and actual rows diverge. - Explain when the planner picks a sequential, index, index-only or bitmap scan, and why.
- Describe the cost model of nested loop, hash and merge joins and predict which one wins.
- Fix estimates with statistics targets, extended statistics and query rewrites before reaching for planner switches.
- Diagnose generic versus custom plan problems with prepared statements.
4.1 The query pipeline
Parser → analyzer → rewriter (views, rules, RLS) → planner/optimizer → executor. The planner enumerates scan paths for each relation, join orders and methods (exhaustively up to geqo_threshold = 12 relations, then with the genetic optimizer), and picks the cheapest total cost, or cheapest startup cost when a LIMIT or cursor favors fast first rows. join_collapse_limit and from_collapse_limit (both 8) bound how much it reorders explicit joins and subqueries.
4.2 The cost model
| Parameter | Default | Notes |
|---|---|---|
seq_page_cost | 1.0 | Baseline unit |
random_page_cost | 4.0 | Lower to about 1.1 to 1.5 on SSD/NVMe or when the working set is cached |
cpu_tuple_cost | 0.01 | Per row processed |
cpu_index_tuple_cost | 0.005 | Per index entry |
cpu_operator_cost | 0.0025 | Per operator or function call |
effective_cache_size | 4GB | Planner hint only (no allocation). Set to about 50 to 75% of RAM. |
Sequential scan cost ≈ relpages × seq_page_cost + reltuples × cpu_tuple_cost (plus operator costs for filters). Row estimates come from pg_statistic (view: pg_stats): null fraction, distinct count, most common values with frequencies, histogram bounds, and physical correlation. default_statistics_target (100) controls the sample size and number of MCV and histogram entries.
4.3 Reading EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, WAL)
SELECT c.name, sum(o.total)
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.country = 'DE' AND o.created_at >= '2026-09-01'
GROUP BY c.name;
HashAggregate (cost=48211.20..48236.20 rows=2000 width=40)
(actual time=412.8..413.4 rows=1874 loops=1)
Group Key: c.name
Buffers: shared hit=9120 read=31877
-> Hash Join (cost=1210.00..47961.20 rows=50000 width=18)
(actual time=11.2..389.1 rows=612443 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on orders o (cost=0.00..43520.00 rows=950000 width=14)
(actual time=0.02..201.5 rows=948201 loops=1)
Filter: (created_at >= '2026-09-01'::date)
Rows Removed by Filter: 1051799
-> Hash (cost=1085.00..1085.00 rows=10000 width=20)
(actual time=10.9..10.9 rows=64120 loops=1)
Buckets: 65536 (originally 16384) Batches: 1 Memory Usage: 3843kB
-> Seq Scan on customers c (rows=10000) (actual rows=64120 loops=1)
Filter: (country = 'DE')
- Read inside-out, bottom-up. Each node shows estimated
cost=startup..total,rows,width, then actuals. Actual time and rows are per loop: multiply byloops. - Find the first misestimate. Here customers estimated 10,000 rows for DE but got 64,120 (6×), which propagated into a 12× join misestimate. The hash table resized ("originally 16384"). A stale or too-coarse MCV list for
countryis the likely cause. - Buffers:
shared hitcame from cache,readfrom the OS (page cache or disk),dirtied/writtenshow write side effects,temp read/writtenmeans spills to disk. PG18EXPLAIN ANALYZEincludesBUFFERSby default, and index scans report "Index Searches". - Spills:
Sort Method: external merge Disk: 120MBor hashBatches: 8meanwork_mem(timeshash_mem_multiplier, default 2.0, for hashes) was too small for that node. - Other options:
VERBOSE(output columns, schema-qualified names),SETTINGS(non-default planner GUCs),WAL,SERIALIZEandMEMORY(PG17),GENERIC_PLAN(PG16, plan a$1query without values),FORMAT JSONfor visualizers like explain.dalibo.com.
EXPLAIN ANALYZE executes the statement. Wrap INSERT/UPDATE/DELETE in BEGIN; ... ROLLBACK;. Timing overhead can also inflate nodes that run millions of loops; use TIMING OFF to check.4.4 Scan methods
| Scan | How it works | Chosen when |
|---|---|---|
| Seq Scan | Reads every page in order (parallel-capable, async I/O in PG18) | Selectivity is high (often over ~5 to 10% of rows), the table is small, or no usable index |
| Index Scan | Walks the index, fetches each heap tuple in index order | Few rows, or the index order satisfies ORDER BY/LIMIT |
| Index Only Scan | Answers from the index, heap only for non-all-visible pages | All needed columns in the index (key or INCLUDE) and the visibility map is mostly set |
| Bitmap Index Scan + Bitmap Heap Scan | Collects matching TIDs into an in-memory bitmap, sorts them by page, then reads heap pages once each in physical order | Medium selectivity, or combining several indexes with BitmapAnd/BitmapOr |
| TID Scan / TID Range Scan | Direct ctid access | WHERE ctid = ... or ctid ranges (batched maintenance jobs) |
Bitmap heap scan details. If the bitmap exceeds work_mem, it becomes lossy: it remembers whole pages instead of individual tuples, and every tuple on those pages must be rechecked. EXPLAIN shows Heap Blocks: exact=1200 lossy=48000 and Rows Removed by Index Recheck. More work_mem or a more selective index fixes it. Bitmap scans lose the index order, so they cannot satisfy ORDER BY without a sort.
4.5 Join strategies
| Method | Algorithm | Cost shape | Wins when | Watch for |
|---|---|---|---|---|
| Nested Loop | For each outer row, scan the inner (ideally by index) | outer rows × inner lookup cost | Small outer side and an index on the inner join key; LIMIT queries; non-equality joins | Catastrophic when the outer estimate is 1 but actual is 100,000. Memoize (PG14+) caches inner results for repeated keys. |
| Hash Join | Build a hash table on the smaller input, probe with the larger | ≈ linear in both inputs; memory bounded by work_mem × hash_mem_multiplier | Large unsorted inputs with an equality join | Batches > 1 means spill to disk. Underestimated build side. Parallel Hash shares one table across workers. |
| Merge Join | Walk two inputs sorted on the join key in lockstep | Linear after sorting; sort costs n log n | Both inputs already sorted (indexes, prior sort), very large inputs, or a FULL join | Explicit Sort nodes spilling to disk. Needs mergeable (B-tree) equality operators. |
4.6 Fixing bad estimates
Fix the information first, the plan second.
- Fresh statistics:
ANALYZEafter bulk loads. Autovacuum's analyze threshold is 10% of rows by default. PG18pg_upgradekeeps planner statistics, so major upgrades no longer start with an emptypg_statistic(extended statistics still need a freshANALYZE). - Bigger samples for skewed columns:
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; - Extended statistics for correlated columns. The planner assumes independence, so
city = 'Munich' AND country = 'DE'is underestimated.CREATE STATISTICS addr_stats (dependencies, ndistinct, mcv) ON city, country FROM addresses; ANALYZE addresses; - Rewrite opaque predicates:
WHERE created_at::date = $1becomes a range;WHERE coalesce(x, 0) = 0becomesx = 0 OR x IS NULL; functions get correctROWS/COSTestimates and volatility. - Correlated subqueries and CTEs:
MATERIALIZEDCTEs act as an optimization fence;NOT MATERIALIZEDallows pushdown.NOT IN (subquery)cannot become an anti-join because of NULL semantics; useNOT EXISTS.
4.7 Planner method configuration parameters
The enable_* parameters do not forbid a plan type. They add a large penalty, so the planner avoids that path unless nothing else is possible. PG18 EXPLAIN reports these as Disabled: true on the node instead of showing an inflated cost.
| Group | Parameters |
|---|---|
| Scans | enable_seqscan, enable_indexscan, enable_indexonlyscan, enable_bitmapscan, enable_tidscan |
| Joins | enable_nestloop, enable_hashjoin, enable_mergejoin, enable_memoize, enable_parallel_hash |
| Aggregation and sorting | enable_hashagg, enable_sort, enable_incremental_sort, enable_presorted_aggregate (PG16), enable_group_by_reordering (PG17), enable_distinct_reordering (PG18) |
| Partitioning | enable_partition_pruning, enable_partitionwise_join, enable_partitionwise_aggregate (last two off by default) |
| Other | enable_material, enable_gathermerge, enable_parallel_append, enable_async_append, enable_self_join_elimination (PG18) |
-- Diagnose, never deploy globally:
BEGIN;
SET LOCAL enable_nestloop = off;
EXPLAIN (ANALYZE, BUFFERS) ... ; -- does the hash join plan really run faster?
ROLLBACK;
-- If a forced plan is truly needed for one function or role:
ALTER FUNCTION report_monthly() SET enable_nestloop = off;
ALTER ROLE reporting SET random_page_cost = 1.1;
Other levers: plan_cache_mode (auto, force_custom_plan, force_generic_plan) for prepared statements whose generic plan is bad for skewed parameters; pg_hint_plan for per-query hints when all else fails; jit (often worth disabling for OLTP, where compile time exceeds savings); parallel query knobs max_parallel_workers_per_gather, parallel_setup_cost, min_parallel_table_scan_size.
status, the generic plan can be terrible for the rare value. Look for $1 in EXPLAIN output from auto_explain.Module 4 quiz
-
A Nested Loop shows
rows=1estimated andactual rows=1 loops=48000on its inner Index Scan, withactual time=0.04..0.05. What is the inner side's total time?Show answer and explanation
B. Actual time is per loop. 0.05 ms × 48,000 loops ≈ 2,400 ms. This is the most common misreading of EXPLAIN ANALYZE.
-
A bitmap heap scan reports
Heap Blocks: exact=900 lossy=52000. What is the most direct fix?Show answer and explanation
A. The TID bitmap exceeded
work_memand degraded to page-level, forcing rechecks of every tuple on 52,000 pages. -
Filters on
cityandcountrytogether are underestimated 50×, though each is estimated well alone. What fixes this?Show answer and explanation
D. The planner multiplies selectivities assuming independence. Extended statistics capture the functional dependency between city and country.
-
Which join method is the only one that can handle a
FULL OUTER JOINon a non-hashable but B-tree-sortable key?Show answer and explanation
C. Nested loops cannot do full joins. Hash full joins need hashable operators. Merge join only needs a mergeable B-tree equality operator.
-
What does
SET enable_seqscan = offactually do?Show answer and explanation
B. It is a penalty, not a ban. A table with no usable index still gets a seq scan. PG18 marks such nodes "Disabled: true" in EXPLAIN.
-
A prepared query is fast for 5 runs, then slow forever for rare
statusvalues. What is happening?Show answer and explanation
C. After five executions, Postgres compares generic plan cost with the average custom plan cost and may switch. Fix with
plan_cache_mode = force_custom_planfor that query or role, or with a partial index for the rare values.
Scaling, Concurrency & Infrastructure Operations
Locks, deadlocks, pooling, migrations that do not take the site down, replication, and when to split data across tables or machines.
Learning objectives
- Name the lock each DDL and DML statement takes and predict what it blocks.
- Read a blocking tree from
pg_locksandpg_blocking_pids(), and read a deadlock report. - Choose a PgBouncer pool mode and size, and know what each mode breaks.
- Execute any common schema change with zero downtime using lock timeouts and expand/contract.
- Compare physical and logical replication and design an HA topology with a stated RPO and RTO.
- Decide between partitioning, sharding and neither.
5.1 Table-level lock modes
| Mode | Taken by | Conflicts with |
|---|---|---|
| ACCESS SHARE | SELECT | ACCESS EXCLUSIVE |
| ROW SHARE | SELECT ... FOR UPDATE / FOR SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | INSERT, UPDATE, DELETE, MERGE | SHARE, SHARE ROW EXCL., EXCLUSIVE, ACCESS EXCL. |
| SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY, VALIDATE CONSTRAINT, ATTACH PARTITION (on parent) | itself, SHARE, SHARE ROW EXCL., EXCLUSIVE, ACCESS EXCL. |
| SHARE | CREATE INDEX (non-concurrent) | ROW EXCL., SHARE UPDATE EXCL., SHARE ROW EXCL., EXCLUSIVE, ACCESS EXCL. |
| SHARE ROW EXCLUSIVE | CREATE TRIGGER, ADD FOREIGN KEY (both tables) | ROW EXCL. and everything stronger, including itself |
| EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | everything except ACCESS SHARE |
| ACCESS EXCLUSIVE | DROP, TRUNCATE, VACUUM FULL, CLUSTER, REINDEX, most ALTER TABLE forms | everything, including plain SELECT |
Row-level locks live in the tuple header (xmax plus infomask bits, with MultiXacts when shared), not in the lock table: FOR KEY SHARE (taken by FK checks) < FOR SHARE < FOR NO KEY UPDATE (taken by ordinary UPDATE) < FOR UPDATE (taken by DELETE and updates of key columns).
5.2 The lock queue problem
Lock requests queue in order. If a long SELECT holds ACCESS SHARE and a migration requests ACCESS EXCLUSIVE, the migration waits, and every new query (even plain reads) queues behind the migration. A 2-millisecond ALTER TABLE takes the site down because of a 10-minute report.
SET lock_timeout = '3s'; -- give up instead of blocking the queue
SET statement_timeout = '15s';
ALTER TABLE orders ADD COLUMN source text; -- retry with backoff on failure
5.3 Diagnosing blocking and deadlocks
-- Who blocks whom
SELECT a.pid, a.usename, a.state, a.wait_event_type, a.wait_event,
pg_blocking_pids(a.pid) AS blocked_by,
now() - a.xact_start AS xact_age,
left(a.query, 80) AS query
FROM pg_stat_activity a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY xact_age DESC;
-- End the root blocker (cancel first, terminate if it is idle in transaction)
SELECT pg_cancel_backend(12345);
SELECT pg_terminate_backend(12345);
A deadlock is a cycle in the wait-for graph. After deadlock_timeout (default 1s), the waiting backend runs the detector and aborts one transaction with SQLSTATE 40P01. The server log shows both processes, their lock requests and their queries. Turn on log_lock_waits to also log any wait longer than deadlock_timeout.
Common causes and fixes:
- Inconsistent row order: two transactions update rows A then B and B then A. Fix by sorting keys before updating, or updating in one statement.
- Foreign keys: inserting children takes
FOR KEY SHAREon the parent, which conflicts with a concurrent key update or delete of the parent. - Upserts across unique indexes in different orders under concurrency.
- Explicit lock upgrades: two sessions take
SHAREthen try to upgrade toEXCLUSIVE. Take the strongest lock you will need first.
Patterns to teach: job queues with SELECT ... FOR UPDATE SKIP LOCKED LIMIT n; fail-fast with NOWAIT; application-level mutual exclusion with advisory locks (pg_try_advisory_xact_lock); idle_in_transaction_session_timeout and transaction_timeout (PG17) as safety nets.
5.4 Connection pooling with PgBouncer
| Pool mode | Server connection returned | Works | Breaks |
|---|---|---|---|
| session | When the client disconnects | Everything | Little multiplexing; only helps with connection churn |
| transaction | At COMMIT/ROLLBACK | Most OLTP apps; protocol-level prepared statements since PgBouncer 1.21 (max_prepared_statements) | Session state: plain SET, session advisory locks, LISTEN, WITH HOLD cursors, temp tables across transactions, SQL-level PREPARE |
| statement | After each statement | Autocommit-only workloads | Multi-statement transactions are refused |
- Sizing: database throughput peaks at a small number of active connections, roughly 2 to 4 × CPU cores for OLTP. Start
default_pool_sizenear that per database/user pair and raise it only with measured gains. Total server connections across all pooler instances must stay belowmax_connectionsminus reserved and admin slots. - Key settings:
max_client_conn,default_pool_size,reserve_pool_size,server_idle_timeout,query_wait_timeout,server_reset_query(only used in session mode). - Topology: PgBouncer on each app host (reduces network hops, multiplies server connections) versus a central tier (one global limit, extra hop, needs its own HA). PgBouncer is single-threaded; run several processes with
so_reuseportfor very high throughput. - Monitor:
SHOW POOLS(cl_waiting,maxwait) andSHOW STATSon the admin console. - Alternatives: PgCat and PgDog (multi-threaded, with load balancing and sharding features), Odyssey, Supavisor, and cloud proxies such as RDS Proxy.
5.5 Zero-downtime schema migrations
Rule one: every migration sets lock_timeout and retries. Rule two: anything that rewrites or scans a big table under ACCESS EXCLUSIVE gets replaced by a multi-step pattern.
| Change | Unsafe form | Safe pattern |
|---|---|---|
| Add column with default | Volatile default (DEFAULT clock_timestamp(), gen_random_uuid()) rewrites the table | Constant defaults are metadata-only since PG11. For volatile values, add nullable, backfill in batches, then set the default. |
| Add index | CREATE INDEX blocks writes (SHARE) | CREATE INDEX CONCURRENTLY, outside a transaction block; check indisvalid afterward |
| Add foreign key | Validates all rows while holding SHARE ROW EXCLUSIVE | ADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT (SHARE UPDATE EXCLUSIVE, does not block writes) |
| Add CHECK | Full scan under ACCESS EXCLUSIVE | NOT VALID then VALIDATE |
| Set NOT NULL | Full scan under ACCESS EXCLUSIVE | Add CHECK (col IS NOT NULL) NOT VALID, validate, then SET NOT NULL skips the scan (PG12+), then drop the check. PG18 ADD CONSTRAINT ... NOT NULL col NOT VALID directly. |
| Add unique constraint | Builds index under ACCESS EXCLUSIVE | CREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... UNIQUE USING INDEX |
| Change column type | ALTER COLUMN TYPE rewrites the table and all its indexes (except binary-compatible changes such as varchar(50)→varchar(100) or varchar→text) | Expand/contract: new column, dual-write (trigger or app), backfill, switch reads, drop old |
| Rename column or table | Breaks running app versions instantly | Expand/contract, or a view/generated column alias during the transition |
| Remove bloat | VACUUM FULL | pg_repack or pg_squeeze (brief locks only at start and swap) |
| Detach partition | DETACH PARTITION (ACCESS EXCLUSIVE on parent) | DETACH PARTITION ... CONCURRENTLY (PG14+) |
-- Batched backfill: small transactions, resumable, gentle on WAL and replicas
DO $$
DECLARE n int;
BEGIN
LOOP
UPDATE orders SET source = 'web'
WHERE id IN (SELECT id FROM orders WHERE source IS NULL LIMIT 5000 FOR UPDATE SKIP LOCKED);
GET DIAGNOSTICS n = ROW_COUNT;
EXIT WHEN n = 0;
COMMIT; -- procedures and DO blocks can commit (PG11+)
PERFORM pg_sleep(0.05);
END LOOP;
END $$;
Tooling: Squawk (lints migration SQL for these hazards), strong_migrations (Rails), pgroll and Reshape (automated expand/contract with versioned views).
5.6 Physical vs logical replication
| Physical (streaming) | Logical (publish/subscribe) | |
|---|---|---|
| Unit | WAL bytes, whole cluster | Row changes per table (decoded from WAL) |
| Replica | Byte-identical, read-only hot standby | Independent, writable database |
| Versions | Same major version and platform | Across major versions (upgrades) |
| Replicates | Everything: DDL, sequences, indexes, all databases | DML of published tables. No DDL, no sequence values, no large objects |
| Filtering | None | Per table, row filters and column lists (PG15+) |
| Requires | wal_level = replica | wal_level = logical, a primary key or REPLICA IDENTITY |
| Used for | HA, read scaling, PITR base | Major upgrades, CDC (Debezium), consolidation, partial copies |
- Synchronous replication:
synchronous_standby_names = 'ANY 1 (s1, s2)'withsynchronous_commit = on(orremote_applyfor read-your-writes on replicas). A sync standby going away stalls commits unless another can take over. - Slots guarantee WAL retention for a consumer. An abandoned slot fills the disk; cap it with
max_slot_wal_keep_size, and PG18idle_replication_slot_timeoutinvalidates idle slots. - Standby conflicts: vacuum on the primary can remove rows a standby query needs. Options:
hot_standby_feedback = on(moves bloat to the primary), or raisemax_standby_streaming_delay(increases lag). - Logical replication progress: decoding from standbys (PG16); failover slots synced to standbys with
sync_replication_slots(PG17);pg_createsubscriberconverts a physical standby into a logical subscriber (PG17);pg_upgradepreserves logical slots (PG17); PG18 replicates stored generated columns and reports conflict counts inpg_stat_subscription_stats. - HA orchestration: Patroni (with etcd or Consul) is the de facto standard on VMs; CloudNativePG on Kubernetes. Fencing and a single source of truth for "who is primary" matter more than failover speed.
- Backups: pgBackRest or Barman for base backups plus WAL archiving and point-in-time recovery. A replica is not a backup.
5.7 Partitioning and sharding
Declarative partitioning (RANGE, LIST, HASH) splits one logical table into child tables. It pays off for:
- Retention: dropping or detaching a month is instant;
DELETEof a month is a bloat event. - Pruning: at plan time for constants, at execution time for parameters and joins (look for "Subplans Removed").
- Maintenance: vacuum, reindex and freeze per partition; old partitions become all-frozen and are skipped.
Costs and rules: primary keys and unique constraints must include the partition key; queries without the key touch every partition; planning time grows with partition count (keep it in the hundreds to low thousands); attach a pre-loaded table with a matching CHECK constraint already validated to avoid a scan. Use pg_partman to create future partitions and enforce retention.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id uuid NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_10 PARTITION OF events
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE events_default PARTITION OF events DEFAULT;
Sharding spreads partitions across machines. Options: Citus (distributed tables by a shard key, reference tables copied to every node, colocation so joins on the shard key stay local), application-level sharding by tenant, or newer proxy-based sharders such as PgDog. Shard only when a single well-tuned primary (vertical scaling plus read replicas) cannot hold the write load or the data size. Cross-shard transactions, joins and schema changes are the costs.
Module 5 quiz
-
A 10-minute analytics query is running on
orders. You runALTER TABLE orders ADD COLUMN note textwithout a lock timeout. What happens to newSELECTs onorders?Show answer and explanation
C. Lock requests queue in order. The pending ACCESS EXCLUSIVE request conflicts with the new ACCESS SHARE requests, so they wait behind it. Always set
lock_timeoutfor DDL. -
Which sequence adds a foreign key to a 300M-row table without blocking writes for the validation?
Show answer and explanation
A.
NOT VALIDenforces the FK for new rows with a short lock.VALIDATEscans existing rows under SHARE UPDATE EXCLUSIVE, which allows reads and writes. -
Under PgBouncer transaction pooling, which feature does NOT work reliably?
Show answer and explanation
D. Session-scoped state stays on a server connection that another client gets next. Transaction-scoped advisory locks (
pg_advisory_xact_lock) work. -
You need to upgrade from PG14 to PG18 with under a minute of downtime. Which approach fits?
Show answer and explanation
B. Physical replication requires the same major version. Logical replication crosses versions; the cutover only waits for lag to reach zero. Sequence values are not replicated and must be set before switching.
-
Two transactions deadlock repeatedly while updating the same pair of account rows for transfers. What is the cleanest fix?
Show answer and explanation
C. Deadlocks need a cycle. A global lock order makes a cycle impossible. A longer timeout only delays detection.
-
On a table partitioned by
created_at, why doesCREATE UNIQUE INDEX ON events (id)fail?Show answer and explanation
A. Each partition enforces uniqueness locally. Including the partition key makes local uniqueness imply global uniqueness. Use
(id, created_at).
3 · Production-Scale Architectural Labs
Every lab runs against the same sandbox and the same schema, so data you build in Lab A is still there in Lab F. There is no grader: each lab names the evidence to collect, a before/after metric and the query or setting that changed it, and that evidence is how you know you are done. Pause and resume whenever you like; docker compose stop keeps the data.
Sandbox environment
One Docker Compose stack that you run yourself, on a workstation or a rented cloud VM, sized for 8 vCPU / 32 GB. A smaller machine works if you generate less data, though some effects take longer to appear. Storage is deliberately constrained so bloat and I/O effects show up within minutes. Stop the VM between sessions to keep costs down.
| Service | Image / tool | Purpose |
|---|---|---|
pg-primary | postgres:18 with pg_stat_statements, auto_explain, pageinspect, pgstattuple, pg_buffercache, pg_visibility, hypopg, pgvector, pg_repack, pg_partman | Primary database. shared_buffers kept small (1 GB) so working sets spill and I/O is visible. |
pg-replica | postgres:18 streaming standby | Read scaling, replication lag, standby conflicts and failover drills |
pg-logical | postgres:17 | Logical replication and cross-version upgrade lab |
pgbouncer | PgBouncer 1.24 | Transaction pooling in front of the primary |
patroni + etcd | Patroni 4, 3-node etcd (optional track) | Automated failover for Lab F |
loadgen | pgbench 18, custom scripts, k6 | Workload drivers with named profiles (oltp, bloat, hotspot, report, blackfriday) |
monitoring | postgres_exporter, Prometheus, Grafana, PgHero | Dashboards for TPS, latency percentiles, locks, bloat, replication lag, autovacuum |
chaos | Toxiproxy and shell scripts | Inject network latency, kill backends, fill disks, open idle transactions |
Lab schema: Ledgerline
Ledgerline is a fictional multi-tenant commerce and payments platform. It is small enough to understand in ten minutes and large enough (about 80 GB generated) to make bad plans hurt.
| Table | Rows | Used in | Designed to exercise |
|---|---|---|---|
tenants | 2,000 | Labs B, capstone | RLS, skewed tenant sizes (top 1% hold 40% of data) |
customers | 20M | Labs C, D | Correlated city/country columns for estimate errors |
products | 2M | Lab C | jsonb attributes (GIN), halfvec embeddings (HNSW), trigram name search |
orders | 200M | Labs A, D, E, capstone | Skewed status (98% delivered), hot updates, migrations |
order_items | 600M | Labs D, capstone | Join strategies, custom aggregate |
accounts | 1M | Lab E, capstone | Balance transfers that deadlock |
ledger_entries | 1B | Labs A, C, F | Append-only, BRIN, freezing, replication volume |
events | 2B, partitioned monthly | Labs A, F | Partitioning, retention, pruning |
job_queue | churns ~5k/s | Labs A, E | Queue bloat, SKIP LOCKED |
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT uuidv7(),
name text NOT NULL,
plan text NOT NULL CHECK (plan IN ('free','pro','enterprise')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
email text NOT NULL,
city text,
country char(2),
segment text NOT NULL DEFAULT 'smb',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, email)
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
name text NOT NULL,
attributes jsonb NOT NULL DEFAULT '{}',
price numeric(12,2) NOT NULL,
embedding halfvec(768)
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL CHECK (status IN ('pending','paid','shipped','delivered','refunded')),
total numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
) WITH (fillfactor = 90);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id),
line_no int NOT NULL,
product_id bigint NOT NULL REFERENCES products(id),
qty int NOT NULL CHECK (qty > 0),
unit_price numeric(12,2) NOT NULL,
PRIMARY KEY (order_id, line_no)
);
CREATE TABLE accounts (
id bigint PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
balance numeric(14,2) NOT NULL CHECK (balance >= 0)
) WITH (fillfactor = 70);
CREATE TABLE ledger_entries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id bigint NOT NULL REFERENCES accounts(id),
order_id bigint,
amount numeric(14,2) NOT NULL,
posted_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id uuid NOT NULL,
kind text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE job_queue (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind text NOT NULL,
payload jsonb NOT NULL,
run_at timestamptz NOT NULL DEFAULT now(),
locked_by text
);
Indexes are intentionally incomplete at the start. You add them during Labs C and D and in the capstone.
Lab A · The bloat factory
Module 1 · Time about 3 h · Goal Create severe bloat on purpose, prove what blocks cleanup, then tune autovacuum so the table stays under 20% dead tuples at 5,000 updates/s.
- Record a baseline with
pgstattuple('orders'),pg_relation_sizeand index sizes. - Start the
bloatprofile: pgbench script updatingorders.statusandupdated_aton random rows, plusjob_queueinsert/delete churn. - Open a
REPEATABLE READtransaction in another session and leave it idle. Watchn_dead_tuprise and VACUUM VERBOSE report dead tuples it "cannot remove yet". - Find the culprit using
backend_xmininpg_stat_activity. Then repeat with an abandoned logical replication slot as the culprit. - Add an index on
updated_atand measure the HOT ratio collapse. Remove it, setfillfactor = 80, rewrite withpg_repack, and measure again. - Tune per-table autovacuum (scale factor, threshold, cost limit and delay) until dead tuples stay below target. Show
pg_stat_progress_vacuumduring a run. - Stretch: burn XIDs with a tight loop of single-row transactions, watch
age(datfrozenxid)climb, and trigger an anti-wraparound autovacuum.
Evidence to collect: dead-tuple percentage over time (Grafana screenshot), HOT ratio before and after, and the final ALTER TABLE ... SET (...) statement.
Lab B · Multi-tenant RLS behind PgBouncer
Module 2 · Time about 2.5 h · Goal Enforce tenant isolation with RLS that holds under transaction pooling and keeps index scans.
- Enable and force RLS on
orders,customersandproductswith acurrent_setting('app.tenant_id')policy. - Reproduce the leak: set the tenant with plain
SETthrough PgBouncer and show another client inheriting it. Fix withset_config(..., true). - Compare plans with and without RLS. Fix a plan that lost its index because of a non-leakproof function in the user's predicate.
- Write the analytical queries the tenant dashboard needs: 7-day rolling revenue (
RANGEframe), top 3 products per category (LATERAL), weighted average price (custom aggregate), and the referral tree (recursive CTE withCYCLE).
Evidence: a test that fails before the fix and passes after, plus EXPLAIN output for the RLS-filtered query showing an index scan.
Lab C · Index tournament
Module 3 · Time about 3 h · Goal For six query shapes, pick an index, measure read latency, write overhead, size and build time, and defend the choice.
| Round | Query shape | Candidates |
|---|---|---|
| 1 | Tenant's open orders, newest first, LIMIT 50 | composite, partial, covering |
| 2 | products.attributes @> '{"color":"red"}' | GIN jsonb_ops vs jsonb_path_ops |
| 3 | Product name ILIKE '%wireless%' | GIN trigram vs GiST trigram |
| 4 | ledger_entries by posted_at range over 1B rows | B-tree vs BRIN (minmax, minmax-multi, different pages_per_range) |
| 5 | Semantic product search, filtered by tenant | HNSW vs IVFFlat, vector vs halfvec, iterative scan on/off; measure recall@10 |
| 6 | Overlapping reservation check on a small bookings (room_id, during tstzrange) table created for this round | GiST exclusion constraint vs trigger |
Evidence: a results table per round (p50/p95 read latency, insert TPS impact, index size) and one paragraph on the winner. Each round ends with pg_stat_user_indexes to find the losing indexes and drop them.
Lab D · Plan surgery
Module 4 · Time about 3 h · Goal Fix ten slow queries from the pg_stat_statements top-10, each with a different root cause.
Planted causes: stale statistics after a bulk load; correlated city/country columns; a non-sargable created_at::date = $1; NOT IN with a nullable subquery; a generic plan for the rare status = 'refunded'; a lossy bitmap scan from low work_mem; a hash join spilling to 16 batches; a nested loop with a 1-row estimate that is really 300k; a MATERIALIZED CTE blocking pushdown; and random_page_cost = 4 on NVMe.
Rules: diagnose with EXPLAIN (ANALYZE, BUFFERS); use enable_* only to test a hypothesis inside BEGIN ... ROLLBACK; the fix must be statistics, an index, a rewrite, or a scoped setting. Evidence: before and after plans for each query and total time saved per hour from pg_stat_statements.
Lab E · Locks, deadlocks and live migrations
Module 5 · Time about 2.5 h · Goal Ship five schema changes to orders while the oltp profile runs, with p99 latency never exceeding 2× baseline.
- Reproduce the lock-queue outage: start a long report, run
ALTER TABLE ... ADD COLUMNwithout a timeout, and watch TPS drop to zero in Grafana. Recover, then redo it withlock_timeoutand a retry loop. - Ship: a column with a constant default, a
NOT NULLon an existing column, a foreign key fromorder_itemstoproducts, a newexternal_refcolumn with a unique constraint on(tenant_id, external_ref), and a type change oftotalfromnumeric(12,2)tonumeric(14,2)(find out whether it rewrites). - Run the transfer workload that deadlocks, read the server log, and fix the lock ordering.
- Convert the
job_queueworker fromFOR UPDATEtoFOR UPDATE SKIP LOCKEDand compare throughput. - Size PgBouncer: sweep
default_pool_sizefrom 8 to 128 under 1,000 clients and plot TPS and p99.
Evidence: the migration scripts (linted with Squawk), the latency graph during each change, and the pool-size sweep chart.
Lab F · Replication, failover and partitioning
Module 5 · Time about 2.5 h · Goal Measure lag and data loss under failure, and move events retention from DELETE to partition drops.
- Measure replica lag in bytes and seconds under load. Run a long query on the replica and trigger a recovery conflict; fix it two ways and compare the side effects.
- Switch to synchronous replication, measure the commit latency cost, then kill the sync standby and observe.
- With Patroni, kill the primary and measure time to recovery and any lost transactions (async vs sync).
- Set up logical replication from PG18 to the PG17 node for
orderswith a row filter, and show what happens when you add a column on the publisher only. - Fill a disk with an abandoned replication slot, then prevent it with
max_slot_wal_keep_size. - Set up
pg_partmanonevents, compareDELETEof one month versusDETACH ... CONCURRENTLYplusDROPin WAL generated and bloat left behind.
Capstone · "Black Friday at Ledgerline"
A solo incident simulation and the last step of the course. You start the blackfriday profile yourself: 3,000 client connections, a 10× spike in checkout traffic, and a set of planted faults. The database is slowing down and partly locked. Your job is to restore service, stabilize it, and ship one schema change the business needs, without downtime. Plan on about 3 hours in one sitting so the incident stays realistic; there is no hard limit, and you can rerun the profile from scratch as often as you like.
Planted faults (spoiler: open only after your postmortem)
- A batch job left a session
idle in transactionholding a row lock on a hotaccountsrow and pinning the xmin horizon. - A deploy started
ALTER TABLE orders ADD COLUMN fraud_score numeric DEFAULT random()(volatile default, so a full rewrite) with no lock timeout, behind a long report. - The checkout query
SELECT ... FROM orders WHERE tenant_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT 20switched to a generic plan that seq-scans forstatus = 'pending'. - An analytics query on
customersfiltered bycityandcountrychooses a nested loop over 300k rows. - PgBouncer
default_pool_sizeis 400, so the server thrashes on context switches and lock contention. - Autovacuum on
job_queueuses defaults and cannot keep up; the queue table is 40× its live size. - The transfer service locks accounts in request order and deadlocks under load.
Suggested timebox
Use these phases as a guide. Keep a timer running; it is the closest you can get to the pressure of a real incident.
Triage. Alerts fire: p99 checkout latency over 8s, TPS down 70%, connection errors. Write down the top three symptoms with evidence from pg_stat_activity, pg_locks, Grafana and PgHero before touching anything.
Stop the bleeding. Break the lock queue (find root blockers with pg_blocking_pids, cancel the migration, terminate the idle transaction), reduce the pool size, set idle_in_transaction_session_timeout. Record every action in an incident log with timestamps.
Fix the plans. Use pg_stat_statements to rank queries by total time. Fix the generic-plan problem (partial index for non-delivered statuses or plan_cache_mode for the role), add extended statistics for city/country, fix lock ordering in the transfer function, and tune autovacuum on job_queue then repack it online.
Ship the change. Product needs fraud_score on orders, populated for the last 90 days, plus an index for "orders with fraud_score > 0.8 per tenant". Deliver it with expand/contract: nullable column, batched backfill throttled on replica lag, CREATE INDEX CONCURRENTLY as a partial index, and an app-facing default set at the end. Every statement runs with lock_timeout.
Postmortem. Write a one-page blameless postmortem: timeline, root causes, what fixed each one, metrics before and after, and three prevention items (alerts, config, process).
Self-assessment rubric (100 points)
Score yourself honestly once the postmortem is written, then open the planted-faults list above to check your diagnosis.
| Area | Points | Full marks require |
|---|---|---|
| Diagnosis | 25 | All seven faults identified with direct evidence (query output, plan, log line) |
| Recovery | 20 | p99 checkout latency back under 200 ms and TPS within 10% of baseline within 50 minutes |
| Plan fixes | 20 | Before/after plans; no global enable_* changes; fixes survive a restart |
| Zero-downtime migration | 20 | No lock wait over 3s during the change; p99 never over 2× baseline; backfill throttled |
| Postmortem | 15 | Accurate timeline, clear root causes, prevention items that would actually have caught each fault |
4 · Advanced Toolkit & Reference Architecture
4.1 Extensions that ship with Postgres (contrib)
| Extension | What it gives you | Production note |
|---|---|---|
pg_stat_statements | Cumulative stats per normalized query: calls, total/mean time, rows, buffers, WAL, JIT, planning time | Must be in shared_preload_libraries. The single most important extension. Set track_io_timing = on for I/O time per query. |
auto_explain | Logs plans of statements slower than auto_explain.log_min_duration | Use log_analyze with sample_rate to limit overhead; log_nested_statements for functions |
pgstattuple | Exact dead-tuple and free-space figures for tables and indexes | Full scan; use pgstattuple_approx on big tables |
pg_buffercache | What is in shared_buffers right now | PG18 adds pg_buffercache_numa for NUMA placement |
pg_visibility | Visibility map and all-frozen bits per page | Explains index-only scan heap fetches and freeze progress |
pageinspect | Raw page and tuple contents for heap, B-tree, GIN, GiST, BRIN | Teaching and forensics; superuser only |
pg_walinspect | WAL records and stats over an LSN range in SQL (PG15+) | Find which tables generate the most WAL |
amcheck / pg_amcheck | Verifies B-tree, heap (and GIN in PG18) structural integrity | Run regularly against replicas or restored backups |
pg_prewarm | Loads relations into cache; autoprewarm restores the buffer cache after restart | Shortens cold-start latency after failover |
postgres_fdw | Query remote Postgres tables | Manual sharding, migrations, federated reporting |
4.2 Third-party extensions
| Extension | Purpose |
|---|---|
pg_wait_sampling | Samples wait events per backend and query for a time-based profile of where time goes |
pg_stat_kcache | Real CPU and disk I/O per query from the kernel |
pg_qualstats | Records predicates used in WHERE and join clauses; feeds index suggestions |
hypopg | Hypothetical indexes for EXPLAIN without building them |
pg_hint_plan | Plan hints in comments; last resort for a plan you cannot fix otherwise |
pg_repack / pg_squeeze | Online table and index rebuilds to remove bloat |
pg_partman | Automatic partition creation and retention |
pg_cron | Cron-style jobs inside the database |
pgaudit | Session and object audit logging for compliance |
pgvector | Vector types and HNSW/IVFFlat indexes (Module 3) |
Citus | Distributed tables and sharding (Module 5) |
TimescaleDB | Time-series hypertables, compression and continuous aggregates |
PostGIS | Geospatial types and GiST/SP-GiST indexing |
4.3 Monitoring and operations tools
These are external tools, not extensions. PgHero in particular is a web dashboard (a Ruby app or Docker image) that reads from pg_stat_statements and the catalog.
| Tool | Category | Use it for |
|---|---|---|
| PgHero | Dashboard | Quick health view: slow queries, unused and duplicate indexes, bloat, connections, vacuum status |
| postgres_exporter + Prometheus + Grafana | Metrics and alerting | Time-series dashboards and alerts on TPS, latency, lag, XID age, locks |
| pgwatch | Metrics | Postgres-specific metric collection with ready dashboards |
| pganalyze, Datadog DBM, AWS Performance Insights | Commercial monitoring | Query insights, plan collection, index advice |
| pgBadger | Log analysis | HTML reports from server logs: slow queries, lock waits, checkpoints, autovacuum |
| pg_activity, pgcenter | Terminal top | Live view of sessions, waits and I/O during an incident |
| explain.dalibo.com, explain.depesz.com | Plan visualizers | Share and read large EXPLAIN plans |
| pgBackRest, Barman, WAL-G | Backup and PITR | Base backups, WAL archiving, verified restores |
| Patroni, CloudNativePG, pg_auto_failover | HA orchestration | Leader election, automatic failover, switchover |
| Squawk, pgroll | Migration safety | Lint DDL for locking hazards; automated expand/contract |
Key catalog views to know by heart
pg_stat_activity, pg_locks, pg_stat_user_tables, pg_stat_user_indexes, pg_statio_user_tables, pg_stat_io (PG16+), pg_stat_wal, pg_stat_checkpointer (PG17+), pg_stat_bgwriter, pg_stat_replication, pg_replication_slots, pg_stat_subscription_stats, pg_stat_progress_vacuum, pg_stat_progress_create_index, pg_stats, pg_stats_ext.
4.4 Benchmarking and load testing
Methodology
- State the question first: "Does
random_page_cost = 1.1lower p99 for the checkout mix?" not "Is the database fast?" - Use production-shaped data and queries. Same row counts and skew, and the top queries from
pg_stat_statements, weighted by call share. - Warm up, then measure for long enough to include checkpoints and autovacuum (at least 2 ×
checkpoint_timeout). - Change one variable per run and repeat each run at least three times. Report the median and spread.
- Report latency percentiles at a fixed rate, not just maximum throughput. Open-loop tests with
-Ravoid coordinated omission hiding latency spikes. - Watch the whole system during the run: CPU, I/O wait,
pg_stat_io, wait events, WAL rate. The bottleneck may be the client.
pgbench recipes
# Initialize: scale 1 = 100,000 pgbench_accounts rows (scale 1000 ≈ 15 GB)
pgbench -i -s 1000 --foreign-keys --partitions=16 bench
# Built-in TPC-B-like mix, 64 clients, 16 threads, 10 minutes, progress every 10s
pgbench -c 64 -j 16 -T 600 -P 10 -M prepared bench
# Read-only ceiling
pgbench -b select-only -c 128 -j 16 -T 300 -M prepared bench
# Fixed-rate open-loop test with an SLO: 5,000 TPS target, count transactions over 50 ms
pgbench -c 64 -j 16 -T 600 -R 5000 --latency-limit=50 -P 10 bench
# Custom weighted workload replaying the Ledgerline mix
pgbench -c 200 -j 16 -T 900 -P 10 -M prepared \
-f checkout.sql@60 -f order_status.sql@30 -f report.sql@1 -f transfer.sql@9 \
--log --sampling-rate=0.05 ledgerline
-- checkout.sql: skewed access with a Zipfian distribution
\set cust random_zipfian(1, 20000000, 1.07)
\set amount random(5, 500)
BEGIN;
INSERT INTO orders (tenant_id, customer_id, status, total)
SELECT tenant_id, :cust, 'pending', :amount FROM customers WHERE id = :cust;
UPDATE accounts SET balance = balance - :amount
WHERE id = (:cust % 1000000) + 1 AND balance >= :amount;
COMMIT;
Other load tools
- HammerDB (TPROC-C and TPROC-H): standardized OLTP and analytics benchmarks for comparing hardware and versions.
- sysbench: OLTP Lua scripts, useful for cross-database comparison.
- pgreplay / pgreplay-go: replay real production statement logs at original or scaled speed. The closest you get to real traffic.
- k6, Gatling, Locust: drive load through the application API to include pooling, ORM behavior and network.
- fio: measure raw storage IOPS and latency before blaming Postgres.
4.5 Reference architecture
The architecture you should be able to draw and defend by the end of the course, for a write-heavy OLTP service at a few thousand TPS.
Baseline configuration (16 vCPU, 64 GB RAM, NVMe, PG18)
# Memory
shared_buffers = 16GB # ~25% of RAM
effective_cache_size = 48GB
work_mem = 32MB # per node per worker; raise per role for reports
maintenance_work_mem = 2GB
huge_pages = try
# I/O and planner
io_method = worker # PG18; io_uring where supported
effective_io_concurrency = 200
random_page_cost = 1.1
max_parallel_workers_per_gather = 4
jit = off # OLTP
# WAL and checkpoints
wal_compression = zstd
max_wal_size = 16GB
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
# Connections and safety nets (behind PgBouncer)
max_connections = 200
idle_in_transaction_session_timeout = 60s
statement_timeout = 0 # set per role instead
lock_timeout = 0 # set in migration sessions
# Autovacuum
autovacuum_max_workers = 6
autovacuum_vacuum_cost_limit = 2000
autovacuum_naptime = 15s
autovacuum_vacuum_scale_factor = 0.05
# Observability
shared_preload_libraries = 'pg_stat_statements,auto_explain'
track_io_timing = on
track_wal_io_timing = on
log_min_duration_statement = 500ms
log_lock_waits = on
log_autovacuum_min_duration = 10s
log_temp_files = 0
auto_explain.log_min_duration = 2s
Toolkit quiz
-
Which is not a Postgres extension?
Show answer and explanation
B. PgHero is an external web dashboard that reads from
pg_stat_statementsand system catalogs. -
Why use
pgbench -Rwith--latency-limitinstead of running flat out?Show answer and explanation
C. In closed-loop mode, a slow transaction delays the next one, so stalls look like lower throughput instead of high latency (coordinated omission). Rate-limited runs schedule transactions independently.
-
Which view tells you, per backend type and I/O context, how many reads, writes and fsyncs happened (PG16+)?
Show answer and explanation
A.
pg_stat_iobreaks I/O down by backend type, object and context (normal, vacuum, bulkread, bulkwrite). PG18 adds byte counts and WAL rows.