Anti-Patterns & Readiness Checklist
This chapter is the back cover of the track: the mistakes that show up again and again, and the checklist to run before you call yourself done.
1. The twenty anti-patterns
| # | Anti-pattern | Symptom | Fix |
|---|---|---|---|
| 1 | SELECT * |
extra I/O, broken code when columns change | list columns |
| 2 | NOT IN (subquery) with NULLs |
silently returns zero rows | NOT EXISTS |
| 3 | Function on an indexed column | index ignored, slow scans | rewrite range, or expression index |
| 4 | Implicit type cast | index ignored, wrong results | match types explicitly |
| 5 | OFFSET pagination |
slows linearly; duplicates/skips on writes | keyset pagination |
| 6 | N+1 queries | hundreds of round trips per page | join / batch / eager load |
| 7 | DISTINCT to hide duplicates |
masks a fan-out bug, costs a sort | fix the join grain |
| 8 | Missing index on FK | slow parent deletes, slow child lookups | index child columns |
| 9 | Over-indexing | slow writes, bloat, cache pressure | drop unused indexes |
| 10 | Money in floats | rounding errors, mismatched totals | numeric or integer cents |
| 11 | timestamp without zone |
off-by-hours bugs across DST/regions | timestamptz |
| 12 | CSV/JSON in a text column | unqueryable, unindexable | join table, array, JSONB + GIN |
| 13 | EAV for core fields | every read becomes a pivot | real columns |
| 14 | No foreign keys | orphan rows, silent corruption | declare FKs |
| 15 | Read-then-write for uniqueness | duplicate rows under concurrency | ON CONFLICT / MERGE |
| 16 | Long transactions | bloat, locks, vacuum starvation | short transactions |
| 17 | Business logic only in triggers | invisible behaviour, hard debugging | app logic + constraints |
| 18 | OR across columns |
planner falls back to seq scans | UNION ALL, partial indexes |
| 19 | Unordered LIMIT |
different rows each run | deterministic ORDER BY |
| 20 | No retry on 40001/40P01 |
random 500s under load | retry with backoff |
2. The ones worth seeing in code
N+1: the ORM trap
// One query for the list, then one per row: 1 + N round trips.
const orders = await db.query("SELECT * FROM orders LIMIT 50");
for (const order of orders) {
order.user = await db.query("SELECT * FROM users WHERE id = $1", [order.user_id]);
}
// One join: 1 round trip.
const orders = await db.query(`
SELECT o.*, u.full_name AS user_name
FROM orders AS o
JOIN users AS u ON u.id = o.user_id
LIMIT 50
`);
An ORM makes this easy to trigger and hard to see. Enable query logging in development and count the queries per request.
Offset pagination
-- Deep pages get slower and unstable when rows are inserted.
SELECT id, placed_at
FROM orders
ORDER BY placed_at DESC, id DESC
OFFSET 120 LIMIT 10;
-- Keyset: constant cost, stable under writes.
SELECT id, placed_at
FROM orders
WHERE (placed_at, id) < ('2026-01-01 00:00+00', 999999)
ORDER BY placed_at DESC, id DESC
LIMIT 10;
Keyset pagination requires a deterministic total order (placed_at, id) and an index that matches it: (placed_at DESC, id DESC).
SELECT * in production
Beyond I/O, it couples your code to the physical schema: a new blob column, a rename, or a column reorder breaks deserialisation. RETURNING * has the same problem.
Counting instead of existing
-- Scans every match just to answer yes/no
SELECT COUNT(*) FROM orders WHERE user_id = 7 AND status = 'cancelled';
-- Stops at the first match
SELECT EXISTS (SELECT 1 FROM orders WHERE user_id = 7 AND status = 'cancelled');
Predicates that cannot use indexes
WHERE lower(email) = 'a@example.com' -- expression index or normalise on write
WHERE created_at::date = '2026-03-01' -- half-open range
WHERE status = 'cancelled' OR user_id = 7 -- different columns: consider UNION ALL
WHERE id IN (1, 2, 3) AND id <> 2 -- harmless, but the planner sees a scan
Hidden fan-out in reports
Every "revenue is wrong" bug in this list's history was a join multiplied by a child table before a SUM. Aggregate at the parent grain, then join aggregates (chapters 2 and 4).
3. A schema smell test
Ask these ten questions about any schema you inherit:
- Does every table have a primary key? Is it the right type?
- Are all relationships enforced with foreign keys?
- Are money, dates, and identifiers using precise types?
- Is any column storing multiple values (CSV, JSON-as-schema)?
- Are there
phone1..phone5-style repeating columns? - Is there a
statusplus booleans that can contradict it? - Are historical facts (prices, addresses) snapshotted where they must be?
- Is there a single source of truth for cached/denormalised values?
- Do unique constraints exist for natural keys (email, slug, invoice number)?
- Are multi-tenant tables scoped by
tenant_idin keys, indexes, and queries?
Any "no" is a conversation, not necessarily a defect — but it should be a decision.
4. Production readiness checklist
Queries
- Every list query has a deterministic
ORDER BYand a boundedLIMIT. - Filters are sargable; no functions/casts on indexed columns.
- Existence checks use
EXISTS; anti-joins useNOT EXISTS. - No
SELECT *in application code. - Aggregations are protected from fan-out.
- Pagination is keyset-based for deep pages.
Schema
- Primary keys, foreign keys, and
NOT NULLare declared. -
CHECK/UNIQUEconstraints encode business rules. - Money/timestamps use precise types (
numeric/cents,timestamptz). - Join tables and FK columns are indexed.
- Denormalised values have a single write path.
Transactions
- Multi-statement writes are explicit transactions.
- Isolation level is chosen; serialization failures are retried.
- Read-modify-write uses
FOR UPDATEor a single atomic statement. - Upserts use
ON CONFLICT/MERGE. - Transactions do not wrap network calls or user think-time.
Operations
- Slow queries are visible (
pg_stat_statements, logs). - Migrations set
lock_timeout; big indexes useCONCURRENTLY. -
ANALYZE/VACUUMrun automatically; bloat is monitored. - Backups and restore are tested (a backup you cannot restore is not a backup).
- Connection pool size is intentional; no
idle in transactionleak. - Reads and writes are separable (replica/routing) if scale requires it.
Security
- Queries are parameterised; no string concatenation (see OWASP).
- Application users have least-privilege grants.
- Sensitive columns are encrypted at rest/in transit as required.
- Multi-tenant queries always filter by tenant.
5. Interview drill
Answer these out loud; the act of explaining exposes gaps.
- Why can't you use a
SELECTalias inWHERE? BecauseWHEREruns beforeSELECTin the logical order. WHEREvsHAVING? Rows before grouping vs groups after aggregation.COUNT(*)vsCOUNT(col)? All rows vs non-NULL values.INNERvsLEFTjoin, and where do filters go? Left keeps unmatched rows; right-table filters go inONfor outer joins,WHEREotherwise.- What is fan-out? A 1:N join multiplying parent rows, inflating aggregates.
ROW_NUMBERvsRANK? No ties vs ties with gaps.- Why does
LAST_VALUEreturn the current row? Default frame ends atCURRENT ROW; useUNBOUNDED FOLLOWING. - When does a CTE materialise? Multiple references, recursion, or
MATERIALIZED; single-reference non-volatile CTEs inline in PG 12+. - How do you safely upsert?
ON CONFLICTon a unique key (orMERGE), never read-then-write. - What is write skew, and which level prevents it? Two transactions read an invariant and write different rows; only
SERIALIZABLEprevents it. - Why is
SELECT ... FOR UPDATE SKIP LOCKEDuseful? Concurrent workers claim different rows without blocking. - Why can an index be ignored? Low selectivity, non-sargable predicate, stale stats, or type mismatch.
- What does an index-only scan need? All columns in the index (or
INCLUDE) and a current visibility map. - Why is
OFFSET 100000slow? The engine reads and discards 100,000 rows; keyset pagination avoids that. - How do you add a
NOT NULLcolumn to a huge table? Add nullable → backfill in batches →CHECK ... NOT VALID→VALIDATE→SET NOT NULL.
6. Where to go next
- Practice — run every example in the SQL Playground and work through the 64-question practice sheet with instant feedback.
- Optimisation — SQL Query Optimisation for the application-side view (N+1, keyset pagination, indexes).
- Transactions at scale — Relational Databases for replication, ACID, and sharding.
- Storage choices — Choosing the Right Database when relational is not the answer.
- Operating it — Monitoring & Observability for query telemetry in production.
[!TIP] ELI5: Readiness is reflexes, not trivia A senior engineer does not recite normal forms at a whiteboard. They notice the missing
WHEREbefore pressing Enter, ask about cardinality before joining, and check the plan before declaring victory. The checklist above is just those reflexes written down.
"The query is the easy part. Knowing what one row means is the job."