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.
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_statusenum (orCHECK ... IN) over a free-textstatus textthat can hold'Paid','paid', and'PAID'; - a
tstzrangewith anEXCLUDEconstraint over twotextcolumns holding ISO strings the app must parse and compare; numeric/integer cents over atext“amount”;timestamptzover atextdate; atags text[](or join table) over a comma-joined string;- a
jsonbdocument 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
idnever 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
UNIQUEconstraints (you still wantusers.email UNIQUE), and occasionally as the primary key when the value is genuinely immutable and externally meaningful — acurrencies(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 schema-design workflow from default posture to last resort:
- 1 Normalize: put every fact in exactly one place
- 2 Model with real Postgres types/constraints, not stringly-typed columns
- 3 Use surrogate primary keys plus natural UNIQUE constraints
- 4 Add indexes for the hot query paths and measure with EXPLAIN ANALYZE
- 5 Only if a measured read is still too slow, denormalize that specific path
- 6 Keep the denormalized copy consistent via a generated column or trigger
An order row copies the customer's email. The customer changes their email. What's the core problem with this denormalized design?
You decide a denormalized line_total (qty × unit_price) is worth caching on order_items. How should you keep it consistent?
- 01What does normalization buy you, what does it cost, and why is it the default?
- 02Surrogate vs natural keys — which for the primary key, and where do natural keys belong?
- 03When should you denormalize, and how do you keep the copy from drifting?
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.
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.