Denormalization And Practical Tradeoffs
Denormalization deliberately stores data in a shape that duplicates or precomputes facts to improve a measured read path, reporting workflow, or system boundary. It is not the absence of database design. A responsible denormalized model defines a source of truth, update mechanism, acceptable delay, failure handling, and rebuild strategy.
Normalize first to understand the dependencies. Denormalize only when a concrete requirement justifies the extra consistency work.
Recognize Several Different Techniques
Denormalization can mean:
- copying a display value beside a foreign key;
- storing a calculated total;
- maintaining a summary table;
- building a materialized view;
- creating a search document;
- maintaining a read model for a specific screen;
- caching a joined result in Redis;
- duplicating data across service boundaries.
These techniques have different consistency and operational properties. Do not describe all of them merely as "a cache."
Begin With A Measured Read Problem
Suppose an administrative dashboard repeatedly aggregates millions of order lines to display daily revenue. The normalized query is correct but exceeds the agreed response time under realistic load.
Before duplicating data:
- Inspect the query plan.
- Add or correct indexes.
- Remove application N+1 queries.
- Select only required columns.
- Check pagination and date boundaries.
- Measure database and network time.
- Define the required freshness.
If the aggregate remains too expensive, a daily summary table may be justified.
CREATE TABLE daily_sales_summary (
sales_date DATE PRIMARY KEY,
order_count BIGINT NOT NULL,
gross_revenue_pence BIGINT NOT NULL,
refreshed_at TIMESTAMP NOT NULL
);
This table is derived from authoritative orders and lines. It should not silently become a second independent source of sales truth.
Name The Source Of Truth
Every duplicated fact needs one authoritative owner. For daily revenue, completed orders and their lines may be authoritative; the summary is rebuildable.
Document:
Source: completed orders and order_lines
Derived model: daily_sales_summary
Update mode: incremental event processing plus nightly reconciliation
Freshness target: under five minutes
Repair: recompute a selected date range from source rows
Without this information, future developers may update the summary directly, attempt two-way synchronization, or trust stale values during reconciliation.
Two writable sources for one fact create conflict policy. Avoid that unless the domain genuinely requires distributed ownership and has explicit resolution rules.
Historical Snapshots Are Not Mere Performance Copies
An order line storing unit_price_pence_at_purchase duplicates a value that once came from the product catalogue, but it represents a different historical fact. It should not change when the current product price changes.
Likewise, an invoice may preserve the legal customer name and billing address used at issue time. These snapshots support audit and customer communication. They are not rebuildable from current customer records.
Name historical values clearly and define when they are captured. Do not run a synchronization job that overwrites them with current data.
Derived Columns Need One Update Path
Storing quantity, unit price, and line total creates an invariant:
line_total_pence = quantity * unit_price_pence
Possible approaches include calculating the total at read time, using a generated column, or storing it through one controlled write path.
If PHP calculates and stores the value, protect it with tests and, where practical, a database check:
CHECK (line_total_pence = quantity * unit_price_pence)
Be aware of numeric overflow, rounding rules, discounts, and tax allocation. Financial totals often cannot be reconstructed from a simplistic multiplication after promotions and rounding, so the stored components and rules must match the business meaning.
Synchronous Updates Increase Write Coupling
One strategy updates the normalized source and derived table in the same transaction:
<?php
declare(strict_types=1);
function recordCompletedOrder(PDO $pdo, string $date, int $totalPence): void
{
$pdo->beginTransaction();
try {
$order = $pdo->prepare(
'INSERT INTO orders (completed_date, total_pence, status)
VALUES (:date, :total, :status)'
);
$order->execute(['date' => $date, 'total' => $totalPence, 'status' => 'completed']);
$summary = $pdo->prepare(
'INSERT INTO daily_sales_summary
(sales_date, order_count, gross_revenue_pence, refreshed_at)
VALUES (:date, 1, :total, CURRENT_TIMESTAMP)
ON CONFLICT (sales_date) DO UPDATE SET
order_count = daily_sales_summary.order_count + 1,
gross_revenue_pence = daily_sales_summary.gross_revenue_pence + excluded.gross_revenue_pence,
refreshed_at = CURRENT_TIMESTAMP'
);
$summary->execute(['date' => $date, 'total' => $totalPence]);
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
}
The exact upsert syntax varies by database. A same-database transaction provides immediate consistency but adds work and contention to every write. Corrections, cancellations, and backdated changes must update the summary correctly too.
Asynchronous Updates Trade Freshness For Decoupling
Another strategy commits the source change and an outbox event. A worker updates the read model later. This reduces request work and can maintain models in another database or search engine.
The read model is eventually consistent. Product requirements must accept and communicate the delay. A dashboard can display refreshed_at; a payment confirmation page may need to read authoritative state instead.
Workers need idempotency. If OrderCompleted is delivered twice, the summary must not count it twice. Store processed event IDs, use idempotent upserts keyed by event identity, or rebuild aggregates from authoritative facts.
Ordering also matters. A cancellation event processed before its completion event can corrupt an incremental total unless versions or ordering constraints are enforced.
Materialized Views And Summary Tables
A database materialized view stores a query result and refreshes it according to database capabilities. It can simplify ownership because the derivation remains defined in SQL. Refresh behavior, locking, incremental support, and indexing vary by engine.
A custom summary table provides more control but requires application or job code. It can support incremental updates and domain-specific columns, while creating more opportunities for drift.
Choose based on refresh requirements, database support, operational ownership, and query complexity. Test refresh time and its effect on production traffic.
Read Models Are Shaped For Use Cases
A read model can join and flatten data for one screen or API:
order_id
customer_display_name
shipping_country
item_count
formatted_total
last_status
This shape may not be suitable for writes. Treat it as a projection. Commands update the authoritative model; projection handlers update or rebuild the read side.
CQRS terminology is sometimes used when read and write models differ, but a separate class or view does not require a complex distributed architecture. A small application can use a dedicated reporting query object without adopting event sourcing.
Search Indexes Are Derived Models
Elasticsearch, OpenSearch, Meilisearch, and similar systems commonly store denormalized search documents. A product document may include category names, searchable descriptions, availability, and popularity.
The relational database remains authoritative for transactional state. Search results can lag or omit a recently updated product. The application should know whether a final product page must re-check price and stock in the primary store.
Provide full reindexing, incremental updates, version handling, and monitoring for failed documents. Never rely on an index that cannot be reconstructed.
Caches Are Temporary Denormalization
A cached response or object duplicates data for faster access. It requires cache keys, expiry, invalidation, stampede protection, and a fallback when the cache is unavailable.
Cache-aside behavior is common:
<?php
declare(strict_types=1);
function productSummary(CacheInterface $cache, ProductRepository $products, int $id): array
{
$key = "product-summary:$id:v1";
$cached = $cache->get($key);
if (is_array($cached)) {
return $cached;
}
$summary = $products->summary($id);
$cache->set($key, $summary, 300);
return $summary;
}
The database remains authoritative. The code must still work after a cache miss or eviction. Type and version checks help prevent stale serialized shapes from causing errors after deployment.
Duplication Across Services Has Ownership Costs
A shipping service may copy customer address data from an ordering service so it can operate independently. This is more than a database optimization: it creates a service boundary and consistency contract.
Decide whether the copied address is a historical shipment snapshot or a current customer address. Decide how updates arrive, what ordering guarantees exist, and how missing events are reconciled. Avoid synchronous request chains merely to simulate one normalized database across services.
Service autonomy can justify duplication, but only with explicit ownership and failure semantics.
Design Rebuilds Before Production
A derived model will eventually drift because of a bug, missed event, manual correction, or schema change. The rebuild path is part of the feature.
A safe rebuild may:
- Create a new version of the derived table or index.
- Read authoritative source data in bounded chunks.
- Build deterministic records.
- Compare counts and totals.
- Switch readers atomically.
- Retain the old version briefly for rollback.
- Remove it after validation.
For in-place repair, define date or key ranges and avoid locking the full source. Record progress so a failed rebuild can resume.
Reconcile Incremental Models
Incremental updates are fast but sensitive to missed or duplicated work. Periodic reconciliation compares the derived result with authoritative data.
For financial summaries, recompute daily totals and compare them with stored summaries. Alert on differences before overwriting them so the underlying bug is investigated. Keep audit evidence for corrections.
Checksums, row counts, maximum source versions, event offsets, and aggregate totals can all help. The right signals depend on the model.
Denormalization Can Hide Security Bugs
Copied authorization or tenant data can become stale. A cached role, account status, or ownership field may continue granting access after revocation.
Security-sensitive decisions often require authoritative or tightly invalidated data. Keep cache lifetimes short where appropriate, publish revocation events, and fail safely when required state cannot be verified.
Never copy sensitive values merely for convenience. Minimize personal data in analytics, search, logs, and caches. Apply retention and deletion requirements to every duplicate.
Measure The Whole Tradeoff
A faster read can create slower writes, larger storage, more deployment steps, new workers, cache incidents, reconciliation jobs, and debugging complexity.
Measure:
- read latency and throughput improvement;
- write amplification;
- storage growth;
- refresh lag;
- rebuild duration;
- error and drift rate;
- operational support cost;
- behavior during cache or worker failure.
A denormalized model that saves 5 milliseconds on a low-traffic page may not earn its complexity. A summary that reduces a critical report from minutes to seconds may clearly do so.
Review Checklist
Before adding duplicated data, require answers to:
- What measured problem does this solve?
- What is the authoritative source?
- Is the copy historical, derived, cached, or independently owned?
- How and when is it updated?
- What freshness is acceptable?
- How are duplicate and out-of-order events handled?
- How is drift detected?
- Can the model be rebuilt deterministically?
- What happens when the derived store is unavailable?
- Does duplication expand security or privacy risk?
What You Should Be Able To Do
After this lesson, you should be able to distinguish historical snapshots from performance copies, select between synchronous updates, asynchronous projections, materialized views, caches, and search documents, and state the consistency tradeoff of each.
You should also be able to demand a source of truth, freshness target, idempotent update design, reconciliation process, and rebuild procedure before accepting denormalization. Duplication is safe only when its ownership and failure behavior are designed as carefully as its fast read path.
Practice
Review A Summary Table
A team proposes daily_sales_summary because its dashboard is slow. Write the questions that must be answered before approval, then define a viable source of truth, freshness target, update mechanism, and repair path.
Show solution
First require the measured query plan, latency target, traffic, and evidence that indexes and N+1 queries were addressed. Completed orders and lines remain authoritative. The summary may target five-minute freshness, update from idempotent outbox events, and display refreshed_at.
A nightly reconciliation recomputes each day and alerts on differences. A range-based rebuild recreates selected dates from authoritative rows. Dashboard failure falls back to a slower report or a clear unavailable state rather than accepting writes to the summary.
Design A Rebuildable Cache
Design cache-aside storage for product summaries. Specify the key version, TTL, miss behavior, invalidation after product changes, stampede protection, and deployment behavior when the cached value shape changes.
Show solution
Use a short lock or stale-while-revalidate policy to prevent many workers rebuilding one hot key. A new key version prevents old serialized shapes being read after deployment. Cache failure falls back to the repository with rate and load protection; the cache contains no unique source data.
Choose The Source Of Truth
For each, state whether it is authoritative, historical, derived, or cached and describe whether later source updates should overwrite it.
Show solution
- Current product price is authoritative catalogue data and may change through controlled writes.
- Order-line purchase price is a historical snapshot and must not follow later catalogue changes.
- Search-index product name is derived and should receive updates or be rebuilt.
- A cached authorization decision is temporary and needs strict invalidation or short expiry; sensitive checks may bypass it.
- The dispatched parcel address is a historical fulfillment fact and should not change when the customer edits their current address.