Denormalization
Normalization (1NF through 3NF and beyond) optimizes a schema for correctness: it eliminates redundancy so that every fact is stored exactly once, and update anomalies become impossible. Denormalization is the deliberate, informed reversal of that process — reintroducing some redundancy on purpose, in specific places, to make reads faster or simpler. It is not a mistake or a shortcut for someone who does not know normalization; it is a targeted trade-off made after the fact, once real query patterns and performance numbers are known.
Why a fully normalized schema can be slow
A properly normalized schema often spreads a single "answer" a page needs across several tables, connected by foreign keys. Rendering a product page might require joining products, categories, reviews, and an aggregate rating computed from potentially thousands of review rows. Each of those joins and aggregations is correct, but it costs CPU and I/O every single time the page is requested — even though the underlying data, like a product's average rating, might change relatively rarely compared to how often it is read.
Common denormalization techniques
A few patterns cover most real-world denormalization:
Cached aggregates — storing a precomputed value like order_total or average_rating directly on a row instead of recalculating it from child rows on every read.
Duplicated columns — copying a frequently-needed column (like a product_name) onto a related table so a join is not required just to display it.
Flattened one-to-many relationships — storing a small, fixed set of related values as columns on the parent row instead of a separate child table, when the set is genuinely small and rarely changes shape.
Precomputed summary tables — a separate table that periodically rolls up detailed data into the shape a dashboard or report actually queries.
Worked example: a cached order total
Without denormalization, an order's total has to be computed by summing its line items every time it is needed:
Fully normalized: total is always recalculated
CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT NOT NULL ); CREATE TABLE order_items ( order_id INT, product_id INT, quantity INT, unit_price NUMERIC(10, 2), PRIMARY KEY (order_id, product_id) ); -- Every time the total is needed: SELECT order_id, SUM(quantity * unit_price) AS order_total FROM order_items WHERE order_id = 1001 GROUP BY order_id;
If order history is displayed on every page load for a busy account, recomputing this sum constantly adds up. Denormalizing by caching the total directly on the orders row turns that recurring aggregation into a single indexed lookup:
Denormalized: total is cached and kept in sync
ALTER TABLE orders ADD COLUMN order_total NUMERIC(10, 2) NOT NULL DEFAULT 0; -- Recompute and store the total whenever line items change UPDATE orders SET order_total = ( SELECT SUM(quantity * unit_price) FROM order_items WHERE order_items.order_id = orders.order_id ) WHERE order_id = 1001; -- Reads are now a plain column lookup SELECT order_id, order_total FROM orders WHERE customer_id = 42;
The cost of this speedup is that order_total can now drift out of sync with the underlying order_items rows if the code path that updates it is ever skipped — for example, a bulk data-fix script that touches order_items directly without also recalculating the cached total. Keeping the cache correct usually means updating it inside the same transaction as the line-item change, or maintaining it with a trigger.
Trade-offs
Aspect | Normalized | Denormalized |
|---|---|---|
Read performance | Slower — requires joins/aggregation | Faster — data is pre-shaped for the read |
Write complexity | Simple — update one place | More complex — must keep copies in sync |
Storage | Minimal, no redundancy | Higher, redundant data stored |
Risk of inconsistency | Low — each fact stored once | Higher — copies can drift if not maintained carefully |
Best suited for | Write-heavy, transactional workloads | Read-heavy workloads with well-understood query patterns |
Materialized views (covered separately) offer a more structured way to get many of the same benefits as manual denormalization — the database maintains a precomputed result set on your behalf, following a refresh strategy you control, instead of requiring hand-written triggers or application code to keep duplicated columns in sync.
Denormalization is a deliberate trade of write simplicity and storage for read performance.
Common techniques include cached aggregates, duplicated columns, and precomputed summary tables.
Every denormalized value needs a clear plan for how it stays in sync with its source of truth.
Normalize first, then denormalize specific, measured hot paths — not the whole schema up front.