PostgreSQL · Field Guide · 46 Use Cases

PostgreSQL Pro Playbook

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.

Writing Data

01UPSERT — INSERT ... ON CONFLICT

Scenario: syncing user profiles from an external system — insert if new, update if the email already exists. No race conditions, one statement.

Analogy: Like a hotel front desk: “if you already have a reservation I’ll update it, otherwise I’ll create one” — decided in a single visit, so two clerks can never accidentally create duplicate bookings for the same guest.
Setup
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()
);
Query
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;
Output
idemaillogin_count
42ravi@corp.com3

✅ Do — good use cases

  • Idempotent APIs: retrying a “create user” request twice is harmless
  • Counters: ON CONFLICT DO UPDATE SET views = t.views + 1
  • Nightly syncs from external systems — insert new, refresh existing

❌ Don’t — anti-patterns

  • SELECT, then if found: UPDATE else: INSERT in app code — two requests race, one dies with duplicate-key
  • UPSERT as a bulk-import tool for millions of rows — use COPY into a staging table + one merge statement instead
Pattern & benefit: Replaces the buggy "SELECT then INSERT-or-UPDATE" dance. Atomic — safe under heavy concurrency, no unique-violation retries. EXCLUDED 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.
Gotcha — why it goes wrong: 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.

02JSONB — schemaless data with real indexes

Scenario: product catalog where every category has different attributes. You want NoSQL flexibility with SQL joins and ACID.

Analogy: A filing cabinet where every folder has a flexible “misc” pouch: structured drawers for the common fields, a pouch for the odd ones — and the card catalog (GIN index) can still find things inside the pouches.
Setup
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"]}');
Query — containment (@>) uses the GIN index
SELECT name, attrs->>'ram_gb' AS ram, attrs->'tags' AS tags
FROM products
WHERE attrs @> '{"type":"laptop"}'
  AND (attrs->>'ram_gb')::int >= 16;
Output
nameramtags
ThinkPad X132["business", "14inch"]
MacBook Air16["m3", "13inch"]

✅ Do — good use cases

  • Variable product attributes, user preferences, webhook/API payloads
  • Audit snapshots of “the whole row as it was” (#20)
  • Fields you query with @> containment + GIN

❌ Don’t — anti-patterns

  • One table = id + data jsonb for your whole app — no types, no constraints, no FKs; you bought MongoDB’s problems with none of its tooling
  • Filtering every query on attrs->>'status' — promote hot fields to real columns
Pattern & benefit: jsonb 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.
Veteran tip: Columns you filter on constantly deserve promotion to real columns. JSONB is for the long tail of attributes, not a replacement for schema design.

Analytics & Reporting

03Top-N per group — ROW_NUMBER()

Scenario: "show the 2 highest-paid employees in each department" — the classic interview question, solved the pro way.

Analogy: Ranking students within each classroom instead of across the whole school — PARTITION BY draws the classroom walls, ORDER BY hands out the ranks inside each room.
Setup
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);
Query
SELECT * FROM (
  SELECT name, dept, salary,
         ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) t WHERE rn <= 2;
Output
namedeptsalaryrn
AshaEng1800001
VikramEng1650002
PriyaSales1400001
JohnSales1200002

✅ Do — good use cases

  • Leaderboards, “top 3 products per category”, best N per region
  • Deduplication: keep rn = 1, delete the rest
  • Always with a unique tiebreaker in ORDER BY

❌ Don’t — anti-patterns

  • One query per department in an app loop (N+1 queries)
  • GROUP BY dept + MAX(salary) joined back — returns multiple rows on salary ties and misses the runner-up entirely
Pattern & benefit: One scan, no self-joins, no correlated subqueries. Swap ROW_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.
Gotcha — why it goes wrong: If two employees tie on salary, 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.

04Running totals & moving averages

Scenario: finance dashboard — cumulative revenue and a 3-day moving average, straight from SQL, no app-side loops.

Analogy: A bank passbook: every line shows both the transaction and the balance-so-far. The window frame is you sliding a ruler over the last 3 lines to average them.
Setup
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);
Query
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;
Output
dayrevenuerunning_totalma_3day
2026-06-01100010001000
2026-06-02150025001250
2026-06-0380033001100
2026-06-04200053001433
2026-06-05120065001333

✅ Do — good use cases

  • Cumulative revenue, account balances over time, burn-down charts
  • Day-over-day deltas with LAG(), moving averages for smoothing

❌ Don’t — anti-patterns

  • Fetching all rows and looping in Python/JS to accumulate — ships megabytes to compute one column
  • Correlated subquery (SELECT SUM(...) WHERE day <= t.day) per row — O(n²), dies past a few thousand rows
Pattern & benefit: Window frames (ROWS 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.
Gotcha — why it goes wrong: Writing 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.

05Recursive CTE — org charts, categories, BOMs

Scenario: walk an employee → manager tree of any depth in a single query. Works for category trees, folder structures, bill of materials.

Analogy: Building a family tree: start with the grandparents, keep asking “who are your children?”, and stop when nobody answers. One conversation, any depth.
Setup
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);
Query
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;
Output
depthpath
0CEO
1CEO → VP Eng
2CEO → VP Eng → Eng Mgr
3CEO → VP Eng → Eng Mgr → Dev A
3CEO → VP Eng → Eng Mgr → Dev B
1CEO → VP Sales

✅ Do — good use cases

  • Org charts, category trees, folder hierarchies, bill-of-materials
  • Graph walks: “everything reachable from X”, dependency chains
  • Always with a depth cap or CYCLE clause

❌ Don’t — anti-patterns

  • One query per level in app code — round-trips scale with tree depth
  • Hardcoded 5-way self-join a JOIN b JOIN c... — silently truncates level 6
Pattern & benefit: One round-trip regardless of tree depth — replaces N+1 recursive app queries. The anchor selects the roots; the recursive member joins children on each iteration. Track depth to cap runaway recursion. For huge, frequently-read trees consider the ltree extension.
Gotcha — why it goes wrong: Cyclic data (A manages B, B manages A) makes the recursion loop forever. Why wrong: UNION ALL never de-duplicates, so it revisits nodes endlessly. Fix: cap with WHERE depth < 20, or use the CYCLE clause (PG 14+).

06LATERAL join — "for each row, run a subquery"

Scenario: for every customer, fetch their 2 most recent orders. LATERAL is a correlated subquery that can return multiple rows and columns.

Analogy: A personal shopper per customer: for each customer in your list, dash into the orders store, grab exactly their 2 newest orders, come back. The dash is a tiny index lookup, not a full store search.
Setup
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');
Query
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;
Output
nameorder_idtotalplaced_at
Acme119002026-06-20
Acme105002026-06-01
Globex1312002026-06-15

✅ Do — good use cases

  • “Latest N children per parent” when the parent list is small (a page of customers)
  • Calling a set-returning function per row: CROSS JOIN LATERAL jsonb_array_elements(...)

