open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 06 · 06

Schema design: normalize, then denormalize on purpose

Normalize so a fact lives in one place, model with real Postgres types instead of stringly-typed columns, pick surrogate vs natural keys deliberately, and denormalize only for read performance — kept consistent by the engine.

SQL Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

A startup denormalized early “for speed”: every order row copied the customer’s name, email, and address. Fast reads, until a customer changed their email and support found three years of orders showing the old one — because the truth had been copied into a million rows and nobody updated them all. The opposite failure is just as real: a fully normalized analytics query joining eight tables timing out on the dashboard. Schema design is the discipline of knowing when a fact should live in exactly one place and when, deliberately and consistently, it should be duplicated for reads.

Normalize first: one fact, one place

Normalization is the default, and the rule of thumb that captures most of it: every fact lives in exactly one place. A customer’s email is stored once, on the users row; an order references the user by id and never copies the email. When the email changes, you update one row and every order automatically reflects the truth, because it was never duplicated. (The databases track’s relational-model chapter covers the normal forms formally; here we focus on the engineering judgment.)

The payoff is update integrity: there’s no way for two copies of a fact to disagree, because there’s only one copy. The cost is that reading a complete picture requires joins — to show an order with its customer’s current name you join orders to users. For the overwhelming majority of OLTP workloads, that join is cheap (an indexed lookup) and the integrity is priceless. Start normalized. Always.

Model with Postgres types, not strings

The throughline of this whole unit: a schema that leans on Postgres’s type system is enforced by the engine; a stringly-typed schema pushes that work into fragile application code. Concretely, prefer:

  • a status order_status enum (or CHECK ... IN) over a free-text status text that can hold 'Paid', 'paid', and 'PAID';
  • a tstzrange with an EXCLUDE constraint over two text columns holding ISO strings the app must parse and compare;
  • numeric/integer cents over a text “amount”; timestamptz over a text date; a tags text[] (or join table) over a comma-joined string;
  • a jsonb document over a serialized blob the database can’t index or validate;
  • a domain (CREATE DOMAIN email AS text CHECK (value ~ '^[^@]+@[^@]+$')) to attach a reusable constraint to a semantic type.

Each of these moves a rule from “the app remembers to check” to “the engine guarantees.” A stringly-typed column is a deferred bug: it accepts garbage today and surfaces it as a production incident later.

Surrogate vs natural keys

A natural key is a column that’s already a real-world identifier (an ISO country code, a SKU, an email). A surrogate key is a meaningless engine-generated id (bigint identity or uuid). The judgment:

  • Surrogate is the safe default for the primary key. It’s stable (a customer can change their email; their id never changes), compact, and uniform. Foreign keys point at the surrogate, so a natural-value change doesn’t cascade through the whole database.
  • Natural keys earn their place as UNIQUE constraints (you still want users.email UNIQUE), and occasionally as the primary key when the value is genuinely immutable and externally meaningful — a currencies(code) table keyed on 'USD', 'EUR'. Even then, beware: “immutable” identifiers have a habit of changing (companies rename, countries split).

The pragmatic rule: surrogate primary key + natural unique constraints. You get a stable join target and still enforce the real-world uniqueness rule.

Denormalize deliberately — and keep it consistent

Denormalization is a performance optimization you reach for after measuring, not a starting posture. It’s right when a read is hot, the join is genuinely expensive at your scale, and the duplicated fact rarely changes — a precomputed order_total, a cached comment_count, a search-friendly copy. The non-negotiable: a denormalized copy must be kept consistent by the engine, not by hope. Two clean ways:

-- 1) STORED generated column: derive within the same row, always correct, no trigger
ALTER TABLE order_items
  ADD COLUMN line_total bigint GENERATED ALWAYS AS (qty * unit_price_cents) STORED;

-- 2) trigger: maintain a cross-row aggregate (e.g. orders.item_count) on item change
CREATE FUNCTION bump_item_count() RETURNS trigger AS $$
BEGIN
  UPDATE orders SET item_count = item_count + 1 WHERE id = NEW.order_id;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

A STORED generated column handles same-row derivations for free (no trigger, can’t drift). Cross-row aggregates need a trigger or a periodically-refreshed materialized view — and that’s a real maintenance cost you take on knowingly. The cardinal sin is the Hook’s mistake: copying a fact and then trusting the application to update every copy. Application-maintained denormalization drifts; engine-maintained denormalization doesn’t.

Why this works

Why not denormalize from the start “to be safe on performance”? Because you’re trading a definite, permanent cost (every write must maintain the copies; every change risks drift) against a hypothetical, future read cost you haven’t measured. Normalized OLTP schemas with the right indexes serve the vast majority of workloads with millisecond joins. Premature denormalization is the schema equivalent of premature optimization: it bakes in complexity and a class of consistency bugs to solve a problem you may never have. Normalize, index, measure — and denormalize only the specific hot path that proves it needs it, with the engine maintaining the copy.

Order the steps

Order the schema-design workflow from default posture to last resort:

  1. 1 Normalize: put every fact in exactly one place
  2. 2 Model with real Postgres types/constraints, not stringly-typed columns
  3. 3 Use surrogate primary keys plus natural UNIQUE constraints
  4. 4 Add indexes for the hot query paths and measure with EXPLAIN ANALYZE
  5. 5 Only if a measured read is still too slow, denormalize that specific path
  6. 6 Keep the denormalized copy consistent via a generated column or trigger
Quiz

An order row copies the customer's email. The customer changes their email. What's the core problem with this denormalized design?

Quiz

You decide a denormalized line_total (qty × unit_price) is worth caching on order_items. How should you keep it consistent?

Recall before you leave
  1. 01
    What does normalization buy you, what does it cost, and why is it the default?
  2. 02
    Surrogate vs natural keys — which for the primary key, and where do natural keys belong?
  3. 03
    When should you denormalize, and how do you keep the copy from drifting?
Recap

Schema design is the discipline of where a fact lives. The default is to normalize so each fact lives in exactly one place, which makes updates consistent by construction and keeps OLTP joins cheap — you start here always. Throughout, model with Postgres’s real types and constraints — enums, ranges with EXCLUDE, numeric/cents, timestamptz, arrays, jsonb, and domains — so the engine enforces what a stringly-typed schema would leave to fragile application code. Choose surrogate primary keys (stable, compact bigint identity or uuid) for join targets and keep natural keys as UNIQUE constraints, promoting a natural key to primary only when it’s genuinely immutable and externally meaningful. Denormalization is a measured, last-resort optimization for a proven hot read path, never a starting posture — and a denormalized copy must be maintained by the engine: a STORED generated column for same-row derivations (can’t drift) or a trigger / materialized view for cross-row aggregates, never application code that silently lets copies disagree. Now when you see a schema where the customer’s name is copied into every order row, or a status text column happily holding 'Paid', 'paid', and 'PAID' — you’ll know the structural problem and the fix: normalize the fact, type the column, let the engine own the rule.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.