Denormalization: Add Redundancy with a Maintenance Plan
Normalization separates facts so each has a clear home. Denormalization deliberately stores an additional copy or precomputed result to make a particular read cheaper. It creates a new responsibility: keep that copy correct, or explicitly allow it to lag.
Start with a concrete slow read, not with the assumption that joins are always expensive.
Example: show an order's total
In the bookstore, an order's amount can be calculated from its lines:
order 101:
line 1: 2 × 15.00 = 30.00
line 2: 1 × 25.00 = 25.00
total: 55.00
A query can calculate this using SUM(quantity * purchase_price). If every order-history page repeatedly calculates totals for many orders, storing an order_total may be useful.
The line items remain the source of truth: the facts from which the total is derived. The stored total is an additional representation of those facts.
Follow the update, not only the fast read
Suppose staff change line 1's quantity from two to three. The correct total becomes 70.00.
If the application changes the line but leaves order_total = 55.00, the fast read is now wrong. If two applications maintain the total differently, the same order can produce different answers depending on the query.
The performance benefit therefore has to include both sides:
| Operation | Calculate from lines | Store a total |
|---|---|---|
| Read the total | Read and aggregate relevant lines. | Read the saved total. |
| Change a line | Change the line. | Change the line and maintain the total. |
| Diagnose disagreement | One representation to inspect. | Compare the total with its source lines. |
Implement a stored total and its maintenance path
Use the fresh SQLite bookstore setup. This example adds a derived field to orders while leaving line items authoritative:
ALTER TABLE orders ADD COLUMN cached_total_cents INTEGER;
UPDATE orders
SET cached_total_cents = (
SELECT COALESCE(SUM(i.quantity * i.unit_price_cents), 0)
FROM order_items AS i
WHERE i.order_id = orders.order_id
);
SELECT order_id, cached_total_cents FROM orders ORDER BY order_id;
| order_id | cached_total_cents |
|---|---|
| 101 | 5500 |
| 102 | 1500 |
| 103 | 5000 |
A correlated subquery calculates each order's total; COALESCE chooses zero for an order without lines. The nullable field lets the initial migration represent “not computed yet.” After backfilling and checking it, an engine-appropriate migration can enforce a stronger required-value rule if needed.
Follow a complete write rather than updating only the display field
BEGIN;
UPDATE order_items SET quantity = 3 WHERE order_id = 101 AND line_number = 1;
UPDATE orders
SET cached_total_cents = (
SELECT COALESCE(SUM(quantity * unit_price_cents), 0)
FROM order_items WHERE order_id = 101
)
WHERE order_id = 101;
SELECT cached_total_cents FROM orders WHERE order_id = 101;
ROLLBACK;
The query sees 7000 inside the transaction. Rollback returns both the quantity and the cached total to their earlier values. Atomic grouping prevents committing just one change, but all writers still need a concurrency-safe maintenance policy. The example relies on one SQLite writer at a time; do not infer that this recomputation is safe under every multi-writer engine and isolation level.
Input order lines before the transaction:
| order_id | line_number | quantity | unit_price_cents |
|---|---|---|---|
| 101 | 1 | 2 | 1500 |
| 101 | 2 | 1 | 2500 |
The query inside the transaction returns:
| cached_total_cents |
|---|
| 7000 |
After rollback, the stored order and line return to:
| order_id | line_number | quantity | cached_total_cents |
|---|---|---|---|
| 101 | 1 | 2 | 5500 |
| 101 | 2 | 1 | 5500 |
For a server database, concurrent edits can require locking the owning order, applying safe deltas, or serializable transactions with retries. A trigger can centralize maintenance but must handle inserts, updates, deletes, moves between orders, and multi-row operations. A trigger that updates only for inserts would leave deletions wrong.
Detect drift and rebuild
SELECT o.order_id, o.cached_total_cents,
COALESCE(SUM(i.quantity * i.unit_price_cents), 0) AS actual_total_cents
FROM orders AS o
LEFT JOIN order_items AS i ON i.order_id = o.order_id
GROUP BY o.order_id, o.cached_total_cents
HAVING o.cached_total_cents IS NULL
OR o.cached_total_cents <> COALESCE(SUM(i.quantity * i.unit_price_cents), 0)
ORDER BY o.order_id;
Example input tables are the same bookstore orders and order_items tables above. When cached totals match their source lines, the drift query returns no rows:
| order_id | cached_total_cents | actual_total_cents |
|---|---|---|
| (no rows) |
The correctly initialized sample returns no rows. Run the original backfill expression to rebuild totals from the source. Reconciliation is part of the design: “the application normally updates it” is not enough when bugs, old clients, or imports can bypass that path.
Choose how the copy is maintained
In the same transaction: the line change and total update commit together. All writers must follow the maintenance rule, or database-side logic must enforce it. Concurrent changes still need safe coordination.
By background refresh: a worker periodically recomputes totals or summaries. Readers can see an older result. Record refresh progress and decide how much staleness is acceptable.
Through a materialized view: the database stores a query result using its supported refresh model. This reduces custom code but has engine-specific costs and restrictions.
A transaction is a group of work that commits together or rolls back. A refresh is the operation that updates a derived copy. They solve different parts of the problem.
Distinguish redundancy from historical facts
A purchase price belongs to an order line even if the product has a current price elsewhere. Those values describe different times and are not duplicate copies of one fact.
Similarly, an invoice may need the billing address as issued, not the customer's current address. Define which question a field answers before replacing it with a reference or labeling it denormalization.
Measure before introducing another representation
First inspect the query, its indexes, and the number of rows actually read. A suitable index or smaller result may remove the bottleneck without duplicating data.
If denormalization is still justified, document:
- Which read it accelerates.
- Which data is authoritative.
- Which writes must update the copy.
- Whether readers may see stale values.
- How to rebuild and reconcile the copy after an error.
An index adds an access path to existing data; a stored total adds another representation of a calculation. A cache is another copy with its own expiry or invalidation rules. Do not treat these mechanisms as identical.
Compare four concrete techniques
| Technique | Read saved | Additional write responsibility |
|---|---|---|
| Redundant column | Avoid fetching a related value | Keep the copy aligned with its authoritative owner |
| Precomputed aggregate | Avoid repeated sums or counts | Apply every relevant source change or refresh |
| Separate read table | Present a flattened report in one lookup | Rebuild and synchronize the entire projection |
| Embedded document | Load a bounded aggregate as one record | Decide which shared facts are duplicated and how they change |
A projection is a representation prepared for a particular read. A customer-order dashboard might use a row containing the order identifier, current customer display name, and total. It is useful only if readers know whether it means current state, an as-issued snapshot, or a delayed report.
PostgreSQL materialized views provide a managed stored result
This independent PostgreSQL example requires a database where you can create objects. It keeps the setup small so it does not depend on SQLite's dialect:
-- PostgreSQL
CREATE TABLE denorm_sales (
sale_id INTEGER PRIMARY KEY,
sold_on DATE NOT NULL,
amount_cents BIGINT NOT NULL
);
INSERT INTO denorm_sales VALUES (1, DATE '2025-01-10', 3000),
(2, DATE '2025-01-10', 2500);
CREATE MATERIALIZED VIEW denorm_daily_sales AS
SELECT sold_on, SUM(amount_cents) AS revenue_cents
FROM denorm_sales GROUP BY sold_on;
SELECT sold_on, revenue_cents FROM denorm_daily_sales ORDER BY sold_on;
Input rows in denorm_sales:
| sale_id | sold_on | amount_cents |
|---|---|---|
| 1 | 2025-01-10 | 3000 |
| 2 | 2025-01-10 | 2500 |
The initial materialized-view result is:
| sold_on | revenue_cents |
|---|---|
| 2025-01-10 | 5500 |
The initial result is January 10 with revenue 5500. Adding a sale does not automatically update this ordinary PostgreSQL materialized view:
-- PostgreSQL
INSERT INTO denorm_sales VALUES (3, DATE '2025-01-10', 1500);
REFRESH MATERIALIZED VIEW denorm_daily_sales;
SELECT sold_on, revenue_cents FROM denorm_daily_sales ORDER BY sold_on;
After inserting sale 3 and refreshing the view, it returns:
| sold_on | revenue_cents |
|---|---|
| 2025-01-10 | 7000 |
After refresh it shows 7000. A concurrent refresh has extra requirements, including a suitable unique index; it is not the default behavior merely because the view exists. See PostgreSQL materialized views.
This can fit a report that accepts refresh delay. It is a poor substitute for a checkout decision that must use current stock. An ordinary view stores a query definition; a materialized view stores its result according to its refresh model.
Document and query-oriented stores make the same tradeoff visible
MongoDB can embed a bounded set of order lines inside the order document, making one-order reads convenient. Cassandra often maintains different tables for different keyed queries. These are concrete reasons a model can use deliberate redundancy, not reasons to copy every field indiscriminately.
A background projection needs a version or progress marker, retry handling, and a policy for duplicate or out-of-order changes. If a consumer applies an increment twice, its total drifts even when every event was valid. Rebuilding from authoritative data and comparing results gives an escape path when the incremental copy becomes suspect.
Decide whether the measured benefit justifies the cost
Compare response-time distribution, source rows read, update cost, storage growth, and reconciliation complexity. Test typical and unusually large orders. Check whether the latest display must include a just-committed write. A faster stale total is not an equivalent result if the application promised a current amount.
Document the source, refresh or update mechanism, permitted delay, ownership, and rebuild procedure beside the schema. Retire a projection when the workload no longer needs it instead of carrying its write cost indefinitely.
Check your understanding
- What should happen to order 101's total if a line is changed?
- Why is “update it in the application” incomplete when several programs write the data?
- When would a daily sales summary allow delayed refresh, while a checkout amount would not?
- Why is purchase price different from a duplicated current price?
Continue with indexing strategies for a way to improve access without adding business facts. Materialized views develops the stored-summary approach.