❌ Don’t — anti-patterns

  • CROSS JOIN LATERAL when parents may have zero children — rows vanish; use LEFT JOIN LATERAL ... ON true
  • LATERAL over the entire 10M-customer table — the window-function form (#03) scans once instead of 10M index dips
Pattern & benefit: With an index on (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.
Gotcha — why it goes wrong: 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.

07Full-text search — no Elasticsearch needed

Scenario: search articles with stemming ("running" matches "run"), ranking, and an index — built into Postgres.

Analogy: A trained librarian, not a string matcher: she knows “running”, “ran” and “runs” are the same word, ignores “the” and “of”, and hands you the most relevant book first.
Setup
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);
Query
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;
Output
idtitlerank
3Query speed tips0.9524
1Index tuning guide0.6079

✅ Do — good use cases

  • Product/article/document search with ranking and stemming
  • Weighted fields: title matches beat body matches (setweight)
  • websearch_to_tsquery for anything typed by users

❌ Don’t — anti-patterns

  • ILIKE '%term%' on large tables — leading wildcard = full scan every search
  • Reaching for Elasticsearch before trying built-in FTS — a second system to deploy, sync, and secure
Pattern & benefit: Stemming, stop-words, weighted fields (title > body), ranking — all transactionally consistent with your data, zero sync pipelines. websearch_to_tsquery accepts Google-style input safely. For fuzzy/typo matching add the pg_trgm extension with a GIN trigram index on ILIKE '%term%'.
Gotcha — why it goes wrong: Never feed raw user input to 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.

Indexing & Performance

08Partial indexes — index only what you query

Scenario: 50M orders, 99% are 'completed', your app only ever polls the pending ones. Index just those.

Analogy: Indexing only the “URGENT” tray instead of the entire archive room. The tray index stays tiny, and filing completed paperwork never touches it.
Setup + Query
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;
Output (EXPLAIN excerpt)
QUERY PLAN
Limit (cost=0.14..8.32 rows=10)
  -> Index Scan using idx_orders_pending on orders (actual time=0.021..0.045)

✅ Do — good use cases

  • Hot small subsets: WHERE status='pending', WHERE deleted_at IS NULL
  • Conditional uniqueness: one active subscription per user
  • Queue-polling queries that must stay fast at any table size

❌ Don’t — anti-patterns

  • CREATE INDEX ON orders(status) for a 4-value column — huge index the planner mostly ignores
  • A partial index whose predicate your queries don’t literally imply — it will never be used
Pattern & benefit: The index is ~100× smaller, fits in RAM, and writes to 'completed' rows never touch it. The query's WHERE clause must match the index predicate for the planner to use it. Classic uses: WHERE deleted_at IS NULL, WHERE processed = false, unenforced-yet uniqueness like CREATE UNIQUE INDEX ... WHERE active.
Gotcha — why it goes wrong: The planner only uses a partial index if the query’s WHERE provably implies the index predicate. 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.

09Covering indexes — Index-Only Scans

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.

Analogy: The index card already contains the whole answer, so you never walk to the shelf. “Heap Fetches: 0” = zero trips to the shelf.
Setup + Query
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';
Output
QUERY PLAN
Index Only Scan using idx_users_email_inc on users (actual time=0.019..0.020 rows=1)
  Heap Fetches: 0

✅ Do — good use cases

  • One ultra-hot lookup powering login/session/profile checks
  • Read-heavy tables where the same 2–3 columns always ride along

❌ Don’t — anti-patterns

  • INCLUDE-ing 8 columns “just in case” — every UPDATE now writes a fatter index
  • Expecting index-only scans on a heavily-updated, under-vacuumed table — Heap Fetches quietly climb back up
Pattern & benefit: 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.
Gotcha — why it goes wrong: Index-only scans depend on the visibility map: if autovacuum lags, 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.

10Keyset pagination — OFFSET is a trap

Scenario: infinite-scroll feed. OFFSET 100000 reads and throws away 100k rows every page. Keyset reads only what it returns.

Analogy: A bookmark vs. counting pages: OFFSET re-counts 100,000 pages from page one every time; keyset opens the book exactly where the bookmark is.
❌ Slow — degrades with depth
SELECT id, title FROM posts
ORDER BY created_at DESC, id DESC
OFFSET 100000 LIMIT 20;
-- 480 ms, scans 100,020 rows
✅ Fast — constant time at any depth
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
Output comparison
methodpage 1page 5,000rows scanned @ p5000
OFFSET/LIMIT0.4 ms480 ms100,020
Keyset (seek)0.4 ms0.4 ms20

✅ Do — good use cases

  • Infinite scroll, mobile feeds, cursor-based REST/GraphQL APIs
  • Batch exports that walk a huge table chunk by chunk

❌ Don’t — anti-patterns

  • OFFSET 100000 anywhere users can paginate deep
  • Paginating on a mutable column like updated_at — rows teleport between pages mid-scroll
Pattern & benefit: Remember the last row's (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.
Gotcha — why it goes wrong: You can’t jump to “page 500” with keyset — it’s next/previous only. And the cursor columns must be immutable and unique-in-combination: paginate on (updated_at, id) while rows are being updated and entries shift between pages mid-scroll.

Concurrency Patterns

11Job queue — FOR UPDATE SKIP LOCKED

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.

Analogy: A cafeteria with many counters: if the counter you approach is busy (locked), you step to the next free one instead of queueing behind someone. Nobody waits, nobody gets the same tray.
Setup
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';
Query — each worker runs this in a transaction
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;
Output (worker #3's result while workers 1–2 hold other rows)
idpayload
1043{"task": "send_email", "to": "blsraut@gmail.com"}

✅ Do — good use cases

  • Background jobs, email sends, webhook dispatch — with transactional enqueue
  • Fan-out to N workers with zero lock contention
  • Pair with a reaper for jobs stuck in 'running'

❌ Don’t — anti-patterns

  • SELECT a job then UPDATE it without locking — two workers grab the same job and your customer gets two emails
  • Plain FOR UPDATE without SKIP LOCKED — 20 workers form a polite single-file queue, throughput of 1
Pattern & benefit: SKIP 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).
Gotcha — why it goes wrong: If a worker crashes after grabbing a job, the row stays '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.

12DISTINCT ON — latest row per key

Scenario: sensor readings table — get the most recent reading per device. Postgres-only syntax, wonderfully terse.

Analogy: From a pile of photos of many people, keep only the newest photo of each person: sort by person, newest on top, take the top one per person.
Setup
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');
Query
SELECT DISTINCT ON (device_id)
       device_id, temp, at
FROM readings
ORDER BY device_id, at DESC;
Output
device_idtempat
122.12026-07-03 10:00:00+00
229.42026-07-03 09:45:00+00

✅ Do — good use cases

  • Latest reading per device, current status per shipment, newest version per document
  • Backed by an index on (key, sort_col DESC)

❌ Don’t — anti-patterns

  • GROUP BY device_id + MAX(at) joined back to get the row — extra scan, and duplicate rows on timestamp ties
  • DISTINCT ON when you need portable SQL — it’s Postgres-only; use the #03 pattern for portability
Pattern & benefit: Keeps the first row per group according to ORDER BY — so sort by key, then recency DESC. Simpler and often faster than the ROW_NUMBER approach for "latest per entity". Back it with an index on (device_id, at DESC).
Gotcha — why it goes wrong: The ORDER BY must start with the DISTINCT ON expressions (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.

Reporting Tricks

13FILTER clause — pivot tables in one pass

Scenario: one row per month with signups split by plan — a pivot report without CASE WHEN soup.

Analogy: Sorting mail into labeled pigeonholes in a single pass down the stack — one walk, every envelope counted into its slot, instead of re-walking the stack once per slot.
Query
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;
Output
monthtotalfreeproent
2026-05-014203019821
2026-06-0151534014233

✅ Do — good use cases

  • Pivot-style reports: signups by plan, errors by severity, sales by region — one scan
  • Mixed aggregates: AVG(amount) FILTER (WHERE refunded) next to overall totals

❌ Don’t — anti-patterns

  • One query per category merged in app code — N scans of the same table
  • Nested SUM(CASE WHEN...) pyramids — works, but reviewers need a map and a flashlight
Pattern & benefit: agg() 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.
Gotcha — why it goes wrong: 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.

14generate_series — fill gaps in time series

Scenario: chart shows daily orders, but days with zero orders vanish from GROUP BY output. Generate the calendar, then LEFT JOIN.

Analogy: Print the blank calendar first, then write sales onto it. Days with no sales stay visible as empty squares — GROUP BY alone would tear those pages out.
Query
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;
Output
dayorders
2026-06-2814
2026-06-290
2026-06-3022
2026-07-010
2026-07-0231

✅ Do — good use cases

  • Time-series charts where every day/hour must appear, even empty ones
  • Generating test data: INSERT ... SELECT ... FROM generate_series(1, 1e6)
  • Calendar spines for cohort and retention reports

❌ Don’t — anti-patterns

  • Filling missing dates in application code after the query
  • Joining on o.placed_at::date = d — the cast kills index use; join on a half-open range instead
Pattern & benefit: generate_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.
Gotcha — why it goes wrong: Joining on 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).

Scale & Architecture

15Declarative partitioning — big tables, small pain

Scenario: events table growing 50M rows/month. Partition by month: queries prune to one partition, old data drops instantly.

Analogy: Monthly binders instead of one 10,000-page folder: June’s question opens only June’s binder, and old months are shredded whole (DROP) instead of tearing out pages one by one (DELETE).
Setup
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');
Query + plan showing partition pruning
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

✅ Do — good use cases

  • Append-mostly time-series past ~100M rows: events, logs, metrics
  • Retention policies: DROP old partitions instantly
  • Automate partition creation with pg_partman

❌ Don’t — anti-patterns

  • Partitioning a 2M-row table because it sounds professional — pure overhead, zero benefit
  • Mass-DELETE for retention on a huge unpartitioned table — days of bloat and vacuum debt
Pattern & benefit: Partition pruning skips irrelevant data automatically. Retention becomes DROP 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.
Gotcha — why it goes wrong: Every PRIMARY KEY / UNIQUE constraint must include the partition key — you cannot have a global unique 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.

16Materialized views — precomputed dashboards

Scenario: a revenue-summary query joins 5 tables and takes 30s. The dashboard hits it every page load. Compute once, read instantly.

Analogy: Sunday meal prep: cook the expensive dish once, reheat portions all week. REFRESH is the next cooking session; between sessions everyone eats leftovers — fast, slightly less fresh.
Setup
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;
Output
monthrevenueorders
2026-05-011,284,3008,911
2026-06-011,502,75010,204

✅ Do — good use cases

  • Expensive multi-join dashboards refreshed every N minutes
  • Pre-aggregated rollups that BI tools hammer
  • Always with a unique index so CONCURRENTLY works

❌ Don’t — anti-patterns

  • Matviews for live numbers — inventory, balances, anything money-adjacent
  • Non-concurrent REFRESH at peak hours — every dashboard query blocks for the full rebuild
Pattern & benefit: Dashboard reads drop from 30s to sub-millisecond index hits. Schedule the refresh (cron / pg_cron) at whatever staleness the business tolerates. CONCURRENTLY keeps the view queryable during refresh. It's a cache the database keeps honest — you can index it like any table.
Gotcha — why it goes wrong: 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.

17Row-Level Security — multi-tenancy in the DB

Scenario: SaaS app, many tenants in one table. One forgotten WHERE tenant_id = ? in app code leaks data across customers. RLS makes leaks impossible.

Analogy: Hotel keycards: every guest’s card only opens their own room. Even if a confused guest (buggy app code) tries every door in the corridor, the locks — not the guest’s memory — enforce the rule.
Setup
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';
Query & output — same SQL, tenant 7 sees only its rows
SELECT id, tenant_id, amount FROM invoices; -- no WHERE clause!
idtenant_idamount
30174,500
31771,200

✅ Do — good use cases

  • Multi-tenant SaaS: tenant isolation the app cannot forget
  • Compliance boundaries: support staff see only their region’s rows
  • Defense in depth even when the ORM also filters

❌ Don’t — anti-patterns

  • Trusting every developer to append WHERE tenant_id = ? forever — one code review miss is a data breach
  • Testing RLS while connected as superuser/owner — policies silently don’t apply and everything “works”
Pattern & benefit: The filter is enforced by the database on every SELECT/UPDATE/DELETE — app bugs can't bypass it. Policies compose with roles for read-only vs admin access. This is the backbone of Supabase-style architectures.
Veteran tip: Table owners and superusers bypass RLS by default — run the app as a non-owner role, and add FORCE ROW LEVEL SECURITY if the owner connects too.

Schema Design Power Tools

18Arrays + unnest — tags without a join table

Scenario: posts have tags. For simple cases an array column with a GIN index beats a three-table many-to-many.

Analogy: Sticky labels written on the folder itself instead of a separate label registry — quicker to read and search, but the labels can’t carry their own paperwork.
Setup
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}');
Query — contains, and tag frequency via unnest
SELECT title FROM posts WHERE tags @> '{postgres}';

