The patterns a 20-year Postgres veteran reaches for daily — each with a real table, a real query, real output, and why the pattern wins. Copy, run, adapt.
Scenario: syncing user profiles from an external system — insert if new, update if the email already exists. No race conditions, one statement.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text UNIQUE NOT NULL,
full_name text,
login_count int DEFAULT 0,
updated_at timestamptz DEFAULT now()
);
INSERT INTO users (email, full_name)
VALUES ('ravi@corp.com', 'Ravi Kumar')
ON CONFLICT (email) DO UPDATE SET
full_name = EXCLUDED.full_name,
login_count = users.login_count + 1,
updated_at = now()
RETURNING id, email, login_count;
| id | login_count | |
|---|---|---|
| 42 | ravi@corp.com | 3 |
ON CONFLICT DO UPDATE SET views = t.views + 1SELECT, then if found: UPDATE else: INSERT in app code — two requests race, one dies with duplicate-keyEXCLUDED refers to the row you tried to insert. Add RETURNING to get the final row back without a second query. Use DO NOTHING for idempotent ingestion.ON CONFLICT (email) requires a UNIQUE constraint/index on exactly that column — without it: “there is no unique or exclusion constraint matching the ON CONFLICT specification”. And if one INSERT batch contains the same email twice, it fails with “cannot affect row a second time” — dedupe the batch first.Scenario: product catalog where every category has different attributes. You want NoSQL flexibility with SQL joins and ACID.
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
attrs jsonb NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_products_attrs ON products USING gin (attrs);
INSERT INTO products (name, attrs) VALUES
('ThinkPad X1', '{"type":"laptop","ram_gb":32,"tags":["business","14inch"]}'),
('Pixel 9', '{"type":"phone","ram_gb":12,"tags":["android","5g"]}'),
('MacBook Air', '{"type":"laptop","ram_gb":16,"tags":["m3","13inch"]}');
SELECT name, attrs->>'ram_gb' AS ram, attrs->'tags' AS tags
FROM products
WHERE attrs @> '{"type":"laptop"}'
AND (attrs->>'ram_gb')::int >= 16;
| name | ram | tags |
|---|---|---|
| ThinkPad X1 | 32 | ["business", "14inch"] |
| MacBook Air | 16 | ["m3", "13inch"] |
@> containment + GINid + data jsonb for your whole app — no types, no constraints, no FKs; you bought MongoDB’s problems with none of its toolingattrs->>'status' — promote hot fields to real columnsjsonb is binary, deduplicated, and indexable — always prefer it over json. GIN + @> containment gives millisecond lookups over millions of semi-structured rows. Operators: -> (json), ->> (text), #>> (path), ? (key exists), jsonb_set() to update.Scenario: "show the 2 highest-paid employees in each department" — the classic interview question, solved the pro way.
CREATE TABLE employees (id int, name text, dept text, salary int);
INSERT INTO employees VALUES
(1,'Asha','Eng',180000),(2,'Vikram','Eng',165000),
(3,'Meera','Eng',150000),(4,'John','Sales',120000),
(5,'Priya','Sales',140000),(6,'Dev','Sales',110000);
SELECT * FROM (
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
) t WHERE rn <= 2;
| name | dept | salary | rn |
|---|---|---|---|
| Asha | Eng | 180000 | 1 |
| Vikram | Eng | 165000 | 2 |
| Priya | Sales | 140000 | 1 |
| John | Sales | 120000 | 2 |
rn = 1, delete the restGROUP BY dept + MAX(salary) joined back — returns multiple rows on salary ties and misses the runner-up entirelyROW_NUMBER() for RANK() (gaps on ties) or DENSE_RANK() (no gaps) depending on tie semantics. This family of window functions replaces 90% of painful GROUP BY gymnastics.ROW_NUMBER() picks a winner arbitrarily — and differently on each run. Why wrong: non-deterministic reports. Fix: add a tiebreaker (ORDER BY salary DESC, id) or use RANK() if ties should share a position.Scenario: finance dashboard — cumulative revenue and a 3-day moving average, straight from SQL, no app-side loops.
CREATE TABLE daily_sales (day date, revenue numeric);
INSERT INTO daily_sales VALUES
('2026-06-01',1000),('2026-06-02',1500),('2026-06-03',800),
('2026-06-04',2000),('2026-06-05',1200);
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day) AS running_total,
ROUND(AVG(revenue) OVER (
ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 0) AS ma_3day
FROM daily_sales;
| day | revenue | running_total | ma_3day |
|---|---|---|---|
| 2026-06-01 | 1000 | 1000 | 1000 |
| 2026-06-02 | 1500 | 2500 | 1250 |
| 2026-06-03 | 800 | 3300 | 1100 |
| 2026-06-04 | 2000 | 5300 | 1433 |
| 2026-06-05 | 1200 | 6500 | 1333 |
LAG(), moving averages for smoothing(SELECT SUM(...) WHERE day <= t.day) per row — O(n²), dies past a few thousand rowsROWS BETWEEN) compute per-row aggregates without collapsing rows. Also great: LAG()/LEAD() for day-over-day deltas, FIRST_VALUE() for baselines. Pushing analytics into the DB avoids shipping raw rows to the app.OVER (ORDER BY day) without a frame defaults to RANGE, which includes all peer rows with equal sort values — duplicate dates silently inflate your running total. Say ROWS BETWEEN ... explicitly when duplicates are possible. Also: you can’t put a window function in WHERE — wrap it in a subquery/CTE.Scenario: walk an employee → manager tree of any depth in a single query. Works for category trees, folder structures, bill of materials.
CREATE TABLE org (id int PRIMARY KEY, name text, manager_id int);
INSERT INTO org VALUES
(1,'CEO',NULL),(2,'VP Eng',1),(3,'VP Sales',1),
(4,'Eng Mgr',2),(5,'Dev A',4),(6,'Dev B',4);
WITH RECURSIVE tree AS (
SELECT id, name, manager_id, 0 AS depth, name::text AS path
FROM org WHERE manager_id IS NULL
UNION ALL
SELECT o.id, o.name, o.manager_id, t.depth + 1, t.path || ' → ' || o.name
FROM org o JOIN tree t ON o.manager_id = t.id
)
SELECT depth, path FROM tree ORDER BY path;
| depth | path |
|---|---|
| 0 | CEO |
| 1 | CEO → VP Eng |
| 2 | CEO → VP Eng → Eng Mgr |
| 3 | CEO → VP Eng → Eng Mgr → Dev A |
| 3 | CEO → VP Eng → Eng Mgr → Dev B |
| 1 | CEO → VP Sales |
a JOIN b JOIN c... — silently truncates level 6depth to cap runaway recursion. For huge, frequently-read trees consider the ltree extension.UNION ALL never de-duplicates, so it revisits nodes endlessly. Fix: cap with WHERE depth < 20, or use the CYCLE clause (PG 14+).Scenario: for every customer, fetch their 2 most recent orders. LATERAL is a correlated subquery that can return multiple rows and columns.
CREATE TABLE customers (id int PRIMARY KEY, name text);
CREATE TABLE orders (id int, customer_id int, total numeric, placed_at date);
INSERT INTO customers VALUES (1,'Acme'),(2,'Globex');
INSERT INTO orders VALUES
(10,1,500,'2026-06-01'),(11,1,900,'2026-06-20'),(12,1,300,'2026-05-10'),
(13,2,1200,'2026-06-15');
SELECT c.name, o.id AS order_id, o.total, o.placed_at
FROM customers c
CROSS JOIN LATERAL (
SELECT id, total, placed_at
FROM orders
WHERE customer_id = c.id
ORDER BY placed_at DESC LIMIT 2
) o;
| name | order_id | total | placed_at |
|---|---|---|---|
| Acme | 11 | 900 | 2026-06-20 |
| Acme | 10 | 500 | 2026-06-01 |
| Globex | 13 | 1200 | 2026-06-15 |
CROSS JOIN LATERAL jsonb_array_elements(...)CROSS JOIN LATERAL when parents may have zero children — rows vanish; use LEFT JOIN LATERAL ... ON true(customer_id, placed_at DESC) this does a tiny index scan per customer — often far faster than the window-function approach on big tables when the outer set is small. Use LEFT JOIN LATERAL ... ON true to keep customers with zero orders.CROSS JOIN LATERAL silently drops customers with zero orders — your “all customers” report loses rows and nobody notices. Fix: LEFT JOIN LATERAL (...) o ON true keeps them with NULLs.Scenario: search articles with stemming ("running" matches "run"), ranking, and an index — built into Postgres.
CREATE TABLE articles (
id int PRIMARY KEY,
title text,
body text,
tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')
) STORED
);
CREATE INDEX idx_articles_tsv ON articles USING gin(tsv);
INSERT INTO articles VALUES
(1,'Index tuning guide','How indexes speed up queries',DEFAULT),
(2,'Vacuum internals','Why vacuuming keeps tables fast',DEFAULT),
(3,'Query speed tips','Indexing strategies for speedy queries',DEFAULT);
SELECT id, title, ts_rank(tsv, q) AS rank
FROM articles, websearch_to_tsquery('english', 'index speed') q
WHERE tsv @@ q
ORDER BY rank DESC;
| id | title | rank |
|---|---|---|
| 3 | Query speed tips | 0.9524 |
| 1 | Index tuning guide | 0.6079 |
setweight)websearch_to_tsquery for anything typed by usersILIKE '%term%' on large tables — leading wildcard = full scan every searchwebsearch_to_tsquery accepts Google-style input safely. For fuzzy/typo matching add the pg_trgm extension with a GIN trigram index on ILIKE '%term%'.to_tsquery() — characters like & or ! make it throw syntax errors (a user typing “fish & chips” breaks your search). websearch_to_tsquery() exists precisely because it accepts anything safely. Also: querying without the tsv column (to_tsvector(body) @@ ...) works but can’t use the index unless the index is on that same expression.Scenario: 50M orders, 99% are 'completed', your app only ever polls the pending ones. Index just those.
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending'; -- indexes ~1% of rows
SELECT id, created_at FROM orders
WHERE status = 'pending' ORDER BY created_at LIMIT 10;
| QUERY PLAN |
|---|
| Limit (cost=0.14..8.32 rows=10) |
| -> Index Scan using idx_orders_pending on orders (actual time=0.021..0.045) |
WHERE status='pending', WHERE deleted_at IS NULLCREATE INDEX ON orders(status) for a 4-value column — huge index the planner mostly ignoresWHERE deleted_at IS NULL, WHERE processed = false, unenforced-yet uniqueness like CREATE UNIQUE INDEX ... WHERE active.WHERE status = $1 with a bind parameter may not qualify — the planner can’t prove $1 = 'pending'. Why wrong: your index exists but silently isn’t used. Check with EXPLAIN using the literal value.Scenario: a hot lookup query hitting the table millions of times/day. Put every needed column in the index so Postgres never touches the heap.
CREATE INDEX idx_users_email_inc ON users (email) INCLUDE (full_name, login_count);
EXPLAIN ANALYZE
SELECT full_name, login_count FROM users WHERE email = 'ravi@corp.com';
| QUERY PLAN |
|---|
| Index Only Scan using idx_users_email_inc on users (actual time=0.019..0.020 rows=1) |
| Heap Fetches: 0 |
Heap Fetches: 0 is the win — the answer comes entirely from the index. INCLUDE columns are payload-only: not sorted, not usable for filtering, so the index stays lean vs. a multi-column key. Keep the table well-vacuumed or the visibility map forces heap fetches anyway.Heap Fetches climbs and your “covering” index quietly degrades to a normal one. Also, every INCLUDE column bloats the index and slows writes — cover your hottest query, not every query.Scenario: infinite-scroll feed. OFFSET 100000 reads and throws away 100k rows every page. Keyset reads only what it returns.
SELECT id, title FROM posts
ORDER BY created_at DESC, id DESC
OFFSET 100000 LIMIT 20;
-- 480 ms, scans 100,020 rows
SELECT id, title FROM posts
WHERE (created_at, id) < ('2026-05-01 10:00', 88231)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 0.4 ms, scans 20 rows
| method | page 1 | page 5,000 | rows scanned @ p5000 |
|---|---|---|---|
| OFFSET/LIMIT | 0.4 ms | 480 ms | 100,020 |
| Keyset (seek) | 0.4 ms | 0.4 ms | 20 |
OFFSET 100000 anywhere users can paginate deepupdated_at — rows teleport between pages mid-scroll(created_at, id) as a cursor and seek past it with a row-value comparison — it maps to a single index range scan on (created_at DESC, id DESC). Always add the unique id tiebreaker or rows can be skipped/duplicated on equal timestamps.(updated_at, id) while rows are being updated and entries shift between pages mid-scroll.Scenario: 20 workers pulling jobs from one table. Without SKIP LOCKED they all fight for the same row; with it, each worker instantly grabs a different job.
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb,
status text DEFAULT 'queued',
run_at timestamptz DEFAULT now()
);
CREATE INDEX ON jobs (run_at) WHERE status = 'queued';
UPDATE jobs SET status = 'running'
WHERE id = (
SELECT id FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, payload;
| id | payload |
|---|---|
| 1043 | {"task": "send_email", "to": "blsraut@gmail.com"} |
SELECT a job then UPDATE it without locking — two workers grab the same job and your customer gets two emailsFOR UPDATE without SKIP LOCKED — 20 workers form a polite single-file queue, throughput of 1SKIP LOCKED skips rows other transactions hold instead of waiting — zero lock contention, perfect work distribution. This one clause is why many teams replace Redis/RabbitMQ with plain Postgres for job queues: you get transactional enqueue (job commits atomically with the business change that created it).'running' forever — SKIP LOCKED released the lock at crash, but your status column still says running. Why wrong: jobs silently vanish. Fix: a reaper query that re-queues stale running jobs (started_at < now() - interval '10 min'), or keep the lock held for the whole job instead of a status column.Scenario: sensor readings table — get the most recent reading per device. Postgres-only syntax, wonderfully terse.
CREATE TABLE readings (device_id int, temp numeric, at timestamptz);
INSERT INTO readings VALUES
(1, 21.5, '2026-07-03 09:00'),(1, 22.1, '2026-07-03 10:00'),
(2, 30.0, '2026-07-03 09:30'),(2, 29.4, '2026-07-03 09:45');
SELECT DISTINCT ON (device_id)
device_id, temp, at
FROM readings
ORDER BY device_id, at DESC;
| device_id | temp | at |
|---|---|---|
| 1 | 22.1 | 2026-07-03 10:00:00+00 |
| 2 | 29.4 | 2026-07-03 09:45:00+00 |
(key, sort_col DESC)GROUP BY device_id + MAX(at) joined back to get the row — extra scan, and duplicate rows on timestamp ties(device_id, at DESC).ORDER BY device_id, at DESC) — anything else is a syntax error. And if you want final output sorted by temp, you need an outer query; bolting it onto this ORDER BY breaks the “latest per device” logic.Scenario: one row per month with signups split by plan — a pivot report without CASE WHEN soup.
SELECT date_trunc('month', created_at)::date AS month,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE plan = 'free') AS free,
COUNT(*) FILTER (WHERE plan = 'pro') AS pro,
COUNT(*) FILTER (WHERE plan = 'enterprise') AS ent
FROM signups
GROUP BY 1 ORDER BY 1;
| month | total | free | pro | ent |
|---|---|---|---|---|
| 2026-05-01 | 420 | 301 | 98 | 21 |
| 2026-06-01 | 515 | 340 | 142 | 33 |
AVG(amount) FILTER (WHERE refunded) next to overall totalsSUM(CASE WHEN...) pyramids — works, but reviewers need a map and a flashlightagg() FILTER (WHERE ...) is SQL-standard, reads cleanly, and works with any aggregate — SUM, AVG, ARRAY_AGG. One table scan produces the whole pivot. Cleaner than SUM(CASE WHEN ... THEN 1 ELSE 0 END) and the intent is obvious to reviewers.AVG(x) FILTER (WHERE plan='pro') averages only matching rows — it is not “treat others as zero”. SUM(CASE WHEN plan='pro' THEN amount ELSE 0 END)/COUNT(*) answers a different question. Pick the semantics you actually mean; mixing them up skews every ratio on the dashboard.Scenario: chart shows daily orders, but days with zero orders vanish from GROUP BY output. Generate the calendar, then LEFT JOIN.
SELECT d::date AS day, COALESCE(COUNT(o.id), 0) AS orders
FROM generate_series('2026-06-28'::date, '2026-07-02'::date, '1 day') d
LEFT JOIN orders o ON o.placed_at::date = d
GROUP BY d ORDER BY d;
| day | orders |
|---|---|
| 2026-06-28 | 14 |
| 2026-06-29 | 0 |
| 2026-06-30 | 22 |
| 2026-07-01 | 0 |
| 2026-07-02 | 31 |
INSERT ... SELECT ... FROM generate_series(1, 1e6)o.placed_at::date = d — the cast kills index use; join on a half-open range insteadgenerate_series works with ints, dates, and timestamps — instant calendar/spine tables for reports, test data (INSERT ... SELECT ... FROM generate_series(1, 1000000)), and bucketing. The zero-rows problem disappears and charts stop lying.o.placed_at::date = d wraps the column in a cast, so a plain index on placed_at is unusable — the join becomes a scan per day. Fix: range join o.placed_at >= d AND o.placed_at < d + interval '1 day', which is index-friendly (and correct across time zones).Scenario: events table growing 50M rows/month. Partition by month: queries prune to one partition, old data drops instantly.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
type text,
at timestamptz NOT NULL
) PARTITION BY RANGE (at);
CREATE TABLE events_2026_06 PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE events_2026_07 PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
EXPLAIN SELECT count(*) FROM events WHERE at >= '2026-07-01';
| QUERY PLAN |
|---|
| Aggregate |
| -> Seq Scan on events_2026_07 events ← only 1 of 2 partitions scanned |
DROP old partitions instantlyDELETE for retention on a huge unpartitioned table — days of bloat and vacuum debtDROP TABLE events_2026_06 — instant, no bloat, no giant DELETE. Vacuum and indexes work per-partition (smaller, faster). Rule of thumb: consider partitioning past ~100M rows or when you need time-based retention. Use pg_partman to auto-create partitions.id across partitions. And inserting a row with no matching partition fails outright: create partitions ahead of time (pg_partman) or a DEFAULT partition as a safety net.Scenario: a revenue-summary query joins 5 tables and takes 30s. The dashboard hits it every page load. Compute once, read instantly.
CREATE MATERIALIZED VIEW mv_monthly_revenue AS
SELECT date_trunc('month', placed_at)::date AS month,
SUM(total) AS revenue, COUNT(*) AS orders
FROM orders GROUP BY 1;
CREATE UNIQUE INDEX ON mv_monthly_revenue (month);
-- refresh without blocking readers (needs the unique index):
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_revenue;
| month | revenue | orders |
|---|---|---|
| 2026-05-01 | 1,284,300 | 8,911 |
| 2026-06-01 | 1,502,750 | 10,204 |
CONCURRENTLY keeps the view queryable during refresh. It's a cache the database keeps honest — you can index it like any table.REFRESH MATERIALIZED VIEW without CONCURRENTLY takes an exclusive lock — every dashboard query blocks until the 30s rebuild finishes; with CONCURRENTLY it needs a unique index and roughly doubles the work. Also the data is stale between refreshes — wrong tool for “must be current” numbers like inventory.Scenario: SaaS app, many tenants in one table. One forgotten WHERE tenant_id = ? in app code leaks data across customers. RLS makes leaks impossible.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::int);
-- app sets tenant per connection/transaction:
SET app.tenant_id = '7';
SELECT id, tenant_id, amount FROM invoices; -- no WHERE clause!
| id | tenant_id | amount |
|---|---|---|
| 301 | 7 | 4,500 |
| 317 | 7 | 1,200 |
WHERE tenant_id = ? forever — one code review miss is a data breachFORCE ROW LEVEL SECURITY if the owner connects too.Scenario: posts have tags. For simple cases an array column with a GIN index beats a three-table many-to-many.
CREATE TABLE posts (id int PRIMARY KEY, title text, tags text[]);
CREATE INDEX ON posts USING gin(tags);
INSERT INTO posts VALUES
(1,'Tuning autovacuum', '{postgres,performance}'),
(2,'Intro to JSONB', '{postgres,json}'),
(3,'React hooks', '{javascript,react}');
SELECT title FROM posts WHERE tags @> '{postgres}';
SELECT tag, COUNT(*) FROM posts, unnest(tags) tag
GROUP BY tag ORDER BY 2 DESC;
| tag | count |
|---|---|
| postgres | 2 |
| performance | 1 |
| json | 1 |
| javascript | 1 |
| react | 1 |
@>'a,b,c' — LIKE-parsing hell, no index, no types@> (contains) and && (overlaps) queries are fast; unnest() flattens arrays back to rows whenever you need relational operations. Great fit when tags have no metadata of their own. The moment tags need attributes (color, owner, counts) — graduate to a proper join table.'postgers' lives forever with zero referential integrity. And updating one tag rewrites the whole row (MVCC), so hot, frequently-edited arrays bloat the table. Tags with metadata or heavy churn → join table.Scenario: you always search users by lowercased email and report on order line totals. Let the DB maintain the derived values.
CREATE TABLE line_items (
qty int NOT NULL,
unit_price numeric NOT NULL,
total numeric GENERATED ALWAYS AS (qty * unit_price) STORED
);
INSERT INTO line_items (qty, unit_price) VALUES (3, 199.99), (2, 49.50);
| qty | unit_price | total |
|---|---|---|
| 3 | 199.99 | 599.97 |
| 2 | 49.50 | 99.00 |
CREATE INDEX ON users (lower(email))) make WHERE lower(email) = ... an index scan with no extra column at all.now(), random(), or referencing another generated column all fail. And since the value is STORED, adding one to a huge table rewrites the entire table — plan that migration off-peak.Scenario: compliance asks "who changed this price and when?" A trigger writes every change to an append-only audit table, regardless of which app made the change.
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tbl text, op text,
old_row jsonb, new_row jsonb,
by_user text DEFAULT current_user,
at timestamptz DEFAULT now()
);
CREATE FUNCTION audit_trg() RETURNS trigger AS $$
BEGIN
INSERT INTO audit_log (tbl, op, old_row, new_row)
VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD), to_jsonb(NEW));
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER products_audit
AFTER UPDATE OR DELETE ON products
FOR EACH ROW EXECUTE FUNCTION audit_trg();
| tbl | op | old_row → new_row | by_user | at |
|---|---|---|---|---|
| products | UPDATE | {"price": 999} → {"price": 899} | app_rw | 2026-07-03 11:02 |
to_jsonb trigger reused across all audited tablesto_jsonb(OLD/NEW) means one generic trigger serves every table. For heavy write loads consider logical decoding (CDC) instead to keep triggers off the hot path.Scenario: a query is slow. Before touching anything, see what the planner actually did — estimated vs actual rows is where the truth lives.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'shipped';
| plan (before) |
|---|
| Seq Scan on orders (cost=0.00..189234 rows=1 width=88) (actual time=812.4..812.5 rows=3100) |
| Filter: (customer_id = 42 AND status = 'shipped'); Rows Removed by Filter: 4,996,900 |
| Buffers: shared read=98211 · Execution Time: 812.6 ms |
CREATE INDEX ON orders (customer_id, status);
ANALYZE orders; -- refresh planner statistics
| plan (after) |
|---|
| Index Scan using orders_customer_id_status_idx (actual time=0.03..1.9 rows=3100) |
| Buffers: shared hit=412 · Execution Time: 2.1 ms |
EXPLAIN ANALYZE UPDATE ... outside a BEGIN/ROLLBACK — it really executesrows=1 estimated vs rows=3100 actual (stale stats → run ANALYZE), huge "Rows Removed by Filter" (missing index), Seq Scan on big tables in point lookups, and BUFFERS read ≫ hit (cold cache / too much I/O). 380× here from one composite index.EXPLAIN ANALYZE actually executes the statement — running it on an UPDATE or DELETE changes real data. Wrap it: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;. Plain EXPLAIN (no ANALYZE) is always safe — it only plans.Scenario: "the database is slow." Don't guess — rank every query shape by total time consumed across all executions.
-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION pg_stat_statements;
SELECT substring(query, 1, 45) AS query,
calls,
round(total_exec_time::numeric, 0) AS total_ms,
round(mean_exec_time::numeric, 1) AS avg_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 3;
| query | calls | total_ms | avg_ms |
|---|---|---|---|
| SELECT * FROM orders WHERE customer_id = $1 | 1,240,551 | 9,821,400 | 7.9 |
| UPDATE carts SET items = $1 WHERE id = $2 | 310,922 | 2,114,080 | 6.8 |
| SELECT ... FROM reports_heavy_join ... | 84 | 1,932,000 | 23,000.0 |
total_exec_time every timemean_exec_time alone — surfaces rare 20s reports over the real budget-eaterstotal_exec_time, not mean — a 8ms query called a million times hurts more than one 23s report. This is the single highest-leverage tuning tool in Postgres: fix the top 3 rows, and the "slow database" is usually cured.SELECT pg_stat_statements_reset(); then measure a representative window. Also total_exec_time excludes network and client time — a “fast” query can still feel slow to users fetching 100k rows.Scenario: meeting-room booking. App-level overlap checks always race under concurrency. Let the database physically reject overlaps.
CREATE EXTENSION btree_gist;
CREATE TABLE bookings (
room int,
during tstzrange,
EXCLUDE USING gist (room WITH =, during WITH &&)
);
INSERT INTO bookings VALUES (1, '[2026-07-03 10:00, 2026-07-03 11:00)'); -- OK
INSERT INTO bookings VALUES (1, '[2026-07-03 10:30, 2026-07-03 11:30)'); -- ?
| result |
|---|
| ERROR: conflicting key value violates exclusion constraint "bookings_room_during_excl" |
| DETAIL: Key (room, during)=(1, ["2026-07-03 10:30","2026-07-03 11:30")) conflicts with existing key |
tstzrange, daterange, numrange) + the && overlap operator handle scheduling, pricing tiers, and versioned records. Also stack CHECK constraints (CHECK (price >= 0)) and foreign keys — constraints are the cheapest bugs you'll never have to fix.btree_gist extension for the room WITH = part — without it, cryptic operator-class errors. Mind your range bounds: [10:00, 11:00) half-open ranges make back-to-back bookings legal; use [] inclusive bounds and 10:00–11:00 vs 11:00–12:00 suddenly “overlap”.Scenario: 5 app servers run the same nightly billing job on cron. Only one should execute. No Zookeeper, no Redis — Postgres is the lock service.
-- each server tries; only one wins (non-blocking):
SELECT pg_try_advisory_lock(hashtext('billing-job')) AS acquired;
-- ... do the work if acquired = true, then:
SELECT pg_advisory_unlock(hashtext('billing-job'));
| server | acquired | action |
|---|---|---|
| app-1 | true | runs the billing job |
| app-2 | false | skips, exits quietly |
| app-3 | false | skips, exits quietly |
locks table with boolean flags — a crashed holder leaves it stuck until a human noticesFOR UPDATE and #11 are forpg_try_* variants return immediately instead of blocking. Session-scoped locks vanish if the holder crashes/disconnects — automatic failure recovery. Also ideal for "one migration runner at a time" and singleton schedulers.pg_advisory_lock(feature_id, item_id). And session-level locks stack: lock twice, you must unlock twice — a retry loop that re-acquires without releasing leaks locks until disconnect.Scenario: invalidate app caches or wake up workers the instant a row changes — pushed by the database itself, no polling.
-- session A (worker):
LISTEN order_events;
-- trigger publishes on every new order:
CREATE FUNCTION notify_order() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('order_events',
json_build_object('id', NEW.id, 'total', NEW.total)::text);
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER t_notify AFTER INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order();
| event |
|---|
| Asynchronous notification "order_events" with payload "{"id": 5012, "total": 249.00}" received from server process 8841 |
Scenario: import a 10M-row CSV. Row-by-row INSERTs take an hour; COPY streams it in minutes.
-- server-side file, or from client via \copy in psql:
\copy sales(sold_at, sku, qty, amount) FROM 'sales.csv' WITH (FORMAT csv, HEADER);
-- export a query result just as easily:
\copy (SELECT * FROM mv_monthly_revenue) TO 'revenue.csv' WITH (FORMAT csv, HEADER);
| method | time | relative |
|---|---|---|
| Single-row INSERTs (autocommit) | ~62 min | 1× |
| Multi-row INSERT (1000/batch) | ~4 min | 15× |
| COPY | ~35 sec | ~106× |
text table, then validate/cast with SQL\copy (SELECT ...) TO 'out.csv'save() in a loop for a million rows — hours instead of secondsmaintenance_work_mem, and run ANALYZE when done. Most driver libs expose it (psycopg copy, JDBC CopyManager).text table and cast/clean with SQL afterwards.Scenario: "deleted" users must stay for audit, their emails must be reusable, and active-user queries must stay fast. Partial unique index solves all three.
ALTER TABLE users ADD COLUMN deleted_at timestamptz;
-- uniqueness only among the living:
CREATE UNIQUE INDEX uq_users_email_active ON users (email)
WHERE deleted_at IS NULL;
-- "delete":
UPDATE users SET deleted_at = now() WHERE id = 42;
-- same email can register again:
INSERT INTO users (email, full_name) VALUES ('ravi@corp.com', 'Ravi K'); -- OK ✅
| id | deleted_at | |
|---|---|---|
| 42 | ravi@corp.com | 2026-07-03 11:30:00+00 |
| 57 | ravi@corp.com | NULL |
is_deleted boolean + plain UNIQUE(email) — a deleted user blocks that email foreverCREATE VIEW active_users AS SELECT ... WHERE deleted_at IS NULL) so app code can't forget the filter, and partial-index your hot queries the same way (#08).deleted_at IS NULL; one miss and “deleted” users appear in a report. And foreign keys still point at soft-deleted rows: deleting a user doesn’t cascade-hide their comments. Route all reads through the active_users view, or use RLS (#17) to enforce the filter globally.Scenario: transferring money between accounts. READ COMMITTED (the default) permits anomalies under concurrency that stricter levels prevent.
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- on conflict: ERROR 40001 serialization_failure → retry the transaction
| level | prevents | use when |
|---|---|---|
| READ COMMITTED (default) | dirty reads | general CRUD; each statement sees latest committed data |
| REPEATABLE READ | + non-repeatable reads, snapshot for whole txn | reports/exports needing one consistent view |
| SERIALIZABLE | + all anomalies (as if run one-by-one) | money movement, inventory — with a retry loop |
SET balance = balance - 100 is already race-freeSELECT ... FOR UPDATE when you must read-then-write specific rowsSELECT ... FOR UPDATE to lock specific rows, and single-statement atomic updates (SET balance = balance - 100) which are already race-free.Scenario: a heavily-updated table gets slower every week even though row count is flat. MVCC keeps old row versions ("dead tuples") until vacuum reclaims them.
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
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 3;
| relname | n_live_tup | n_dead_tup | dead_pct | last_autovacuum |
|---|---|---|---|---|
| carts | 2,100,000 | 1,890,000 | 47.4 | 2026-06-28 03:11 |
| sessions | 890,000 | 120,400 | 11.9 | 2026-07-03 02:40 |
| orders | 5,000,000 | 41,200 | 0.8 | 2026-07-03 01:15 |
n_dead_tup weekly; tune scale factor per hot tableALTER TABLE hot SET (autovacuum_vacuum_scale_factor = 0.01)idle_in_transaction_session_timeout globallyVACUUM FULL during business hours — exclusive lock, table offlinedead_pct + stale last_autovacuum = autovacuum can't keep up. Fixes: per-table tuning (ALTER TABLE carts SET (autovacuum_vacuum_scale_factor = 0.01)), shorter transactions (long ones block cleanup), and pg_repack for online debloating. Never disable autovacuum — tune it.idle in transaction connection can hold back vacuum for the whole database. Set idle_in_transaction_session_timeout.Scenario: traffic spike, app opens 800 connections, database melts at 40% idle CPU. Each Postgres connection is a full OS process — they're expensive even when idle.
SELECT state, count(*) FROM pg_stat_activity
GROUP BY state ORDER BY 2 DESC;
| state | count |
|---|---|
| idle | 742 |
| active | 31 |
| idle in transaction | 9 |
max_connections to 5000 to “fix” connection errors — more idle processes, worse latencytransaction pooling mode in front: thousands of client connections multiplex over ~30–60 real server connections (rule of thumb: pool ≈ 2–4 × CPU cores). Result: lower latency, no too many connections errors, headroom for spikes. Transaction mode caveat: no session state (SET, LISTEN, prepared statements need care).SET, LISTEN/NOTIFY (#25), session advisory locks (#24), server-side prepared statements. Why wrong: features work in dev (direct connection), die in prod (pooled). Route session-stateful workloads through a separate session-mode pool.Scenario: “find users NOT in the banned list” returns zero rows and nobody knows why. NULL is the culprit — SQL logic has three values: true, false, and unknown.
SELECT NULL = NULL; -- NULL (not true!)
SELECT 5 <> NULL; -- NULL (not true!)
-- the killer: NOT IN with a NULL in the list
SELECT * FROM users
WHERE id NOT IN (SELECT banned_id FROM bans); -- bans has one NULL → 0 rows, always
-- the fixes
WHERE id IS DISTINCT FROM other_id -- NULL-safe comparison
WHERE NOT EXISTS (SELECT 1 FROM bans b WHERE b.banned_id = users.id)
COALESCE(discount, 0) -- default a NULL
NULLIF(total, 0) -- avoid division by zero: x / NULLIF(y,0)
| expression | result | surprised? |
|---|---|---|
| NULL = NULL | NULL | most people |
| NULL IS NULL | true | — |
| 1 IS DISTINCT FROM NULL | true | the NULL-safe ≠ |
| x NOT IN (1, NULL) | never true | everyone, once |
| COUNT(col) vs COUNT(*) | skips NULLs vs counts rows | silent report bugs |
| 'a' || NULL | NULL (concat vanishes) | use concat() or coalesce |
NOT EXISTS for “has none” checks — NULL-proof and well-plannedIS DISTINCT FROM when either side may be NULLCOALESCE to make “NULL means zero” explicitNOT IN (subquery) where the subquery can yield NULL — returns nothing, foreverWHERE col != NULL — always empty; it’s IS NOT NULLNOT EXISTS over NOT IN (NULL-proof and usually a better plan), IS DISTINCT FROM for NULL-safe comparisons, and COUNT(*) unless you specifically want to skip NULLs. Also note: unique indexes allow multiple NULLs by default (PG 15+ offers NULLS NOT DISTINCT), and ORDER BY col DESC puts NULLs first — add NULLS LAST.AVG(rating) over {5, NULL, NULL, 1} is 3, not 2 — your “average rating” ignores everyone who didn’t rate. Decide explicitly whether NULL means “exclude” or “treat as zero” and encode it with COALESCE.Scenario: app servers in UTC, office in Pune, users worldwide. With plain timestamp, “9:00” means nothing — 9:00 where? Daylight-saving bugs ship twice a year, on schedule.
timestamptz is a real moment — “the rocket launched” — the same instant for everyone on Earth, displayed in each viewer’s local clock. Plain timestamp is just wall-clock digits with no city attached: “9:00” written on a sticky note.CREATE TABLE meetings (title text, starts_at timestamptz);
INSERT INTO meetings VALUES ('Standup', '2026-07-03 09:00:00+05:30'); -- stored as UTC instant
-- each client sees it in their own zone:
SET timezone = 'Asia/Kolkata';
SELECT starts_at FROM meetings; -- 2026-07-03 09:00:00+05:30
SET timezone = 'America/New_York';
SELECT starts_at FROM meetings; -- 2026-07-02 23:30:00-04
-- “what calendar day was that in Pune?” — be explicit:
SELECT (starts_at AT TIME ZONE 'Asia/Kolkata')::date;
| viewer timezone | same stored instant renders as |
|---|---|
| Asia/Kolkata | 2026-07-03 09:00:00+05:30 |
| America/New_York | 2026-07-02 23:30:00-04 |
| UTC | 2026-07-03 03:30:00+00 |
timestamptz for every “when did it happen” columnAT TIME ZONE 'Asia/Kolkata' for day-bucketed reportstimestamp for events — “9:00” with no city attached; DST bugs twice a year'IST' — India? Israel? Ireland? Always 'Asia/Kolkata'timestamptz stores a single unambiguous UTC instant (same 8 bytes as timestamp — zero storage cost); conversion happens only at display time. Rules of thumb: columns = timestamptz, group-by-day reports = convert first (AT TIME ZONE 'Asia/Kolkata'), future scheduled events in local civil time (“every day 9am Pune”) = store the zone name in its own column and compute.AT TIME ZONE is a footgun: applied to a timestamptz it strips the zone (returns naive timestamp); applied to a naive timestamp it adds one. Applying it twice round-trips. And never use zone abbreviations like 'IST' — ambiguous (India? Israel? Ireland!) — always full names like 'Asia/Kolkata'.Scenario: add an index to a 200M-row table on a live system. Plain CREATE INDEX blocks all writes for 20 minutes — that’s an outage with extra steps.
-- 1. Indexes: CONCURRENTLY builds without blocking writes (can't run in a transaction)
CREATE INDEX CONCURRENTLY idx_orders_email ON orders (email);
-- 2. Always set a lock timeout so migrations fail fast instead of queueing behind traffic
SET lock_timeout = '3s';
-- 3. NOT NULL on a big table, without the full-table-scan lock:
ALTER TABLE orders ADD CONSTRAINT orders_email_nn
CHECK (email IS NOT NULL) NOT VALID; -- instant: new rows only
ALTER TABLE orders VALIDATE CONSTRAINT orders_email_nn; -- scans with weak lock
-- 4. ADD COLUMN with a constant DEFAULT is instant since PG 11 — no rewrite:
ALTER TABLE orders ADD COLUMN source text DEFAULT 'web';
| operation | blocks writes? | duration on 200M rows |
|---|---|---|
| CREATE INDEX | yes — all writes | ~20 min outage |
| CREATE INDEX CONCURRENTLY | no | ~35 min, invisible |
| ADD COLUMN ... DEFAULT 'web' (PG11+) | instant | milliseconds |
| ALTER COLUMN TYPE int → bigint | yes — full rewrite | needs a multi-step dance |
CREATE INDEX CONCURRENTLY — always, on any live tableSET lock_timeout = '3s' before every migrationNOT VALID + VALIDATE CONSTRAINT to split enforce/verifyCREATE INDEX on a 200M-row table at noon — a 20-minute write outageCONCURRENTLY for every index on a live table, lock_timeout so a stuck migration aborts in seconds instead of queueing every query in the database behind it, and NOT VALID + VALIDATE to split “enforce for new rows” from “verify old rows”.CREATE INDEX CONCURRENTLY leaves an INVALID index behind — check \d and drop it before retrying.Scenario: the admin dashboard shows “total users” and now takes 40 seconds, because exact COUNT(*) must visit every live row (MVCC — no maintained global counter exists).
-- 1. Planner estimate: instant, usually within ~1% on analyzed tables
SELECT reltuples::bigint AS estimate
FROM pg_class WHERE relname = 'users';
-- 2. Estimate for any filtered query — parse it from EXPLAIN:
EXPLAIN (FORMAT json) SELECT * FROM users WHERE country = 'IN';
-- → Plan.“Plan Rows”: 1,204,117
-- 3. Exact count, but only when someone truly needs it
SELECT count(*) FROM users;
| method | result | time |
|---|---|---|
| pg_class.reltuples | 18,204,000 | 0.2 ms |
| EXPLAIN row estimate (filtered) | 1,204,117 | 1.1 ms |
| exact COUNT(*) | 18,203,417 | 41,300 ms |
COUNT(*) on every admin-page load of a 100M-row tablereltuples estimates — it drifts with vacuum timingreltuples is only as fresh as the last ANALYZE/VACUUM — on a table with heavy churn and lazy autovacuum it can drift badly (and reads 0 on a never-analyzed table). Don’t use estimates for anything with money or limits attached. And COUNT(col) ≠ COUNT(*): the former skips NULLs (#31).Scenario: a years-old table with no unique constraint on email. Find the dupes, keep the oldest row, delete the rest, and make recurrence impossible.
SELECT email, COUNT(*) FROM users
GROUP BY email HAVING COUNT(*) > 1;
| count | |
|---|---|
| ravi@corp.com | 3 |
| asha@corp.com | 2 |
DELETE FROM users WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at, id) rn
FROM users) t
WHERE rn > 1);
CREATE UNIQUE INDEX CONCURRENTLY uq_users_email ON users (email);
ORDER BY created_at, id — same survivor every runemail IN (dupes) — that deletes the keepers tooROW_NUMBER() OVER (PARTITION BY ...) marks survivors (rn = 1) and victims (rn > 1) in one pass. The unique index converts a recurring data-quality chore into a database guarantee — future dupes fail loudly at insert time, where the bug actually is.UNIQUE NULLS NOT DISTINCT). And rows referenced by foreign keys can’t just be deleted — re-point children to the keeper first.Scenario: deleting a customer. What happens to their orders? That’s a business decision, and the FK clause is where you encode it.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
total numeric
);
-- Postgres does NOT index FK columns automatically. Do it yourself:
CREATE INDEX ON orders (customer_id);
| clause | DELETE FROM customers WHERE id=7 … | fits when |
|---|---|---|
| RESTRICT / NO ACTION | fails if orders exist | financial data — never lose orders (safe default) |
| CASCADE | deletes the orders too | true child data: cart items, session rows |
| SET NULL | orders survive, customer_id → NULL | optional relationships: “assigned agent left” |
DELETE FROM customers sequential-scans the entire orders table to check references — a 1-row delete takes minutes and locks pile up behind it. Postgres indexes the referenced side (the PK), never the referencing side.Scenario: an order status column must only ever hold known values. Three tools, three trade-off profiles — veterans pick by how often the list changes.
-- 1. CHECK: simple, alterable
status text NOT NULL CHECK (status IN ('pending','paid','shipped'))
-- 2. ENUM: typed, ordered, compact
CREATE TYPE order_status AS ENUM ('pending','paid','shipped');
-- 3. Lookup table: values are data
CREATE TABLE statuses (code text PRIMARY KEY, label text, sort_order int);
status text NOT NULL REFERENCES statuses(code)
| CHECK | ENUM | lookup table | |
|---|---|---|---|
| add a value | ALTER constraint | ALTER TYPE ADD VALUE | plain INSERT |
| remove/rename | easy | painful (recreate type) | easy |
| metadata per value | no | no | yes (label, color, sort) |
| best for | stable short lists | truly fixed sets (weekdays) | business-managed lists |
text — six months later: 'paid', 'Paid', 'PAID ', 'payed'status int) with meanings documented only in a wikiALTER TYPE ... ADD VALUE can’t run inside a transaction block on older versions, and you can never DROP an enum value — teams end up with zombie values like 'shipped_old_do_not_use'. CHECK constraint edits take a brief exclusive lock — use NOT VALID + VALIDATE (#33) on big tables.Scenario: choosing PKs for a new system. Sequential bigints are fast but guessable and awkward across services; random UUIDs shred index locality. UUIDv7 threads the needle.
-- workhorse default:
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
-- distributed / client-generated / non-guessable (PG 18: uuidv7(); earlier: extension or app-side):
id uuid PRIMARY KEY DEFAULT uuidv7()
| bigint identity | UUIDv4 | UUIDv7 | |
|---|---|---|---|
| size | 8 B | 16 B | 16 B |
| insert locality (btree) | append-only, cache-friendly | random — page splits everywhere | append-mostly |
| guessable / leaks volume | yes (/orders/10041) | no | no (leaks rough time) |
| generate in client/offline | no | yes | yes |
serial in new code — GENERATED ALWAYS AS IDENTITY is the standard, safer formORDER BY uuid_v7_id as a precise event timeline — the timestamp is coarse; keep a real created_at.Scenario: “customers who have at least one order.” Three phrasings, one intent — but JOIN multiplies rows, and NOT IN has a NULL trapdoor (#31).
-- ✅ EXISTS: stops at first match, never duplicates
SELECT name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- ⚠️ JOIN: a customer with 500 orders appears 500 times
SELECT DISTINCT c.name FROM customers c JOIN orders o ON o.customer_id = c.id;
-- ✅ anti-join: customers with NO orders
SELECT name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
| phrasing | rows for “Acme” | NULL-safe negation? |
|---|---|---|
| EXISTS | 1 | NOT EXISTS: yes |
| IN | 1 | NOT IN: breaks on NULLs |
| JOIN | 500 (needs DISTINCT) | needs LEFT JOIN ... IS NULL |
status IN ('a','b')NOT IN (SELECT nullable_col ...) — one NULL, zero results, no error (#31)COUNT(*) > 0 to test existence — counts everything to answer a yes/noSELECT 1 vs SELECT * inside EXISTS makes zero performance difference — the subquery’s select list is ignored. The thing that actually matters: an index on the probed column (orders.customer_id — see #36’s un-indexed-FK trap).Scenario: users type “jhon smit” and expect to find John Smith. Full-text search (#07) matches words, not typos — trigram similarity handles misspellings and mid-word substrings.
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING gin (full_name gin_trgm_ops);
-- leading-wildcard ILIKE now uses the index (impossible with btree):
SELECT full_name FROM users WHERE full_name ILIKE '%smit%';
-- typo-tolerant ranking:
SELECT full_name, similarity(full_name, 'jhon smit') AS score
FROM users
WHERE full_name % 'jhon smit' -- % = "similar enough" operator
ORDER BY score DESC LIMIT 3;
| full_name | score |
|---|---|
| John Smith | 0.57 |
| Jon Smythe | 0.38 |
| Joan Smart | 0.31 |
ILIKE '%x%' searches indexable without app changesILIKE, % similarity, and even regex matching. Tune the cutoff with SET pg_trgm.similarity_threshold = 0.4.similarity() in the SELECT doesn’t use the index — only the % operator and LIKE/ILIKE patterns do; keep % in the WHERE clause.Scenario: archive expired sessions — move rows from one table to another with no window where they exist in both or neither. One statement, fully atomic.
WITH moved AS (
DELETE FROM sessions
WHERE expires_at < now()
RETURNING *
)
INSERT INTO sessions_archive SELECT * FROM moved
RETURNING id;
| result |
|---|
| INSERT 0 4218 — 4,218 sessions moved, atomically |
UPDATE ... RETURNING * to get the final row without a follow-up SELECTRETURNING makes every write also a read — no second round-trip, no race between “write it” and “read it back”. Writable CTEs chain writes into one atomic statement: move, split, fan-out, all-or-nothing.sessions_archive will not see the rows the first one just inserted. Order of execution between independent CTEs isn’t guaranteed either; chain them by reference (FROM moved), not by assumption.Scenario: three services and two BI tools query your tables directly. Every rename breaks someone. Put a view in between: the view is the promise, the tables are the implementation.
CREATE VIEW api_customers AS
SELECT id,
full_name AS name,
email,
created_at::date AS member_since
FROM customers
WHERE deleted_at IS NULL; -- soft-delete filter (#27) baked in
-- later: rename the real column; repair the contract in the same transaction
BEGIN;
ALTER TABLE customers RENAME COLUMN full_name TO display_name;
CREATE OR REPLACE VIEW api_customers AS
SELECT id, display_name AS name, email, created_at::date AS member_since
FROM customers WHERE deleted_at IS NULL;
COMMIT; -- consumers never saw a thing
SELECT * views — the column list freezes at creation; new table columns silently don’t appearCREATE OR REPLACE VIEW can only add columns at the end — renaming or retyping an existing view column requires DROP + CREATE, which cascades to dependent views. Map your view dependency graph before refactoring (\d+ or pg_depend), or the “safe” rename topples a tower you forgot existed.Scenario: backfill a new column on 80M rows. One UPDATE statement = hours-long transaction, gigabytes of WAL, replication lag, and a vacuum mountain. Chunk it.
-- repeat until 0 rows affected:
WITH batch AS (
SELECT id FROM orders
WHERE normalized_email IS NULL
LIMIT 5000
FOR UPDATE SKIP LOCKED
)
UPDATE orders o SET normalized_email = lower(trim(email))
FROM batch WHERE o.id = batch.id;
-- commit, small sleep, repeat (partial index on the WHERE keeps each pick instant, #08)
| one giant UPDATE | 5k chunks | |
|---|---|---|
| longest row locks held | hours | milliseconds |
| replication lag | massive spike | smooth |
| vacuum debt | 80M dead rows at once | spread out, autovacuum keeps up |
| crash at 90%? | everything rolls back | progress kept, resume where left off |
IS NULL) — safe to re-run anytimeLIMIT-without-index version quietly degrades: each pass scans further to find unprocessed rows, so batch 1 is fast and batch 900 crawls. Partial index on the work predicate (WHERE normalized_email IS NULL) keeps every batch O(chunk). Also add SKIP LOCKED so the backfill never fights production writes.Scenario: someone runs DELETE FROM orders without a WHERE at 14:32. A nightly dump loses the whole day. Point-in-time recovery rewinds to 14:31.
-- logical dump: portable, per-database, great < ~100GB
pg_dump -Fc mydb > mydb.dump # custom format: compressed, parallel-restorable
pg_restore -j 8 -d mydb_restored mydb.dump
-- physical + WAL archiving (PITR): base backup + every change since
pgbackrest --stanza=main backup # or wal-g / barman
pgbackrest --stanza=main restore --type=time "--target=2026-07-03 14:31:00"
| pg_dump | PITR (base + WAL) | |
|---|---|---|
| restore granularity | backup moment only | any second |
| data loss window | up to 24h | seconds–minutes of WAL |
| works across PG versions | yes | same major version |
| 2TB database | impractical | designed for it |
archive_command failures like production errors, because they are. And a plain-SQL pg_dump of a big DB restores single-threaded for hours; always use -Fc so pg_restore -j can parallelize.Scenario: the web app connects as a superuser “because it works”. One SQL injection now owns every database, can read every table, and can drop them too. Ten minutes of GRANTs fixes this forever.
-- app role: exactly CRUD, nothing else
CREATE ROLE app_rw LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA public TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_rw;
-- future tables get the same grants automatically:
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
-- read-only role for BI, analysts, dashboards
CREATE ROLE app_ro LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA public TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro;
| role | SELECT | write rows | DROP TABLE | create roles |
|---|---|---|---|---|
| app_rw (web app) | ✓ | ✓ | ✗ | ✗ |
| app_ro (BI tools) | ✓ | ✗ | ✗ | ✗ |
| migrator (CI only) | ✓ | ✓ | ✓ (DDL owner) | ✗ |
ALTER DEFAULT PRIVILEGES so new tables don’t arrive permission-brokenpostgres/superuser — injection = total loss; also silently bypasses RLS (#17)GRANT ... ON ALL TABLES covers existing tables only — the next migration adds a table and the app gets “permission denied” at 2am. That’s what ALTER DEFAULT PRIVILEGES is for — but note it applies to objects created by the role that ran it: run it as your migrator role, or it silently does nothing useful.Scenario: WHERE city = 'Pune' AND pincode = '411001' is estimated at 40 rows but returns 120,000 — the planner picked a nested loop and the query takes minutes. Why? It assumed the columns are independent.
CREATE STATISTICS stat_city_pin (dependencies)
ON city, pincode FROM addresses;
ANALYZE addresses;
| estimated rows | actual rows | plan chosen | |
|---|---|---|---|
| before | 40 | 120,000 | Nested Loop (catastrophic) |
| after | 108,400 | 120,000 | Hash Join (correct) |
ndistinct for GROUP BY estimates, mcv for common combosenable_nestloop=off) — punishes every other queryWHERE lower(city) = ... or expressions won’t use them (expression statistics exist separately in PG 14+). And they’re only as fresh as your last ANALYZE — a big data load followed by no ANALYZE means the planner is navigating with last month’s map.Answer 5 questions per round — your progress and score are saved in this browser, so you can come back anytime and continue until all 200 are done. Score: 0 / 0