Module F-13·20 min read

Schema modelling, B-tree indexes, what the query planner is actually deciding, and how one slow query turns into an upstream outage.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module F-13 — Database Fundamentals for Architects

What this module covers: The database is where most system design decisions become permanent, and where most outages originate. This module covers schema modelling and when denormalisation is actually justified, constraints as correctness guarantees that application code cannot replicate, B-tree indexes — including composite column order, covering indexes, partial indexes, and selectivity — the costs an index imposes on every write, what the query planner is really deciding and why stale statistics produce bad plans, MVCC and the bloat it creates, and finally the mechanism by which one slow query takes down an entire service.


The Schema Is the Most Expensive Decision You'll Make

Application code gets rewritten. Schemas accumulate data, and data is what makes change hard: a column type you regret is a migration on 400 GB while traffic continues (Module O-4), and a primary key you regret may be effectively permanent (Module P-3).

So schema decisions deserve the DesignDecision treatment from Module F-1 — with the accepted cost written down.

Analogy: the schema is the foundation and the plumbing. You can repaint any room whenever you like. Moving a load-bearing wall or re-routing the drains means occupying the whole building and hoping nothing else was resting on it.


Normalise First, Denormalise With a Reason

Normalisation stores each fact once. One row per customer; orders reference the customer by ID. Updating an address touches one row, and there's no way for two copies to disagree.

Denormalisation duplicates data to avoid a join — storing customer_name on the order row so rendering an order list needs no join at all.

Start normalised. It's the default because correctness is the default: if a fact exists in one place, it cannot be inconsistent with itself. Then denormalise when you can name all three of these:

  1. The join is genuinely expensive at your data volume — verified with EXPLAIN ANALYZE, not assumed.
  2. The read pattern is frequent enough that the saving matters.
  3. Staleness is acceptable, or you have a reliable mechanism to keep the copy correct.

That third condition is the one that gets skipped, and it's what turns denormalisation into a data-integrity bug rather than an optimisation. A copied customer_name is wrong the moment the customer renames themselves, and now you need a trigger, an application-level fan-out update, or a CDC pipeline (Module P-23) to fix every copy. Which is fine — as long as it was a decision.

There's a legitimate special case worth naming: immutable historical facts. An invoice should store the customer's name and address as they were at the time of billing, not join to the current values. That isn't denormalisation, it's correct modelling — the invoice's copy is a different fact from the customer's current address, and the accounting requirement is that it never changes.

Types and constraints, briefly, because they're cheap to get right and expensive to fix:

  • Money is never a float. numeric/decimal, or integer minor units (cents). 0.1 + 0.2 !== 0.3 in binary floating point, and a payment ledger that drifts by fractions of a cent is a reconciliation nightmare (Module P-24).
  • Timestamps are timestamptz, always. A timestamp without time zone records an instant with no way to know which instant, and the bug appears at a DST boundary, months later, in another team's report.
  • JSONB for genuinely schemaless data, not as an escape from schema design. It's excellent for third-party payloads and sparse attributes, and it costs you type checking, foreign keys, and efficient indexing of the fields inside it.

Constraints are the part most worth defending:

sql

The objection is always "we validate that in application code." Application validation fails in four ways the database doesn't:

  1. Concurrency. Check-then-insert is a race. Two simultaneous requests both see no pending order and both create one. A UNIQUE or EXCLUDE constraint is atomic; a SELECT followed by an INSERT is not (Module P-1).
  2. Multiple writers. A background job, a data migration, an admin script, and a second service in another language all bypass your validation layer.
  3. Bulk operations. Imports and backfills routinely skip application logic entirely.
  4. Bugs. A new code path forgets the check. The constraint doesn't forget.

Why this matters in production: a constraint converts a class of data corruption into a failed transaction. Failed transactions are visible, loud, and fixable. Corrupt data is silent, spreads into reports and downstream systems, and by the time someone notices you can't reconstruct what was true.


B-Tree Indexes: What They Actually Do

Postgres's default index is a B-tree: a balanced tree with all values in sorted order at the leaves, which are linked so a range scan can walk sideways. Lookup is O(log n) — for 100 million rows that's roughly 4–5 page reads instead of scanning 100 million.

What matters for design:

Sorted order means a B-tree serves more than equality. It handles =, <, >, BETWEEN, IN, ORDER BY, and prefix LIKE 'abc%' — all from the same structure, because the leaves are already in order. It cannot help LIKE '%abc' (no known prefix) or a function applied to the column (WHERE lower(email) = ...) unless you index that expression.

Composite index column order is not arbitrary. An index on (a, b, c) is usable for queries filtering on a, on (a, b), or on (a, b, c) — the leftmost prefix rule — and is essentially useless for a query filtering only on b.

sql

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.