SELECT tag, COUNT(*) FROM posts, unnest(tags) tag
GROUP BY tag ORDER BY 2 DESC;
Output (second query)
tagcount
postgres2
performance1
json1
javascript1
react1

✅ Do — good use cases

  • Simple labels: tags, feature flags, category codes — plus GIN for @>
  • Small bounded lists that live and die with the row

❌ Don’t — anti-patterns

  • Comma-separated strings 'a,b,c' — LIKE-parsing hell, no index, no types
  • Arrays when tags need their own metadata, counts, or foreign keys — that’s a join table’s job
Pattern & benefit: GIN-indexed @> (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.
Gotcha — why it goes wrong: No foreign keys into array elements — typo’d tag '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.

19Generated columns — computed, stored, indexable

Scenario: you always search users by lowercased email and report on order line totals. Let the DB maintain the derived values.

Analogy: A spreadsheet formula cell: it always recalculates from its inputs and nobody can type over it with a lie.
Setup
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);
Output
qtyunit_pricetotal
3199.99599.97
249.5099.00

✅ Do — good use cases

  • Derived values you filter or index: line totals, normalized emails, extracted JSONB fields
  • Keeping tsvector search columns in sync automatically (#07)

❌ Don’t — anti-patterns

  • Maintaining computed columns from app code — three services write the table, one forgets, data drifts
  • A trigger to do what a generated column does declaratively — more code, more failure modes
Pattern & benefit: The value can never drift out of sync with its inputs — the DB recomputes on every write, and you can index it. Related trick: expression indexes (CREATE INDEX ON users (lower(email))) make WHERE lower(email) = ... an index scan with no extra column at all.
Gotcha — why it goes wrong: The expression must be IMMUTABLE — 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.

20Audit log — triggers that never forget

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.

Analogy: CCTV on the vault door: it records every change no matter who made it or which door they used — and the tape is glued to the transaction, so a rolled-back change leaves no phantom footage.
Setup
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();
Output — after UPDATE products SET price = 899 WHERE id = 5
tblopold_row → new_rowby_userat
productsUPDATE{"price": 999} → {"price": 899}app_rw2026-07-03 11:02

✅ Do — good use cases

  • Compliance audit trails: who changed what, when, from old → new
  • One generic to_jsonb trigger reused across all audited tables
  • Debugging “how did this row end up like this?”

❌ Don’t — anti-patterns

  • App-layer audit logging — misses psql fixes, cron jobs, other services; and logs changes that later roll back
  • Heavy audit triggers on your hottest write path — use logical decoding/CDC there
Pattern & benefit: Runs in the same transaction as the change — audit and data commit or roll back together, and nothing (psql, cron, another service) can bypass it. to_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.
Gotcha — why it goes wrong: The trigger runs inside every write — a slow or buggy trigger function makes every UPDATE on products slow or failing. Why wrong: your audit feature just became a production outage. Keep trigger bodies trivial (one INSERT), and load-test; for very hot tables prefer CDC/logical decoding.

Observability & Tuning

21EXPLAIN ANALYZE — read plans like a pro

Scenario: a query is slow. Before touching anything, see what the planner actually did — estimated vs actual rows is where the truth lives.

Analogy: A GPS trip report: the planner’s estimated route vs. the road actually driven. When “estimated 1 turn” becomes “actually 3,100 turns”, the map (statistics) is stale.
Query
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'shipped';
Output — before and after adding the right index
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

✅ Do — good use cases

  • Any query slower than you expect — before touching indexes
  • Verifying a new index is actually used, with literal values
  • BUFFERS to see cache hits vs disk reads

❌ Don’t — anti-patterns

  • Adding indexes by intuition and hoping — measure, change one thing, measure again
  • EXPLAIN ANALYZE UPDATE ... outside a BEGIN/ROLLBACK — it really executes
Pattern & benefit: Read plans bottom-up, inner-first. Red flags: rows=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 readhit (cold cache / too much I/O). 380× here from one composite index.
Gotcha — why it goes wrong: 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.

22pg_stat_statements — find the real problem queries

Scenario: "the database is slow." Don't guess — rank every query shape by total time consumed across all executions.

Analogy: An itemized phone bill: you feel like “the phone is expensive”, the bill shows exactly which three numbers eat 80% of the budget. Fix those, ignore the rest.
Setup + query
-- 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;
Output
querycallstotal_msavg_ms
SELECT * FROM orders WHERE customer_id = $11,240,5519,821,4007.9
UPDATE carts SET items = $1 WHERE id = $2310,9222,114,0806.8
SELECT ... FROM reports_heavy_join ...841,932,00023,000.0

✅ Do — good use cases

  • Weekly ritual: reset, wait a day, fix the top 3 by total time
  • Finding “death by a thousand cuts” — the 8ms query called a million times
  • Before/after proof that your optimization worked

❌ Don’t — anti-patterns

  • Optimizing the query a developer feels is slow — feelings lose to total_exec_time every time
  • Ranking by mean_exec_time alone — surfaces rare 20s reports over the real budget-eaters
Pattern & benefit: Queries are normalized ($1, $2) so a million variations group into one row. Sort by total_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.
Gotcha — why it goes wrong: Stats accumulate since the last reset — last month’s batch job still dominates today’s ranking. Call 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.

Data Integrity

23Exclusion constraints — no double bookings, ever

Scenario: meeting-room booking. App-level overlap checks always race under concurrency. Let the database physically reject overlaps.

Analogy: A box office that physically cannot print two tickets for the same seat and showtime — no matter how many sales windows are open at once.
Setup
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)'); -- ?
Output
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

✅ Do — good use cases

  • Bookings, reservations, shift scheduling, seat allocation
  • Non-overlapping validity periods for versioned records (price histories)
  • Any “no two rows may overlap” business rule

❌ Don’t — anti-patterns

  • Check-then-insert in app code — two simultaneous requests both pass the check; congratulations, double booking
  • Serializing all bookings through one advisory lock — correctness by traffic jam
Pattern & benefit: "Same room AND overlapping time range" is rejected at the storage layer — correct under any concurrency, no app locks needed. Range types (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.
Gotcha — why it goes wrong: Requires the 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”.

24Advisory locks — app-level mutexes in the DB

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.

Analogy: A talking stick for your app servers: only whoever holds it runs the job, and if the holder faints (crashes), the stick drops automatically for someone else to pick up.
Query
-- 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'));
Output
serveracquiredaction
app-1trueruns the billing job
app-2falseskips, exits quietly
app-3falseskips, exits quietly

✅ Do — good use cases

  • Cron singletons across N app servers: billing runs, report generation
  • “One migration runner at a time” guards
  • Coordinating without Redis/Zookeeper when you already have Postgres

❌ Don’t — anti-patterns

  • A locks table with boolean flags — a crashed holder leaves it stuck until a human notices
  • Advisory locks for row-level work — that’s what FOR UPDATE and #11 are for
Pattern & benefit: Locks on an arbitrary number you choose — no table rows involved, so no bloat, no dead tuples. pg_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.
Gotcha — why it goes wrong: Locks are just numbers — two unrelated features that hash to the same key will block each other mysteriously. Namespace with the two-arg form 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.

25LISTEN / NOTIFY — pub-sub without a broker

Scenario: invalidate app caches or wake up workers the instant a row changes — pushed by the database itself, no polling.

Analogy: A kitchen bell: the waiter gets pinged the moment the dish is ready instead of walking to the kitchen every 30 seconds to ask. And the bell only rings if the dish actually made it out (commit).
Setup
-- 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();
Output — what session A receives when an order is inserted
event
Asynchronous notification "order_events" with payload "{"id": 5012, "total": 249.00}" received from server process 8841

✅ Do — good use cases

  • Cache invalidation the instant data commits
  • Waking workers: NOTIFY is the doorbell, the jobs table (#11) is the truth
  • Live UI updates via a websocket bridge

❌ Don’t — anti-patterns

  • NOTIFY as a durable message queue — no listener at that moment means the event never existed
  • Fat payloads — send the row ID, let the consumer fetch; 8KB cap and no replay
Pattern & benefit: Notifications are transactional — sent only if the INSERT commits, so subscribers never see phantom events. Perfect for cache invalidation, live UI updates (via a websocket bridge), and "wake up, there's work" signals paired with the SKIP LOCKED queue (#11). Payloads are fire-and-forget: send the ID, let the consumer fetch details.
Gotcha — why it goes wrong: NOTIFY is not a durable queue: if no one is listening at that moment, the message is gone forever. Why wrong as a queue: worker restarts = lost events. Pattern: NOTIFY is only the doorbell — the actual work rows live in a table (#11), and a waking worker always polls the table first. Also breaks behind PgBouncer transaction pooling, and payloads cap at ~8 KB.

Operations Essentials

26COPY — bulk load 100× faster than INSERT

Scenario: import a 10M-row CSV. Row-by-row INSERTs take an hour; COPY streams it in minutes.

Analogy: Moving house with one truck vs. carrying one box per car trip. Same boxes, same distance — the per-trip overhead is what kills you.
Query
-- 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);
Output — benchmark, 10M rows
methodtimerelative
Single-row INSERTs (autocommit)~62 min
Multi-row INSERT (1000/batch)~4 min15×
COPY~35 sec~106×

✅ Do — good use cases

  • Initial data loads, nightly imports, ETL landing zones
  • Stage into an all-text table, then validate/cast with SQL
  • Exports: \copy (SELECT ...) TO 'out.csv'

❌ Don’t — anti-patterns

  • ORM save() in a loop for a million rows — hours instead of seconds
  • COPY-ing untrusted files straight into production tables — one bad row aborts everything, or worse, succeeds
Pattern & benefit: COPY skips per-statement parsing/planning and streams data through one protocol message. Pro moves for big loads: drop or defer indexes and recreate after, load in one transaction, bump maintenance_work_mem, and run ANALYZE when done. Most driver libs expose it (psycopg copy, JDBC CopyManager).
Gotcha — why it goes wrong: COPY is all-or-nothing — one malformed row at line 9,999,999 aborts the entire load. Why wrong: hours lost to a stray comma. Fix: validate first, load in chunks, or stage into an all-text table and cast/clean with SQL afterwards.

27Soft delete — without breaking uniqueness

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.

Analogy: The archive drawer: closed files leave the desk but not the building. The desk’s “one active file per name” rule ignores the archive, so a returning client can open a fresh file.
Setup
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 ✅
Output
idemaildeleted_at
42ravi@corp.com2026-07-03 11:30:00+00
57ravi@corp.comNULL

✅ Do — good use cases

  • Recoverable deletion with an “undo” window
  • Audit/history requirements where rows must remain queryable
  • Partial unique index so emails free up on delete

❌ Don’t — anti-patterns

  • is_deleted boolean + plain UNIQUE(email) — a deleted user blocks that email forever
  • Soft-deleting everything by default — GDPR erasure requests need real DELETE, and every query carries the filter tax
Pattern & benefit: The partial unique index enforces "one active account per email" while history stays intact. Pair with a view (CREATE 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).
Gotcha — why it goes wrong: Every single query — joins, counts, EXISTS subqueries, ORM relations — must remember 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.

28Transactions & isolation — pick the right guarantee

Scenario: transferring money between accounts. READ COMMITTED (the default) permits anomalies under concurrency that stricter levels prevent.

Analogy: Bank tellers sharing one ledger: READ COMMITTED lets tellers glance at the live ledger mid-work; SERIALIZABLE behaves as if tellers took turns — and occasionally tells one “your work collided, redo it” (the retry).
Query
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
Cheat sheet
levelpreventsuse when
READ COMMITTED (default)dirty readsgeneral CRUD; each statement sees latest committed data
REPEATABLE READ+ non-repeatable reads, snapshot for whole txnreports/exports needing one consistent view
SERIALIZABLE+ all anomalies (as if run one-by-one)money movement, inventory — with a retry loop

✅ Do — good use cases

  • Money movement, inventory decrement, seat counts: SERIALIZABLE + retry loop
  • Single-statement atomic math: SET balance = balance - 100 is already race-free
  • SELECT ... FOR UPDATE when you must read-then-write specific rows

❌ Don’t — anti-patterns

  • Read balance into the app, compute, write back at READ COMMITTED — the classic lost update
  • Holding a transaction open across user think-time — blocks vacuum (#29) and other writers
Pattern & benefit: Postgres SERIALIZABLE is optimistic (SSI) — no extra blocking, it aborts one transaction instead of deadlocking, so your app must retry on error 40001. Cheaper everyday tools: SELECT ... FOR UPDATE to lock specific rows, and single-statement atomic updates (SET balance = balance - 100) which are already race-free.
Gotcha — why it goes wrong: Using SERIALIZABLE without a retry loop is worse than not using it: users just see “could not serialize access” errors. The error is the feature — your code must catch 40001 and re-run the transaction. Also don’t mix isolation levels across transactions touching the same data and expect combined guarantees.

29VACUUM & bloat — why tables get slow over time

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.

Analogy: A whiteboard where every update crosses out the old line and writes a new one. VACUUM is the person who erases the crossed-out lines — stop them, and you run out of board even though nothing new was said.
Query — check bloat pressure
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;
Output
relnamen_live_tupn_dead_tupdead_pctlast_autovacuum
carts2,100,0001,890,00047.42026-06-28 03:11
sessions890,000120,40011.92026-07-03 02:40
orders5,000,00041,2000.82026-07-03 01:15

✅ Do — good use cases

  • Monitor n_dead_tup weekly; tune scale factor per hot table
  • ALTER TABLE hot SET (autovacuum_vacuum_scale_factor = 0.01)
  • Set idle_in_transaction_session_timeout globally

❌ Don’t — anti-patterns

  • VACUUM FULL during business hours — exclusive lock, table offline
  • Disabling autovacuum because “it uses CPU” — you’ve scheduled a slow-motion outage
Pattern & benefit: Every UPDATE writes a new row version; the old one stays until vacuumed. High dead_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.
Veteran tip: A single forgotten idle in transaction connection can hold back vacuum for the whole database. Set idle_in_transaction_session_timeout.

30Connection pooling — Postgres hates 1,000 connections

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.

Analogy: A taxi fleet vs. everyone owning a car: 30 shared taxis (server connections) serve thousands of riders (client connections), because each rider only needs a car for minutes at a time.
Query — see where connections go
SELECT state, count(*) FROM pg_stat_activity
GROUP BY state ORDER BY 2 DESC;
Output — the classic pathology
statecount
idle742
active31
idle in transaction9

✅ Do — good use cases

  • PgBouncer transaction mode for web/API traffic
  • Pool size ≈ 2–4 × CPU cores — small pools genuinely go faster
  • Separate session-mode pool for LISTEN/NOTIFY and session state

❌ Don’t — anti-patterns

  • Raising max_connections to 5000 to “fix” connection errors — more idle processes, worse latency
  • A connection per request with no pool — handshake + process spawn per HTTP hit
Pattern & benefit: 742 idle processes doing nothing but consuming memory and locks. Put PgBouncer in transaction 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).
Gotcha — why it goes wrong: PgBouncer in transaction mode breaks anything session-scoped: 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.

Sharp Edges Every Veteran Respects

31NULL — three-valued logic bites everyone

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.

Analogy: NULL means “unknown”, not “empty”. Ask “is the unknown number equal to 5?” — the only honest answer is “don’t know.” And WHERE keeps only definite yes rows, so every “don’t know” is silently thrown away.
The classic traps
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)
Output — behavior comparison
expressionresultsurprised?
NULL = NULLNULLmost people
NULL IS NULLtrue
1 IS DISTINCT FROM NULLtruethe NULL-safe ≠
x NOT IN (1, NULL)never trueeveryone, once
COUNT(col) vs COUNT(*)skips NULLs vs counts rowssilent report bugs
'a' || NULLNULL (concat vanishes)use concat() or coalesce

✅ Do — good use cases

  • NOT EXISTS for “has none” checks — NULL-proof and well-planned
  • IS DISTINCT FROM when either side may be NULL
  • COALESCE to make “NULL means zero” explicit

❌ Don’t — anti-patterns

  • NOT IN (subquery) where the subquery can yield NULL — returns nothing, forever
  • WHERE col != NULL — always empty; it’s IS NOT NULL
Pattern & benefit: Prefer NOT 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.
Gotcha — why it goes wrong: Aggregates quietly skip NULLs: 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.

32timestamptz vs timestamp — always the former

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.

Analogy: 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.
Setup + queries
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;
Output
viewer timezonesame stored instant renders as
Asia/Kolkata2026-07-03 09:00:00+05:30
America/New_York2026-07-02 23:30:00-04
UTC2026-07-03 03:30:00+00

✅ Do — good use cases

  • timestamptz for every “when did it happen” column
  • Convert at the edge: AT TIME ZONE 'Asia/Kolkata' for day-bucketed reports
  • Future local-time events: store zone name in its own column

❌ Don’t — anti-patterns

  • Plain timestamp for events — “9:00” with no city attached; DST bugs twice a year
  • Zone abbreviations like 'IST' — India? Israel? Ireland? Always 'Asia/Kolkata'
Pattern & benefit: 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.
Gotcha — why it goes wrong: 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'.

33Zero-downtime migrations — DDL without an outage

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.

Analogy: Repaving a highway lane-by-lane at night with traffic flowing, instead of closing the whole road for a day. Slower for the crew, invisible to the drivers.
The safe playbook
-- 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';
Output — lock impact comparison
operationblocks writes?duration on 200M rows
CREATE INDEXyes — all writes~20 min outage
CREATE INDEX CONCURRENTLYno~35 min, invisible
ADD COLUMN ... DEFAULT 'web' (PG11+)instantmilliseconds
ALTER COLUMN TYPE int → bigintyes — full rewriteneeds a multi-step dance

✅ Do — good use cases

  • CREATE INDEX CONCURRENTLY — always, on any live table
  • SET lock_timeout = '3s' before every migration
  • NOT VALID + VALIDATE CONSTRAINT to split enforce/verify

❌ Don’t — anti-patterns

  • Plain CREATE INDEX on a 200M-row table at noon — a 20-minute write outage
  • Running migrations without lock_timeout — your ALTER queues behind one idle transaction and the whole DB appears frozen
Pattern & benefit: The trio that prevents 3am pages: CONCURRENTLY 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”.
Gotcha — why it goes wrong: Even a “cheap” DDL needs a brief exclusive lock — and lock queuing is the real killer: your ALTER waits behind one long transaction, and every new query waits behind your ALTER. The database looks frozen; the cause is one idle report connection. Also: a failed CREATE INDEX CONCURRENTLY leaves an INVALID index behind — check \d and drop it before retrying.

34Fast counts — COUNT(*) is honest but slow

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).

Analogy: Counting a stadium crowd head-by-head vs. reading ticket-sales figures. The gate tally is 99.9% right and instant — head-counting is for auditors, not for the scoreboard.
Options, fastest first
-- 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;
Output
methodresulttime
pg_class.reltuples18,204,0000.2 ms
EXPLAIN row estimate (filtered)1,204,1171.1 ms
exact COUNT(*)18,203,41741,300 ms

✅ Do — good use cases

  • Estimates for dashboards, “~1.2M results” pagination, monitoring
  • Trigger-maintained counter tables when exactness pays the bills
  • Count a partial index (#08) when you only count a small subset

❌ Don’t — anti-patterns

  • Exact COUNT(*) on every admin-page load of a 100M-row table
  • Billing or quota enforcement from reltuples estimates — it drifts with vacuum timing
Pattern & benefit: Dashboards, pagination (“~1.2M results”), and monitoring rarely need exact numbers — estimates are 100,000× cheaper and refresh with autovacuum/ANALYZE. When exactness matters (billing, quotas), maintain a counter table updated by trigger, or count a partial index (#08) so the scan is small. Google shows “about 1,200,000 results” for exactly this reason.
Gotcha — why it goes wrong: reltuples 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).

Everyday Craft

35Find & kill duplicates — then lock the door

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.

Analogy: Cleaning up a messy contact list: merge the duplicate entries once, then switch on “no duplicates allowed” — otherwise you’ll be cleaning again next quarter.
Find them
SELECT email, COUNT(*) FROM users
GROUP BY email HAVING COUNT(*) > 1;
emailcount
ravi@corp.com3
asha@corp.com2
Delete keeping the oldest, then prevent forever
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);

✅ Do — good use cases

  • Dedupe once, then add the unique constraint in the same maintenance window
  • Deterministic keeper: ORDER BY created_at, id — same survivor every run
  • Dry-run the inner SELECT first and eyeball what will die

❌ Don’t — anti-patterns

  • A scheduled “dedupe job” forever instead of a constraint — treating the symptom
  • Deleting by email IN (dupes) — that deletes the keepers too
Pattern & benefit: ROW_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.
Gotcha — why it goes wrong: You cannot create the unique index while duplicates exist — clean first. NULL emails don’t count as duplicates of each other (NULL ≠ NULL, #31) — decide if that’s what you want (PG 15+: UNIQUE NULLS NOT DISTINCT). And rows referenced by foreign keys can’t just be deleted — re-point children to the keeper first.

36Foreign keys — choose your ON DELETE story

Scenario: deleting a customer. What happens to their orders? That’s a business decision, and the FK clause is where you encode it.

Analogy: A library card: RESTRICT won’t shred a member’s card while books are checked out; CASCADE shreds the card and erases every loan record; SET NULL keeps the loans but marks the borrower “unknown”.
Setup
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);
Behavior comparison
clauseDELETE FROM customers WHERE id=7 …fits when
RESTRICT / NO ACTIONfails if orders existfinancial data — never lose orders (safe default)
CASCADEdeletes the orders tootrue child data: cart items, session rows
SET NULLorders survive, customer_id → NULLoptional relationships: “assigned agent left”

✅ Do — good use cases

  • Index every FK column — required for fast parent deletes and joins
  • CASCADE for composition (line items die with the order)
  • RESTRICT for anything with financial or audit value

❌ Don’t — anti-patterns

  • Skipping FKs “for performance” — orphaned rows are the slowest bug to clean up
  • CASCADE from a big parent by reflex — one DELETE silently nukes 40M child rows
  • Enforcing referential integrity in app code only — every service must never have a bug, forever
Pattern & benefit: FKs make invalid states unrepresentable — no orphaned orders, no dangling references, regardless of which app, script, or human touched the data. The delete behavior documents your domain model right in the schema.
Gotcha — why it goes wrong: The un-indexed FK column is a classic production killer: every 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.

37Constraining values — CHECK vs ENUM vs lookup table

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.

Analogy: A dress code: printed on the door (CHECK — easy to reprint), baked into the building’s architecture (ENUM — renovation required to change), or a guest list at reception (lookup table — edit anytime, can hold notes per guest).
The three options
-- 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)
Decision table
CHECKENUMlookup table
add a valueALTER constraintALTER TYPE ADD VALUEplain INSERT
remove/renameeasypainful (recreate type)easy
metadata per valuenonoyes (label, color, sort)
best forstable short liststruly fixed sets (weekdays)business-managed lists

✅ Do — good use cases

  • CHECK for small stable sets you control in migrations
  • Lookup table + FK when product managers add values or values need labels/colors
  • ENUM for genuinely permanent sets: card suits, ISO weekdays

❌ Don’t — anti-patterns

  • Unconstrained text — six months later: 'paid', 'Paid', 'PAID ', 'payed'
  • ENUM for anything a stakeholder might rename — dropping a value means recreating the type and every column using it
  • Magic numbers (status int) with meanings documented only in a wiki
Pattern & benefit: All three push validation to the only layer every writer shares. The veteran default: CHECK for engineer-owned lists, lookup table for business-owned lists — ENUMs look elegant but age the worst.
Gotcha — why it goes wrong: ALTER 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.

38Primary keys — identity vs UUIDv4 vs UUIDv7

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.

Analogy: Numbered bakery tokens (identity) file themselves in order; random raffle tickets (UUIDv4) are fine in a shoebox but a nightmare to file in millions — every new ticket belongs in a random drawer. UUIDv7 tickets are raffle-unique but numbered by time, so they file at the end like tokens.
Setup
-- 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()
Trade-offs
bigint identityUUIDv4UUIDv7
size8 B16 B16 B
insert locality (btree)append-only, cache-friendlyrandom — page splits everywhereappend-mostly
guessable / leaks volumeyes (/orders/10041)nono (leaks rough time)
generate in client/offlinenoyesyes

✅ Do — good use cases

  • bigint identity for internal, single-DB tables — smallest, fastest
  • UUIDv7 for public-facing IDs, offline-first apps, merging data across regions
  • Both: internal bigint PK + public UUID column for the API

❌ Don’t — anti-patterns

  • UUIDv4 PKs on high-insert tables — random inserts fragment the index and evict hot cache pages
  • Exposing sequential IDs publicly — competitors read your order volume off the URL
  • serial in new code — GENERATED ALWAYS AS IDENTITY is the standard, safer form
Pattern & benefit: PK choice is index-locality choice: btrees love monotonic keys. UUIDv7 keeps UUID benefits (unguessable, client-generatable, globally unique) while inserting like a sequence — measured write-throughput gaps between v4 and v7 on big tables are dramatic.
Gotcha — why it goes wrong: Identity gaps are normal — rollbacks and crashes burn numbers; never build logic that assumes gapless IDs (invoice numbering needs its own counter table). And don’t use ORDER BY uuid_v7_id as a precise event timeline — the timestamp is coarse; keep a real created_at.

39EXISTS vs IN vs JOIN — say what you mean

Scenario: “customers who have at least one order.” Three phrasings, one intent — but JOIN multiplies rows, and NOT IN has a NULL trapdoor (#31).

Analogy: Asking “does this person have any children?” — EXISTS peeks into the room and answers yes/no at the first child it sees. JOIN drags every child out to be counted, then you have to de-duplicate the parents you cloned.
The semi-join, three ways
-- ✅ 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);
Output — behavior on a customer with 500 orders
phrasingrows for “Acme”NULL-safe negation?
EXISTS1NOT EXISTS: yes
IN1NOT IN: breaks on NULLs
JOIN500 (needs DISTINCT)needs LEFT JOIN ... IS NULL

✅ Do — good use cases

  • EXISTS / NOT EXISTS for “has any” / “has none” — intent-revealing and NULL-proof
  • JOIN when you actually need the child columns in the output
  • IN for small literal lists: status IN ('a','b')

❌ Don’t — anti-patterns

  • JOIN + DISTINCT to fix duplicates the JOIN itself created — extra sort, blurred intent
  • NOT IN (SELECT nullable_col ...) — one NULL, zero results, no error (#31)
  • COUNT(*) > 0 to test existence — counts everything to answer a yes/no
Pattern & benefit: The planner turns EXISTS into a semi-join: probe, first match wins, move on. On “does at least one exist” questions that’s both the fastest plan and the phrasing that says exactly what you mean — future readers included.
Gotcha — why it goes wrong: SELECT 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).

40Fuzzy search — pg_trgm for typos and substrings

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.

Analogy: Matching names by overlapping three-letter shingles: “john” and “jhon” share most of their letter-triplets, so they score as near-identical even though string equality sees total strangers.
Setup
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING gin (full_name gin_trgm_ops);
Queries — indexed ILIKE and similarity ranking
-- 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;
Output
full_namescore
John Smith0.57
Jon Smythe0.38
Joan Smart0.31

✅ Do — good use cases

  • Name/address/SKU lookup with typo tolerance
  • Making existing ILIKE '%x%' searches indexable without app changes
  • Combine with FTS (#07): trigram for the query box, tsvector for document bodies

❌ Don’t — anti-patterns

  • Trigram search on 1–2 character inputs — too few trigrams, noise results
  • Levenshtein-in-a-loop from app code over the whole table — the index can’t help you there
  • Replacing FTS with trigrams for long documents — wrong tool, huge index
Pattern & benefit: One extension turns Postgres into a decent typo-tolerant search engine: the GIN trigram index accelerates ILIKE, % similarity, and even regex matching. Tune the cutoff with SET pg_trgm.similarity_threshold = 0.4.
Gotcha — why it goes wrong: Trigram GIN indexes are large (often bigger than the column data) and slow down writes — put them on search columns, not on everything. And similarity() in the SELECT doesn’t use the index — only the % operator and LIKE/ILIKE patterns do; keep % in the WHERE clause.

41RETURNING + writable CTEs — atomic pipelines

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.

Analogy: Moving a file between drawers in one hand-motion, instead of photocopy → file the copy → walk back → shred the original — with a fire alarm (crash) possible between any two steps.
Query
WITH moved AS (
  DELETE FROM sessions
  WHERE expires_at < now()
  RETURNING *
)
INSERT INTO sessions_archive SELECT * FROM moved
RETURNING id;
Output
result
INSERT 0 4218 — 4,218 sessions moved, atomically

✅ Do — good use cases

  • Archive/move patterns: DELETE ... RETURNING feeding an INSERT
  • UPDATE ... RETURNING * to get the final row without a follow-up SELECT
  • Claim-and-return in job queues (#11): UPDATE + RETURNING is the whole dequeue

❌ Don’t — anti-patterns

  • SELECT → INSERT → DELETE as three app-side statements — a crash between them loses or duplicates rows
  • Chaining writable CTEs that read each other’s target tables — they all see the same snapshot, not each other’s writes
Pattern & benefit: RETURNING 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.
Gotcha — why it goes wrong: All CTEs in one statement see the snapshot from the statement’s start — a second CTE reading 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.

42Views as API contracts — refactor without fear

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.

Analogy: A restaurant menu: diners order from the menu, never from the kitchen. The chef can reorganize the entire kitchen overnight — as long as the menu still lists the same dishes, no customer notices.
Setup
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

✅ Do — good use cases

  • Stable read models for BI tools and sibling services
  • Baking in mandatory filters: soft-delete, tenant scoping, published-only
  • Security surface: GRANT on the view, not the raw table (#45)

❌ Don’t — anti-patterns

  • SELECT * views — the column list freezes at creation; new table columns silently don’t appear
  • Views stacked five deep on views — plans get opaque, performance debugging becomes archaeology
  • Confusing them with matviews (#16) — plain views run their query every time, they cache nothing
Pattern & benefit: A view is a zero-cost stable interface: rename, split, or denormalize the underlying tables and repair the view in the same transaction — consumers get uninterrupted service. Simple single-table views are even writable (INSERT/UPDATE pass through).
Gotcha — why it goes wrong: CREATE 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.

43Batch updates in chunks — never one giant UPDATE

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.

Analogy: Repainting the office one room at a time over a week, instead of sealing the whole building for three days. Same paint, same walls — people keep working.
The chunked backfill loop (app or DO block drives 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)
Output — impact comparison, 80M rows
one giant UPDATE5k chunks
longest row locks heldhoursmilliseconds
replication lagmassive spikesmooth
vacuum debt80M dead rows at oncespread out, autovacuum keeps up
crash at 90%?everything rolls backprogress kept, resume where left off

✅ Do — good use cases

  • Backfills, mass status changes, GDPR scrubbing, big deletes (same pattern with DELETE)
  • Idempotent batches keyed on “still needs work” (IS NULL) — safe to re-run anytime
  • Throttle between commits; watch replication lag as you go

❌ Don’t — anti-patterns

  • One statement to update 80M rows during business hours — lock queue + WAL flood + vacuum cliff
  • Chunking without an indexed predicate — each batch re-scans the table to find work
  • Wrapping all the chunks in one outer transaction — congratulations, you rebuilt the giant UPDATE
Pattern & benefit: Short transactions are the golden rule of live-system surgery (#29, #33): locks stay brief, WAL flows steadily, replicas keep up, autovacuum digests dead rows incrementally, and a crash costs one chunk instead of hours.
Gotcha — why it goes wrong: The LIMIT-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.

Stewardship — Security, Backups, Planner

44Backups — pg_dump vs PITR, and the restore test

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.

Analogy: A photo of your house (dump) vs. continuous CCTV (PITR): the photo shows last night; the video lets you rewind to the second before the pipe burst.
The two tiers
-- 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"
Comparison
pg_dumpPITR (base + WAL)
restore granularitybackup moment onlyany second
data loss windowup to 24hseconds–minutes of WAL
works across PG versionsyessame major version
2TB databaseimpracticaldesigned for it

✅ Do — good use cases

  • PITR (pgbackrest/wal-g) as the production safety net
  • pg_dump for dev refreshes, migrations across versions, single-table exports
  • Restore drill on a schedule — timed, documented, automated

❌ Don’t — anti-patterns

  • Backups on the same disk/server as the database — one failure takes both
  • “The cron job ran, we’re safe” — an untested backup is a hope, not a backup
  • Replication as your backup — replicas replay the bad DELETE within milliseconds
Pattern & benefit: PITR turns “fat-finger delete” from a company-ending event into a 20-minute rewind. The restore drill is the actual product: you discover the broken script, missing WAL, and undocumented step on a calm Tuesday instead of during the outage.
Gotcha — why it goes wrong: WAL archiving that silently stops (full disk, expired credentials) leaves you with a base backup and a gap — monitor 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.

45Roles & least privilege — stop connecting as postgres

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.

Analogy: Office keys: interns get the reading-room key, staff get their own office, and nobody carries the master key that opens the vault, the server room, and the shredder — especially not the person standing closest to the front door (the web app).
The minimal sane setup
-- 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;
Who can do what
roleSELECTwrite rowsDROP TABLEcreate roles
app_rw (web app)
app_ro (BI tools)
migrator (CI only)✓ (DDL owner)

✅ Do — good use cases

  • Separate roles: app (DML), migrator (DDL), analyst (read-only)
  • ALTER DEFAULT PRIVILEGES so new tables don’t arrive permission-broken
  • Grant BI tools access to views (#42), not raw tables

❌ Don’t — anti-patterns

  • App connects as postgres/superuser — injection = total loss; also silently bypasses RLS (#17)
  • One shared human login for the whole team — the audit log (#20) says “admin did it” forever
  • GRANT ALL as the fix for every permission error
Pattern & benefit: Least privilege converts “SQL injection” from game-over into a contained incident, and makes RLS (#17) and audit trails (#20) actually mean something. Roles are free; the setup is ten minutes once.
Gotcha — why it goes wrong: 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.

46Extended statistics — when the planner multiplies wrong

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.

Analogy: “Lives in Pune” and “has a Pune pincode” aren’t two independent coin flips — but the planner multiplies them as if they were: 2% × 0.1% = almost nobody. Extended statistics teach it that the two facts travel together.
The fix
CREATE STATISTICS stat_city_pin (dependencies)
  ON city, pincode FROM addresses;
ANALYZE addresses;
Output — estimate vs reality
estimated rowsactual rowsplan chosen
before40120,000Nested Loop (catastrophic)
after108,400120,000Hash Join (correct)

✅ Do — good use cases

  • Correlated column pairs: city+pincode, country+currency, category+brand
  • Any time EXPLAIN ANALYZE (#21) shows estimates off by 100×+ on multi-column filters
  • Also available: ndistinct for GROUP BY estimates, mcv for common combos

❌ Don’t — anti-patterns

  • “Fixing” bad estimates by disabling nested loops globally (enable_nestloop=off) — punishes every other query
  • Sprinkling CREATE STATISTICS on every column pair preemptively — target proven misestimates, they cost ANALYZE time
Pattern & benefit: Most mystery slowness is misestimation: wrong row counts → wrong join strategy → minutes instead of milliseconds. One CREATE STATISTICS + ANALYZE repairs the estimate at the source, and every query touching that column pair benefits — no hints, no rewrites.
Gotcha — why it goes wrong: Extended statistics only apply to plain column references — WHERE 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.

Test Yourself

🎯Quiz — 200 questions, 5 at a time

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

↑ Top