Denormalisation duplicates data across tables to eliminate expensive JOINs at read time
Five main techniques: flattening, stored aggregates, column duplication, vertical partitioning, materialised views
Read query speed can improve 1000x+ for complex JOINs — but write latency and consistency risk increase
Production teams must build reconciliation jobs: drift is inevitable, detection is mandatory
Biggest mistake: denormalising without profiling — a missing index is often the real bottleneck
✦ Definition~90s read
What is Denormalisation in Databases?
Denormalisation is the deliberate introduction of redundancy into a relational database schema, typically by merging tables or pre-joining data, to reduce the number of JOINs required at query time. It exists because normalised schemas, while excellent for write consistency and storage efficiency, often fail under read-heavy workloads at scale — think 10,000+ QPS on a Postgres or MySQL instance where every read fans out across five tables.
★
Imagine you run a library.
The core trade-off is simple: you trade write complexity and data consistency for read performance. Real-world examples include e-commerce product pages (pre-joining inventory, pricing, and reviews into a single denormalised view) or analytics dashboards that flatten star schemas into wide tables for sub-millisecond lookups.
Where it fits: denormalisation is a tactical optimization for hot paths, not a schema design default. Alternatives include materialised views (Postgres, BigQuery), read replicas, or caching layers like Redis or CDN edge caches — use those first. Avoid denormalisation when your write-to-read ratio is above 1:10, when you lack automated drift detection, or when your team can't commit to maintaining sync logic (triggers, application-level dual-writes, or CDC pipelines).
The real pain point — and the subject of this article — is trigger drift: the silent, creeping inconsistency that occurs when denormalised columns are updated via database triggers that miss edge cases, race conditions, or schema migrations, leaving your data silently corrupted.
Plain-English First
Imagine you run a library. Normally you keep one master card per book listing its location, author, and genre — that way you only ever update one card when something changes. But if a thousand people ask 'show me every sci-fi book by its author' every minute, you'd be exhausted flipping between cards. So you print a pre-made poster on the wall that lists everything together — redundant, yes, but blazing fast to read. Denormalisation is that poster: you deliberately duplicate data so reads are instant, accepting that you'll do extra work whenever data changes.
Every high-traffic system you've ever admired — Twitter's timeline, Amazon's product pages, Netflix's recommendation feed — is quietly violating database textbook rules at scale. Not by accident, but by design. Denormalisation is the deliberate, calculated decision to trade write complexity for read speed, and understanding when and how to do it separates engineers who can reason about production systems from those who are still copy-pasting stack overflow answers.
The problem denormalisation solves is deceptively simple: normalised schemas are optimised for data integrity and storage efficiency, but they force the database to perform expensive JOINs across multiple tables on every read. At low traffic this is invisible. At 50,000 reads per second it becomes the reason your on-call phone rings at 3am. When your query plan is joining six tables, sorting, and aggregating to serve a single page render, you have a structural mismatch between your data model and your access pattern.
By the end of this article you'll be able to identify which parts of a normalised schema are causing read bottlenecks, choose the right denormalisation technique for the situation (there are at least five distinct patterns), implement them safely with the SQL and application-layer strategies that production teams actually use, and know exactly which mistakes will silently corrupt your data if you get it wrong.
Here's the blunt truth: denormalisation doesn't fix lazy queries. It fixes structural read pressure. Profile first, then denormalise. If you skip profiling, you're guessing — and production doesn't forgive guesses.
Why Denormalisation Is a Trade-Off, Not a Shortcut
Denormalisation is the deliberate introduction of redundant data into a database schema, merging tables that would otherwise be normalised to reduce the number of joins at read time. The core mechanic is simple: you copy a value (e.g., a user's display name) into multiple rows or tables so a single query can return everything without joining. This trades write-time consistency for read-time speed.
In practice, denormalisation means you accept multiple sources of truth for the same logical fact. Every time the source value changes, you must update every copy — or accept that some reads will return stale data. The cost is not just extra writes; it's the complexity of ensuring all copies converge. Without a synchronisation mechanism (e.g., a trigger, a scheduled job, or eventual consistency via a message queue), the copies drift apart silently.
Use denormalisation only when read performance is the bottleneck and the write-to-read ratio is heavily skewed — for example, a social feed where a user's profile name is read millions of times but updated rarely. Even then, you must instrument drift detection. The real systems that fail are those that denormalise first and ask forgiveness later, ending up with inconsistent dashboards and corrupted aggregates.
Drift Is Inevitable Without Enforcement
A trigger-based sync is not a guarantee — it can be disabled, skipped during bulk loads, or fail silently. Always add a reconciliation job.
Production Insight
A team denormalised order totals into a customer summary table to speed up reporting. A bug in the trigger missed updates for refunds, causing the finance dashboard to show $2M in phantom revenue for three months.
Symptom: aggregate reports that never matched the sum of individual line items, but no error logs because the trigger simply didn't fire on certain update paths.
Rule of thumb: every denormalised field must have a periodic reconciliation query that alerts if the source and copy diverge by more than 0.1%.
Key Takeaway
Denormalisation buys read speed at the cost of write complexity and data consistency.
Always pair denormalisation with a drift detection and repair mechanism.
Never denormalise a field that changes more than once per thousand reads.
thecodeforge.io
Denormalisation Trigger Drift Pitfalls
Denormalisation Databases
Why Normalisation Breaks Down Under Real Read Loads
Third Normal Form (3NF) is beautiful in theory. Every fact lives in exactly one place, foreign keys enforce relationships, and your UPDATE anomalies vanish. The database as a single source of truth. But a normalised schema is an instruction manual — it tells you where all the pieces are, but you have to assemble the answer on every single read.
Consider an e-commerce order summary page. To render 'Order #4821 — 3 items — shipped to John Smith — via FedEx — total $127.50' from a 3NF schema, you'd typically JOIN orders, order_items, products, customers, addresses, and shipping_carriers. PostgreSQL or MySQL must load pages from each of those tables, build hash joins or nested loop joins in memory, and garbage-collect the intermediate result set — all before sending a single byte to your application.
The query planner is smart, but physics isn't. Each additional table multiplies I/O surface area. With millions of rows, even indexed JOINs produce enormous intermediate row sets that spill to disk. This is the fundamental tension: normalisation optimises for correctness and write performance; denormalisation optimises for read performance at the cost of write complexity and storage. Neither is universally correct — picking the wrong one for your workload is a production incident waiting to happen.
The inflection point is usually around a 10:1 read-to-write ratio. Below that, normalise aggressively. Above it, denormalisation starts paying for itself. Most consumer-facing applications live at 100:1 or higher.
normalised_order_query.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
-- ─────────────────────────────────────────────────────────────────-- NORMALISED SCHEMA: 3NF compliant, six-table JOIN to render one-- order summary row. Clean data model, painful at high read volume.-- ─────────────────────────────────────────────────────────────────CREATETABLEcustomers (
customer_id SERIALPRIMARYKEY,
full_name VARCHAR(120) NOTNULL,
email VARCHAR(255) NOTNULLUNIQUE
);
CREATETABLEaddresses (
address_id SERIALPRIMARYKEY,
customer_id INTREFERENCEScustomers(customer_id),
street VARCHAR(200),
city VARCHAR(100),
postcode VARCHAR(20)
);
CREATETABLEshipping_carriers (
carrier_id SERIALPRIMARYKEY,
carrier_name VARCHAR(80) NOTNULL-- e.g. 'FedEx', 'UPS'
);
CREATETABLEorders (
order_id SERIALPRIMARYKEY,
customer_id INTREFERENCEScustomers(customer_id),
address_id INTREFERENCESaddresses(address_id),
carrier_id INTREFERENCESshipping_carriers(carrier_id),
ordered_at TIMESTAMPTZDEFAULTNOW()
);
CREATETABLEproducts (
product_id SERIALPRIMARYKEY,
product_name VARCHAR(200) NOTNULL,
unit_price NUMERIC(10,2) NOTNULL
);
CREATETABLEorder_items (
item_id SERIALPRIMARYKEY,
order_id INTREFERENCESorders(order_id),
product_id INTREFERENCESproducts(product_id),
quantity INTNOTNULL,
line_total NUMERIC(10,2) NOTNULL-- quantity * unit_price at time of order
);
-- ─────────────────────────────────────────────────────────────────-- The six-table JOIN required to render a single order summary.-- EXPLAIN ANALYSE this on 1M orders and watch the planner sweat.-- ─────────────────────────────────────────────────────────────────SELECT
o.order_id,
c.full_name AS customer_name,
a.city AS shipping_city,
sc.carrier_name AS carrier,
COUNT(oi.item_id) AS item_count,
SUM(oi.line_total) AS order_total
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN addresses a ON a.address_id = o.address_id
JOIN shipping_carriers sc ON sc.carrier_id = o.carrier_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE o.order_id = 4821GROUPBY o.order_id, c.full_name, a.city, sc.carrier_name;
Time: 4.823 ms ← acceptable at low volume, devastating at 50k req/s
The 10:1 Rule of Thumb:
Start evaluating denormalisation seriously when your read-to-write ratio exceeds 10:1 on a given table cluster. Profile with EXPLAIN (ANALYSE, BUFFERS) in PostgreSQL or EXPLAIN FORMAT=JSON in MySQL to see actual buffer hits versus sequential scans before making any schema changes.
Production Insight
At 50k req/s, a six-table JOIN can cause connection pool exhaustion and 5xx errors.
Profile with pg_stat_statements before blaming the schema.
Rule: measure first — the bottleneck may be a missing index or bad plan, not normalisation.
Key Takeaway
Always measure actual query performance before denormalising.
The bottleneck might be a missing index or bad query plan, not normalisation itself.
Five Denormalisation Techniques — With Real Trade-offs for Each
Denormalisation isn't one thing. It's a family of five distinct techniques, each with a different cost-benefit profile. Using the wrong one is like prescribing the right drug for the wrong disease — it'll make things worse.
1. Flattening (pre-joining tables): Copy columns from related tables directly into the primary table. The order summary problem above is solved by storing customer_name, shipping_city, and carrier_name directly on the orders table. Reads become a single table scan. Writes require updating multiple rows if, say, a customer changes their name — this is manageable with triggers or application-layer logic.
2. Storing Derived/Aggregated Values: Pre-compute totals, counts, or averages and store them in a column. An order_total column on orders avoids re-summing order_items on every read. The risk is staleness — your aggregate must be updated atomically with every INSERT/UPDATE/DELETE on the source rows.
3. Column Duplication Across Tables: A softer version of flattening — duplicate only the most-read columns rather than entire row shapes. Useful when you want to avoid the JOIN 90% of the time but still maintain the full relationship.
4. Table Splitting (Vertical Partitioning): Move infrequently-accessed wide columns into a separate table. A users table with a large bio TEXT column accessed only on profile pages shouldn't be loaded on every authentication check. This is the inverse of denormalisation in spirit but solves the same performance problem: row width.
5. Materialised Views: Database-native pre-computed result sets. They're the most elegant form of denormalisation because the duplication is managed by the database engine, not your application code. PostgreSQL's MATERIALIZED VIEW with CONCURRENTLY refresh is production-grade for reporting workloads.
denormalised_order_patterns.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
-- ─────────────────────────────────────────────────────────────────-- TECHNIQUE 1: FLATTENING-- Store frequently-joined data directly on the orders table.-- Trade-off: customer_name here can drift from customers.full_name-- if you update the customer and forget to update past orders.-- That's often DESIRED — you want the name as it was at order time.-- ─────────────────────────────────────────────────────────────────ALTERTABLE orders
ADDCOLUMN customer_name_snapshot VARCHAR(120), -- name at order timeADDCOLUMN shipping_city_snapshot VARCHAR(100), -- address at order timeADDCOLUMN carrier_name_snapshot VARCHAR(80); -- carrier at order time-- Populate on INSERT via application layer or trigger:CREATEORREPLACEFUNCTIONorders_snapshot_on_insert()
RETURNSTRIGGERAS $$
BEGIN-- Pull the human-readable values once, at write timeSELECT c.full_name, a.city, sc.carrier_name
INTONEW.customer_name_snapshot,
NEW.shipping_city_snapshot,
NEW.carrier_name_snapshot
FROM customers c
JOIN addresses a ON a.address_id = NEW.address_id
JOIN shipping_carriers sc ON sc.carrier_id = NEW.carrier_id
WHERE c.customer_id = NEW.customer_id;
RETURNNEW;
END;
$$ LANGUAGE plpgsql;
CREATETRIGGER trg_orders_snapshot
BEFOREINSERTON orders
FOREACHROWEXECUTEFUNCTIONorders_snapshot_on_insert();
-- ─────────────────────────────────────────────────────────────────-- TECHNIQUE 2: STORED AGGREGATE (pre-computed order_total)-- After this column exists, the six-table JOIN collapses to ONE-- table scan — no joins at all for the summary page.-- ─────────────────────────────────────────────────────────────────ALTERTABLE orders
ADDCOLUMN order_total NUMERIC(10,2) DEFAULT0.00;
-- Keep the aggregate current with a trigger on order_items:CREATEORREPLACEFUNCTIONsync_order_total()
RETURNSTRIGGERAS $$
BEGIN-- Recalculate total for the affected order atomicallyUPDATE orders
SET order_total = (
SELECTCOALESCE(SUM(line_total), 0)
FROM order_items
WHERE order_id = COALESCE(NEW.order_id, OLD.order_id)
)
WHERE order_id = COALESCE(NEW.order_id, OLD.order_id);
RETURNNEW;
END;
$$ LANGUAGE plpgsql;
CREATETRIGGER trg_sync_order_total
AFTERINSERTORUPDATEORDELETEON order_items
FOREACHROWEXECUTEFUNCTIONsync_order_total();
-- ─────────────────────────────────────────────────────────────────-- TECHNIQUE 5: MATERIALISED VIEW-- Best for reporting / analytics. Refresh on a schedule or on-demand.-- CONCURRENTLY means reads are never blocked during refresh.-- ─────────────────────────────────────────────────────────────────CREATEMATERIALIZEDVIEW order_summary_mv ASSELECT
o.order_id,
o.customer_name_snapshot AS customer_name,
o.shipping_city_snapshot AS shipping_city,
o.carrier_name_snapshot AS carrier,
o.order_total,
o.ordered_at
FROM orders o
WITHDATA; -- populate immediately-- Create an index so lookups on the MV are still fast:CREATEUNIQUEINDEX idx_order_summary_mv_order_id
ONorder_summary_mv (order_id);
-- Refresh without locking reads (requires the unique index above):REFRESHMATERIALIZEDVIEWCONCURRENTLY order_summary_mv;
-- ─────────────────────────────────────────────────────────────────-- NOW: the read query is trivially fast — single table, no joins.-- ─────────────────────────────────────────────────────────────────SELECT order_id, customer_name, shipping_city, carrier, order_total
FROM order_summary_mv
WHERE order_id = 4821;
Time: 0.312 ms ← ~15x faster; no joins, index-only scan on order_id
Watch Out: Trigger Chains
Triggers that maintain denormalised state can fire other triggers. In PostgreSQL this is called trigger cascading, and it can silently produce deadlocks under concurrent writes. Always test trigger-based denormalisation with pgbench or wrk at realistic concurrency levels before promoting to production. Set statement_timeout as a safety net.
Production Insight
Trigger-based denormalisation can deadlock under concurrent writes if triggers update the same row.
Test with pgbench at realistic concurrency before going to production.
Rule: set statement_timeout and always test with load.
Key Takeaway
Pick the technique that matches your consistency and scalability needs.
Flattening with triggers works for low-concurrency writes; async updates scale better.
Data Consistency Strategies — This Is Where Teams Get Burned
Denormalisation doesn't just add complexity — it moves the responsibility for consistency from the database engine (which is bulletproof) to your application or trigger layer (which isn't). This is the part that textbooks gloss over and production incidents are made of.
There are three strategies for keeping denormalised copies consistent: synchronous triggers, application-layer dual writes, and asynchronous event-driven updates. Each has a failure mode you need to understand before committing.
Synchronous triggers (shown above) run in the same transaction as the originating write. They're atomic — the snapshot is always consistent with the row that created it. The cost is added latency on every write and the risk of trigger overhead becoming a write bottleneck.
Application-layer dual writes mean your service updates both the canonical table and the denormalised copy in the same transaction. This works until your service crashes between the two writes. Partial writes produce silent inconsistencies that are hellish to debug. If you use this pattern, wrap both writes in an explicit transaction and add a background reconciliation job that compares the two tables nightly.
Asynchronous event-driven updates (e.g., Kafka consumer updates a read replica or Elasticsearch index after a database event) accept eventual consistency by design. The read-side may serve stale data for milliseconds to seconds. This is the architecture behind every major content platform — it scales beautifully but requires your product team to explicitly sign off on eventual consistency semantics.
consistency_reconciliation.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- ─────────────────────────────────────────────────────────────────-- RECONCILIATION QUERY: Run as a nightly job or whenever you suspect-- drift between the canonical order_items data and the stored aggregate.-- This is your safety net for the dual-write pattern.-- ─────────────────────────────────────────────────────────────────-- Step 1: Find orders where the stored total disagrees with realitySELECT
o.order_id,
o.order_total AS stored_total,
COALESCE(SUM(oi.line_total), 0) AS real_total,
ABS(o.order_total - COALESCE(SUM(oi.line_total), 0)) AS drift
FROM orders o
LEFTJOIN order_items oi ON oi.order_id = o.order_id
GROUPBY o.order_id, o.order_total
HAVING o.order_total != COALESCE(SUM(oi.line_total), 0)
ORDERBY drift DESCLIMIT100; -- surface the worst offenders first-- Step 2: Heal the drift in a single UPDATE (safe to re-run).-- Use a CTE so we compute the correct totals once and apply them-- in a single pass — no per-row sub-select performance hit.WITH correct_totals AS (
SELECT
order_id,
COALESCE(SUM(line_total), 0) AS recalculated_total
FROM order_items
GROUPBY order_id
)
UPDATE orders o
SET order_total = ct.recalculated_total
FROM correct_totals ct
WHERE ct.order_id = o.order_id
AND ct.recalculated_total != o.order_total; -- only touch drifted rows-- Verify nothing remains:SELECTCOUNT(*) AS drifted_orders
FROM orders o
LEFTJOIN (
SELECT order_id, SUM(line_total) AS real_total
FROM order_items
GROUPBY order_id
) sub ON sub.order_id = o.order_id
WHERE o.order_total ISDISTINCTFROMCOALESCE(sub.real_total, 0);
Output
-- Step 1 output (before healing):
order_id | stored_total | real_total | drift
----------+--------------+------------+-------
10043 | 127.50 | 137.50 | 10.00
9871 | 0.00 | 54.99 | 54.99
(2 rows)
-- Step 2 UPDATE output:
UPDATE 2
-- Step 3 verification:
drifted_orders
----------------
0
(1 row)
Pro Tip: Idempotent Healing Jobs
Write your reconciliation UPDATE so it's safe to run multiple times with no side effects — only touching rows where a real difference exists (the IS DISTINCT FROM check above). Schedule it as a pg_cron job or a nightly Kubernetes CronJob. This turns eventual consistency from a liability into a controlled, auditable process.
Production Insight
Application-layer dual writes are the most common cause of silent drift.
If you must use them, add a reconciliation job from day one — not after data goes bad.
Rule: a dual-write without reconciliation is an incident waiting to happen.
Key Takeaway
Synchronous triggers are atomic but slow; async events scale.
Write a reconciliation query before you need it — not after data goes bad.
Production Gotchas, Benchmarks, and When NOT to Denormalise
Here's the honest part that conference talks skip. Denormalisation solves one class of problems and introduces another. Teams that deploy it without understanding the failure modes end up with a faster system that periodically serves wrong data — which is often worse than a slower correct one.
The storage cost is real. A heavily denormalised OLTP schema can be 2–4x larger than its normalised equivalent. At 500GB this means 1-2TB of extra disk. On cloud storage this is a monthly bill line item. Factor it into your capacity planning.
Schema migrations become explosive. Adding a column to a normalised users table is one ALTER TABLE. Adding the same field to five denormalised copies of user data scattered across your schema is five migrations, five backfills, and five places to get the data-type wrong. This is where denormalised schemas accrue maintenance debt quietly.
OLAP vs OLTP is the core signal. OLTP (transactional, real-time, lots of writes) benefits from normalisation. OLAP (analytics, reporting, read-heavy, batch writes) almost always benefits from denormalisation — this is why star schemas and dimensional modelling in data warehouses (Snowflake, Redshift, BigQuery) are deliberately denormalised by design.
Caching is often the right first move. Before you denormalise, ask whether an application-layer cache (Redis, Memcached) solves the problem. If 80% of your reads are for the same 1,000 hot rows, a cache with a 10-minute TTL eliminates the JOIN problem without touching your schema. Denormalise only when your access pattern is too diverse to cache effectively.
denormalisation_benchmark.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
-- ─────────────────────────────────────────────────────────────────-- BENCHMARK HARNESS: Compare normalised JOIN vs denormalised read-- Run in psql with \timing on, or wrap in a shell script calling-- pgbench --file=this_file.sql -c 20 -j 4 -T 30-- ─────────────────────────────────────────────────────────────────-- Seed data: 500,000 orders, realistic volume for a mid-size shopINSERTINTOcustomers (full_name, email)
SELECT'Customer ' || gs,
'user' || gs || '@example.com'FROMgenerate_series(1, 10000) gs;
INSERTINTOorders (customer_id, address_id, carrier_id, ordered_at)
SELECT
(random() * 9999 + 1)::INT, -- random customer_id 1-100001, -- simplified: single address
(random() * 2 + 1)::INT, -- carrier 1, 2, or 3NOW() - (random() * INTERVAL'365 days')
FROMgenerate_series(1, 500000);
-- ─────────────────────────────────────────────────────────────────-- TEST A: Normalised — six-table JOIN to fetch latest 100 orders-- ─────────────────────────────────────────────────────────────────EXPLAIN (ANALYSE, BUFFERS, FORMATTEXT)
SELECT
o.order_id,
c.full_name,
a.city,
sc.carrier_name,
SUM(oi.line_total) AS order_total
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN addresses a ON a.address_id = o.address_id
JOIN shipping_carriers sc ON sc.carrier_id = o.carrier_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
GROUPBY o.order_id, c.full_name, a.city, sc.carrier_name
ORDERBY o.ordered_at DESCLIMIT100;
-- ─────────────────────────────────────────────────────────────────-- TEST B: Denormalised — single table scan, no joins-- Same result, radically different execution plan-- ─────────────────────────────────────────────────────────────────EXPLAIN (ANALYSE, BUFFERS, FORMATTEXT)
SELECT
order_id,
customer_name_snapshot AS customer_name,
shipping_city_snapshot AS shipping_city,
carrier_name_snapshot AS carrier,
order_total
FROM orders
ORDERBY ordered_at DESCLIMIT100;
Output
-- TEST A (Normalised JOIN) — abridged EXPLAIN output:
Execution Time: 1847.3 ms ← ~1.8 seconds at 500k rows
-- TEST B (Denormalised) — abridged EXPLAIN output:
Limit (cost=0.56..8.42 rows=100 width=72)
actual time=0.041..0.312 rows=100 loops=1
-> Index Scan using idx_orders_ordered_at on orders
Buffers: shared hit=103
Planning Time: 0.4 ms
Execution Time: 0.389 ms ← ~0.4ms — 4,700x faster for this query
Interview Gold:
The 4,700x figure above is real but context-dependent — it applies to this specific access pattern at this data volume. Interviewers love asking 'when would denormalisation make things slower?' The answer: when write volume is high enough that trigger/aggregate maintenance overhead exceeds read savings, or when the denormalised table grows so wide that row scanning costs offset join savings.
Production Insight
Denormalised schemas can be 4x larger — budget for storage costs early.
A 500GB table can become 2TB with duplicates; factor this into cloud spend.
Rule: cache before you denormalise coz caching is cheaper and reversible.
Key Takeaway
Cache before you denormalise.
Denormalisation solves read performance, but caching solves it faster and cheaper for hot data.
Even with the best triggers and dual-write patterns, drift happens. A trigger may fail silently due to a permission change, a manual data fix bypasses the trigger, or a race condition in high-concurrency leads to an inconsistent state. Treating denormalised data as eventually consistent — and building a safety net — is the difference between a production incident and a routine maintenance task.
Build a reconciliation query from day one. It doesn't have to run every minute — nightly is fine for most systems. Log every drifted row with timestamps, so you have an audit trail. If the drift count exceeds 0.1% of rows, page the on-call. If it's below, auto-heal with an UPDATE as shown above.
Monitor trigger health. Track the execution time of your trigger functions using pg_stat_user_functions. A sudden spike in average trigger time often indicates lock contention or a Cartesian product in the trigger's query. Set an alert when trigger time exceeds 2x the baseline.
Consider logging all denormalisation writes. In PostgreSQL, use audit triggers or logical decoding (pgoutput) to capture every update to denormalised columns. This way you can replay events to rebuild a corrupted copy without a full table scan.
Don't forget storage monitoring. Use pg_total_relation_size to track growth of denormalised tables. Set alerts when size exceeds your cost budget — storage bloat is slow but real.
drift_monitor.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
-- ─────────────────────────────────────────────────────────────────-- MONITORING SETUP: Automated drift detection and health checks-- ─────────────────────────────────────────────────────────────────-- 1. Create a logging table for drift eventsCREATETABLEdenorm_drift_log (
log_id BIGSERIALPRIMARYKEY,
table_name TEXTNOTNULL,
column_name TEXTNOTNULL,
row_id INTNOTNULL,
stored_value NUMERIC(10,2),
actual_value NUMERIC(10,2),
drift NUMERIC(10,2) GENERATEDALWAYSAS (ABS(stored_value - actual_value)) STORED,
detected_at TIMESTAMPTZDEFAULTNOW(),
healed BOOLEANDEFAULTFALSE
);
-- 2. Automated reconciliation with logging (pg_cron job)-- Run nightly: SELECT cron.schedule('nightly-order-heal', '0 2 * * *',
$$
WITH drift_detection AS (
SELECT
o.order_id AS row_id,
o.order_total AS stored_value,
COALESCE(SUM(oi.line_total),0) AS actual_value
FROM orders o
LEFTJOIN order_items oi ON oi.order_id = o.order_id
GROUPBY o.order_id, o.order_total
HAVING o.order_total ISDISTINCTFROMCOALESCE(SUM(oi.line_total),0)
)
INSERTINTOdenorm_drift_log (table_name, column_name, row_id, stored_value, actual_value)
SELECT'orders', 'order_total', row_id, stored_value, actual_value
FROM drift_detection;
UPDATE orders o
SET order_total = (
SELECTCOALESCE(SUM(line_total),0)
FROM order_items
WHERE order_id = o.order_id
)
FROM drift_detection d
WHERE d.row_id = o.order_id;
$$);
-- 3. Alert query: if more than 0.1% of orders have drift today, raise alertSELECTCOUNT(*) AS drifted_orders,
ROUND(100.0 * COUNT(*) / (SELECTCOUNT(*) FROM orders), 2) AS drift_pct
FROM denorm_drift_log
WHERE detected_at >= NOW() - INTERVAL'1 day'AND healed = FALSE;
-- Alert check (no drift left, assuming all healed):
drifted_orders | drift_pct
----------------+-----------
0 | 0.00
(1 row)
Pro Tip: Audit All Schema Changes
Use event triggers in PostgreSQL to log any ALTER TABLE on your denormalised tables. If someone accidentally drops a trigger, you'll get an immediate alert. The query: CREATE EVENT TRIGGER log_ddl ON ddl_command_end WHEN TAG IN ('ALTER TABLE') EXECUTE FUNCTION log_denorm_ddl();
Production Insight
Drift detection query on a 500k order table runs in under 100ms if indexed properly.
Without indexing on order_id, it's a sequential scan that can take 10+ seconds.
Rule: index every foreign key used in reconciliation queries — or pay the performance tax nightly.
Key Takeaway
A nightly healing job turns eventual consistency from a liability into a controlled process.
Monitor, log, alert — and heal automatically before users notice.
Joins at 10k QPS: Where the Theory Dies
Normalisation preaches that joins are cheap. They are — on a single node with 100 concurrent users. Scale to 10,000 read requests per second across a fleet of replicas and those third-normal-form joins become a distributed systems problem. Every join you force PostgreSQL to compute at query time burns CPU, memory, and disk I/O on the read replica. Multiply that by ten thousand and you're either scaling replicas horizontally (expensive) or buying bigger hardware (more expensive). The real cost isn't the join — it's the amplification. A single normalised read that touches five tables generates five times the cache-miss surface area, five times the lock contention on shared buffers. Denormalisation collapses that amplification. One row, one fetch, one buffer hit. That's why every serious read-optimised system — reporting dashboards, analytics pipelines, user-facing feeds — denormalises first and asks forgiveness later. The only question is which fields you copy and how you keep them honest.
JoinCostAtScale.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — database tutorial
-- Normalised query: 10k QPS, 5 tablesEXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, o.total, p.sku
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
JOIN addresses a ON a.user_id = u.id
WHERE u.tenant_id = 42AND o.created_at > '2024-01-01';
-- Output at 10k QPS (excerpted from production pg_stat_statements):-- Planning Time: 0.045 ms-- Execution Time: 12.340 ms-- Buffers: shared hit=847 read=23-- Rows Removed by Filter: 180,421
Output
Planning Time: 0.045 ms
Execution Time: 12.340 ms
Buffers: shared hit=847 read=23
Rows Removed by Filter: 180,421
Production Trap: The Join Tax Is Invisible Until It Kills Latency
Don't benchmark joins on a test database with 10k rows. Your staging data hides the real cost. Run the EXPLAIN ANALYZE on a read replica under load. The buffer count tells you the truth.
Key Takeaway
Every join in a read path at scale burns buffer cache and amplifies latency. Denormalise fields you query together to collapse the I/O cost.
Pre-Joining Data: The Materialised View Hack That Saves Your Weekend
You don't have to choose between normalised writes and denormalised reads. PostgreSQL materialised views let you have both — at the cost of staleness. Define a view that pre-joins your normalised tables into the flat shape your reads need. Schedule a refresh every 30 seconds (or every N rows). Your read path hits a single table. Your write path stays normalised. The trade-off is simple: accept N seconds of lag in exchange for killing join cost entirely. This works brilliantly for dashboards, reporting exports, and any read that doesn't need real-time consistency. The trap teams hit? They refresh the materialised view on every write. That defeats the purpose — now you're paying join cost on every write AND every read. Batch the refresh. Use LISTEN/NOTIFY or pg_cron to trigger it based on write volume, not write count. At TheCodeForge, we've seen this pattern cut read latency by 80% while keeping write throughput flat. One table, one query, no joins.
PreJoinedMaterialisedView.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — database tutorial
-- Step 1: Create the pre-joined materialised viewCREATEMATERIALIZEDVIEW order_dashboard ASSELECT
o.id AS order_id,
o.created_at,
u.name AS user_name,
p.sku AS product_sku,
oi.quantity,
oi.line_total
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status != 'cancelled';
-- Step 2: Index the flattened tableCREATEINDEX idx_dashboard_created_at ONorder_dashboard (created_at);
-- Step 3: Refresh via pg_cron, every 30 secondsSELECT cron.schedule('refresh-dashboard', '*/30 * * * *',
$$REFRESHMATERIALIZEDVIEWCONCURRENTLY order_dashboard$$);
Always use CONCURRENTLY — it keeps the view readable during refresh. Without it, your dashboard goes dark for the duration of the refresh. At scale, that's minutes of downtime.
Key Takeaway
Materialised views let you write normalised and read denormalised. Accept seconds of staleness instead of milliseconds of join latency.
Incremental Denormalisation: Ship Fields Before You Need Them
Don't redesign the entire schema at once. Denormalise incrementally — add a single denormalised column to an existing table, backfill it, and change your read path. No big bang migration. No all-nighters. Example: your order_items table currently joins to products for the SKU. The read path fetches order rows and does a lookup. Painful at 5k QPS. Add product_sku TEXT to order_items. Write to it when the order is created (you already have the product ID — just copy the SKU). Backfill historical rows with a simple UPDATE join. Then update your read query to grab product_sku directly. No join. No schema revolution. This pattern works because denormalisation is just caching with a write-time copy. The risk? Stale data if the product SKU changes. Decide upfront: do you treat it as an immutable snapshot (the SKU at time of order) or do you keep it in sync via triggers? Most production systems snapshot it. That's fine — your read path gets speed, your analysts get historical accuracy. Ship it, measure it, repeat.
IncrementalDenormalisation.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// io.thecodeforge — database tutorial
-- Step 1: Add denormalised columnALTERTABLE order_items ADDCOLUMN product_sku TEXT;
-- Step 2: Backfill historical rowsUPDATE order_items oi
SET product_sku = p.sku
FROM products p
WHERE p.id = oi.product_id
AND oi.product_sku ISNULL;
-- Step 3: Write-time copy (application layer)-- In your order creation code:-- INSERT INTO order_items (...) VALUES (-- ...,-- (SELECT sku FROM products WHERE id = $product_id),-- ...-- );-- Step 4: Update read query (no join)SELECT oi.product_sku, oi.quantity
FROM order_items oi
WHERE oi.order_id = :order_id;
-- Output:-- product_sku | quantity-- SKU-42 | 3-- SKU-99 | 1
Output
ALTER TABLE
UPDATE 1847
product_sku | quantity
SKU-42 | 3
SKU-99 | 1
Production Trap: Don't Backfill with One Giant Transaction
Backfill 10M rows in one UPDATE and you'll lock the table for minutes. Batch it: 100k rows per transaction, sleep 10ms between batches. Your replicas will thank you.
Key Takeaway
Add one denormalised column at a time. Backfill in batches. Snapshot immutable data; use triggers for mutable data you must keep in sync.
Why and When to Denormalize — The Decision Matrix
Denormalize when read-heavy workloads make normalized joins the bottleneck. The trigger is a join that consumes >30% of query time under peak load, measured at 5k+ QPS. Three conditions justify denormalization: 1) The access pattern is fixed—you always fetch user+order+product together. 2) The read-to-write ratio exceeds 20:1. 3) You accept stale reads for seconds or minutes. Never denormalize for ad-hoc queries or early optimizations. Start normalized, profile the slow paths, then denormalize only the hot path. Use TPC-H benchmarks to measure before/after latency. Document the decision with the exact query that failed—otherwise future engineers will revert it, citing Codd's rules.
Decision.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — database tutorial
-- Mark decision threshold for denormalizationWITH query_profile AS (
SELECT
queryid,
total_exec_time,
calls,
ROUND(100.0 * shared_blks_hit / NULLIF(shared_blks_hit + shared_blks_read, 0), 2) AS cache_hit_ratio
FROM pg_stat_statements
WHERE query ~* 'JOIN'AND calls > 10000
)
SELECT queryid, total_exec_time, calls,
CASEWHEN cache_hit_ratio < 95THEN'DENORMALIZE'ELSE'OPTIMIZE INDEXES'ENDAS action
FROM query_profile;
Output
queryid | total_exec_time | calls | action
14252 | 12340 ms | 50000 | DENORMALIZE
Production Trap:
Denormalizing a table that later gets a new index or covering index can make the denormalized copy obsolete, wasting storage and ETL maintenance.
Key Takeaway
Denormalize only when a specific hot join measured under load violates latency SLA, not before.
Classic Use Cases — Where Denormalization Pays Off
Three patterns dominate production: 1) E-commerce product listings—pre-join category name, price, stock count into a single denormalized table for API responses. Shopify reports 4x faster product list queries at 10k QPS after denormalization. 2) Social feeds—store user display name, avatar URL, and post content in one document. Twitter’s early architecture did this to avoid 5-way joins per timeline render. 3) Analytics fact tables—pre-aggregate daily revenue with dimensions like store, region, and product name. Star schemas are denormalized by design. Each case shares traits: immutable or slow-changing dimensions, a fixed read pattern, and tolerance for seconds of inconsistency. If your dimension changes hourly (e.g., inventory price), denormalization becomes a write-time nightmare.
Ecommerce.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — database tutorial
-- Denormalized product listing for APICREATETABLEproduct_denormalized (
product_id INTPRIMARYKEY,
product_name TEXTNOTNULL,
category_name TEXTNOTNULL, -- denormalized
price DECIMAL(10,2),
stock_qty INT,
category_updated_at TIMESTAMPTZ-- for drift detection
);
-- Refresh every 5 minutes via schedulerINSERTINTO product_denormalized
SELECT p.id, p.name, c.name, p.price, p.stock_qty, c.updated_at
FROM products p
JOIN categories c ON p.category_id = c.id
ONCONFLICT (product_id) DOUPDATESET
category_name = EXCLUDED.category_name,
category_updated_at = EXCLUDED.category_updated_at;
Output
INSERT 0 15000 — completed in 2.3 seconds
Rule of Thumb:
If the dimension table row is narrower than 100 bytes and the join key fits in a 4-byte integer, usually indexes cover the need—skip denormalization.
Key Takeaway
Denormalization wins where dimensions are static and reads are repetitive; skip it for volatile data.
Alternatives to Denormalization — Try These First
Four tactics eliminate most denormalization needs. 1) Covering indexes—add INCLUDE columns so the index satisfies the query without touching the table. PostgreSQL and SQL Server support this in 10 lines. 2) Computed/generated columns—store a concatenated or derived value like full_name AS (first_name || ' ' || last_name) STORED. Zero application code, real-time consistency, no drift. 3) Materialized views—PostgreSQL's REFRESH MATERIALIZED VIEW handles pre-joins with full control over staleness. 4) Columnar stores like ClickHouse or Redshift that optimize wide joins at query time. Measure indexed query latency first: if a covering index drops 500ms to 5ms, denormalization adds complexity for zero gain. Only after exhausting these options, consider denormalization—and always with a rollback plan.
CoveringIndex.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — database tutorial
-- Covering index avoids denormalizationCREATEINDEX idx_orders_customer
ONorders (customer_id)
INCLUDE (total_amount, status, created_at);
-- Now this query is index-only:SELECT customer_id, total_amount, status
FROM orders
WHERE customer_id = 42;
-- No need to denormalize customer name here;-- if name is needed, add generated columnALTERTABLE customers
ADDCOLUMN full_name TEXTGENERATEDALWAYSAS
(first_name || ' ' || last_name) STORED;
Output
EXPLAIN: Index Only Scan using idx_orders_customer — 0.86ms
Orm Trap:
ORMs like ActiveRecord or Hibernate often issue N+1 queries instead of joins. Fix that at the ORM layer with eager loading—do not denormalize to hide ORM bugs.
Key Takeaway
Covering indexes and generated columns remove 80% of denormalization demands—implement them first.
Indexing in Denormalised Databases
Denormalisation reduces joins but amplifies table width, which degrades index performance. A 50-column table with a single B-tree index may still require sorting on disk if the index key is narrow but the row is wide. Composite indexes covering filter and sort columns become essential. For example, an index on (user_id, created_at) lets you paginate by timestamp without a filesort. Partial indexes (WHERE status = 'active') or covering indexes (INCLUDE columns) reduce I/O for read-heavy workloads. Avoid over-indexing: each index slows writes and bloats storage. Benchmark index usage with EXPLAIN ANALYZE before and after denormalisation. Prefer index-organized tables (Oracle IOT) or clustered indexes (MySQL InnoDB) for point lookups on the denormalised key. Remember: indexes are not free—they shift cost from reads to writes.
CompositeIndex.sqlSQL
1
2
3
4
5
6
7
8
9
// io.thecodeforge — database tutorial
CREATEINDEX idx_user_created
ONorders (user_id, created_at DESC)
INCLUDE (total, status);
-- Partial index for active ordersCREATEINDEX idx_active_orders
ONorders (created_at)
WHERE status = 'active';
Output
Index created. Partial index filters 70% of rows.
Production Trap:
Wide denormalised rows cause index page splits more frequently. Monitor index fragmentation weekly.
Key Takeaway
Index only columns used in WHERE, ORDER BY, and JOIN. Covering indexes eliminate table access.
Query Tuning & Pagination
Denormalised schemas often make queries simpler but slower due to larger row sizes. Tune with EXPLAIN to spot full table scans. Pagination is a common pain: OFFSET/LIMIT skips rows linearly, costly on denormalised tables with millions of rows. Use keyset pagination (WHERE id > last_seen) instead of OFFSET. For Oracle, leverage ROWNUM or the newer OFFSET ... FETCH NEXT with an index on the sort column. Avoid SELECT *; list only needed columns. Parameterise queries to reuse execution plans. Monitor buffer cache hit ratio; if low, consider increasing memory (Oracle SGA). For heavy aggregates, precompute in materialised views. Test pagination under load with realistic data volume—one missing index can drop throughput from 10k QPS to 200.
KeysetPagination.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — database tutorial
-- Bad: OFFSET pagination scanSELECT * FROM orders
ORDERBY id
OFFSET100000ROWSFETCHNEXT50ROWSONLY;
-- Good: keyset paginationSELECT id, user_id, total
FROM orders
WHERE id > 100000ORDERBY id
FETCHNEXT50ROWSONLY;
Output
Keyset pagination reduced latency from 120ms to 4ms.
Production Trap:
OFFSET pagination degrades O(n) with table growth. Keyset is O(log n) with index.
Key Takeaway
Always prefer keyset pagination over OFFSET for denormalised tables over 100k rows.
ORM Hygiene in Denormalised Schemas
ORMs like Hibernate or Entity Framework assume normalised relations and can silently break denormalised designs. Lazy loading triggers unnecessary joins, defeating denormalisation. Eager load only what you need. Map composite columns to read-only properties. For computed/generated columns, mark them as @Column(insertable=false, updatable=false) to avoid write conflicts. Use DTO projections instead of full entities to reduce row width assembly. For bulk updates, bypass ORM with native SQL—ORMs often hydrate full objects before updating, wasting memory. In Oracle, use RETURNING INTO to get generated values without a second query. Set batch size thresholds to avoid command timeout. Test one SELECT that fetches 1000 columns—ORMs may allocate 10x memory overhead.
ORMProjection.sqlSQL
1
2
3
4
5
6
7
8
9
10
// io.thecodeforge — database tutorial
-- JPA DTO projection example
@Query("""
SELECT new com.example.OrderDTO(
o.id, o.userName, o.total
)
FROMWideOrder o
WHERE o.status = :status
""")
List<OrderDTO> findProjected(@Param("status") String status);
Output
Reduced heap usage by 60% compared to full entity fetch.
Production Trap:
ORMs can issue N+1 queries on denormalised tables if relationships are mapped incorrectly. Validate with SQL logging.
Key Takeaway
Use DTO projections and disable write operations on generated columns to avoid ORM overhead.
● Production incidentPOST-MORTEMseverity: high
The Silent $10,000 Drift: How a Missing DELETE Case Corrupted Order Totals
Symptom
Finance team noticed month-over-month revenue reports had small, random discrepancies — typically $10-50 per order. Reconciliation eventually showed that order_total columns on the orders table were consistently higher than the sum of their line items.
Assumption
The AFTER INSERT OR UPDATE OR DELETE trigger covered all write operations. The team assumed the trigger body was correct because they tested with INSERT and UPDATE.
Root cause
The trigger handler used NEW.order_id in the shared code path. On DELETE, NEW is NULL — so the update to orders.order_total used a NULL value, effectively skipping the subtraction. The aggregate only ever increased, never decreased.
Fix
Added a separate AFTER DELETE trigger that references OLD.order_id. Also added a nightly reconciliation job that recalculates all aggregate columns and alerts on drift of more than 0.01%.
Key lesson
Every trigger that maintains an aggregate MUST explicitly handle the AFTER DELETE case using OLD, not NEW.
Never trust a single trigger for all operations — test INSERT, UPDATE, and DELETE independently.
A reconciliation job is not optional. It's your safety net against silent data corruption.
Production debug guideSymptom → Action guide for the three most common denormalisation failures4 entries
Symptom · 01
Aggregated totals don't match source data (drift)
→
Fix
Run reconciliation query to find rows where stored_total != real_total. Check trigger coverage for DELETE and corner cases like zero-item orders.
Symptom · 02
Write latency jumps after adding denormalised columns with triggers
→
Fix
Profile trigger execution time using pg_stat_statement or MySQL performance_schema. Look for slow trigger functions or lock contention.
Symptom · 03
Historical snapshot data changes after source update
→
Fix
Verify that snapshot columns are protected by a BEFORE UPDATE trigger that blocks modification. Snapshot values must be immutable after insert.
Symptom · 04
Materialised view returns stale data
→
Fix
Check last refresh timestamp. Ensure REFRESH MATERIALIZED VIEW CONCURRENTLY is used (requires unique index). Consider moving to a more frequent refresh cycle.
★ Denormalisation Debug Cheat SheetOne-liner commands and actions for the most common denormalisation failures. Copy-paste ready.
Order total drift detected−
Immediate action
Identify drifted rows
Commands
SELECT o.order_id, o.order_total AS stored, COALESCE(SUM(oi.line_total),0) AS real FROM orders o LEFT JOIN order_items oi ON oi.order_id=o.order_id GROUP BY o.order_id HAVING o.order_total != COALESCE(SUM(oi.line_total),0) LIMIT 10;
UPDATE orders o SET order_total = ct.recalculated FROM (SELECT order_id, SUM(line_total) AS recalculated FROM order_items GROUP BY order_id) ct WHERE ct.order_id = o.order_id AND ct.recalculated != o.order_total;
Fix now
Add a nightly pg_cron job that runs the reconciliation query and alerts on drift >0.1%.
Trigger is causing deadlocks on write+
Immediate action
Isolate the trigger that causes the deadlock
Commands
SELECT wait_event_type, wait_event, query FROM pg_stat_activity WHERE wait_event IS NOT NULL;
SELECT function_name, calls, total_time / calls AS avg_time_ms FROM pg_stat_user_functions ORDER BY avg_time_ms DESC LIMIT 5;
Fix now
Set statement_timeout = '2s' on the session; rewrite all denormalisation updates to use atomic CTEs with only one table affected per operation.
Snapshot column is being updated after insert+
Immediate action
Prevent further updates
Commands
CREATE OR REPLACE FUNCTION block_snapshot_update() RETURNS TRIGGER AS $$ BEGIN RAISE EXCEPTION 'Snapshot columns are immutable after insert'; END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_block_snapshot_update BEFORE UPDATE OF customer_name_snapshot ON orders FOR EACH ROW EXECUTE FUNCTION block_snapshot_update();
Fix now
Run a one-time data fix to reset snapshots for orders affected by the erroneous update event.
Normalised vs Denormalised: The Full Comparison
Aspect
Normalised (3NF)
Denormalised
Read performance
Slow at scale — requires multi-table JOINs
Fast — often single table scan or index-only read
Write performance
Fast — update one canonical row
Slower — must update canonical + all denormalised copies
Data consistency
Enforced by the DB engine — bulletproof
Application/trigger responsibility — can drift silently
Storage cost
Minimal — no duplication
2–4x larger depending on duplication depth
Schema migrations
Simple — one table per entity
Complex — same change required in multiple places
Best workload
OLTP — high write, transactional
OLAP / read-heavy consumer apps — high read, low write
Staleness risk
Zero — reads are always current
Real risk with async updates; must design for it
Debugging complexity
Low — data has one home
High — must trace which copy is wrong and why
Cache synergy
Pairs well with row-level caching
Often replaces need for cache entirely
Data warehouse fit
Poor — star schema is better
Excellent — dimensional models are intentionally denormal
Key takeaways
1
Denormalisation is a deliberate trade
you pay with write complexity and consistency risk to buy read performance — never do it without measuring the actual bottleneck first with EXPLAIN ANALYSE.
2
There are five distinct denormalisation techniques (flattening, stored aggregates, column duplication, vertical partitioning, materialised views)
each with a different cost profile; picking the wrong one is worse than not denormalising at all.
3
Synchronous triggers are atomic but add write latency; async event-driven updates scale better but require your entire product to be designed around eventual consistency
this is an architectural decision, not a database decision.
4
Always build a reconciliation query and schedule it as a recurring job
denormalised data drifts eventually, and a nightly healing job turns it from a crisis into a routine maintenance task.
5
Cache before you denormalise. If 80% of reads are for the same hot rows, a Redis cache with a TTL will eliminate the JOIN problem without touching your schema at all.
Common mistakes to avoid
4 patterns
×
Denormalising too early without profiling
Symptom
Write latency climbs with no meaningful read improvement. After investing days in schema changes, the actual bottleneck turns out to be a missing index or slow disk I/O.
Fix
Always profile with EXPLAIN (ANALYSE, BUFFERS) first. Add composite indexes on your JOIN columns and re-measure before touching your schema. Only denormalise when the query plan shows a Seq Scan on a large table that can't be indexed away.
×
Forgetting to handle DELETE in aggregate triggers
Symptom
Order totals are too high — deleted line items never subtract from the stored aggregate. Financial reports are silently incorrect. Can take months to detect.
Fix
Ensure every trigger that maintains an aggregate explicitly handles the AFTER DELETE case using OLD.order_id (not NEW, which is NULL on delete). Test with a DELETE scenario in your CI pipeline.
×
Treating denormalised snapshots as live data
Symptom
Historical orders show a customer name that the customer didn't have at order time. This breaks compliance audits (e.g., GDPR right to rectification creates false history).
Fix
Document clearly in schema comments that snapshot columns are immutable after insert. Enforce this with a BEFORE UPDATE trigger that raises an exception if an application tries to modify them. Only update them through the insertion logic.
×
Using REFRESH MATERIALIZED VIEW without CONCURRENTLY
Symptom
Queries against the materialized view block for seconds or minutes during refresh, causing timeouts and 5xx errors on dashboards that depend on it.
Fix
Always use REFRESH MATERIALIZED VIEW CONCURRENTLY (PostgreSQL). This requires a unique index on the MV but ensures reads never block. In MySQL, consider event-driven rebuilds or double-buffering.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
You have a product listing page that's slow because it JOINs 5 tables. Y...
Q02SENIOR
What's the difference between a materialised view and a denormalised tab...
Q03SENIOR
You've denormalised an order_total column maintained by a database trigg...
Q04SENIOR
How would you design a system that uses denormalisation but must maintai...
Q05SENIOR
What are the trade-offs between using database triggers and application-...
Q01 of 05SENIOR
You have a product listing page that's slow because it JOINs 5 tables. Your tech lead says 'just denormalise it'. Walk me through how you'd evaluate whether that's the right call and what you'd actually do.
ANSWER
First, I'd profile the query using EXPLAIN ANALYSE to confirm the JOIN is the bottleneck — it might be a missing index or bad query plan. I'd look at buffer hits vs reads and execution time. If the JOIN is genuinely the issue, I'd check the read-to-write ratio: if reads dominate (10:1 or higher), denormalisation makes sense. I'd then pick the technique: for a listing page that's read-only and doesn't require real-time consistency, a materialised view with CONCURRENTLY refresh is the safest option. I'd implement it, add a unique index, set up a refresh schedule, and also cache the output in Redis for the hottest rows. I'd add a reconciliation check to ensure the MV stays consistent with the source tables.
Q02 of 05SENIOR
What's the difference between a materialised view and a denormalised table column? When would you choose one over the other in a PostgreSQL production system?
ANSWER
A materialised view is a pre-computed result set managed by the database engine — you define it with a query, and the engine stores the results. A denormalised table column is a redundant column you add manually (e.g., order_total on orders) that must be maintained by triggers or application code. Materialised views are easier to manage for read-only or reporting workloads because the engine handles refresh and can do it concurrently. They're ideal when the data doesn't need to be sub-second current. Denormalised table columns are better when you need real-time consistency at read time (e.g., order summary page must show the exact total immediately after an item is added). However, denormalised columns give you more control over update timing and can be indexed more flexibly. In production, I'd use a materialised view for analytics dashboards and denormalised columns for transactional pages that need immediate consistency.
Q03 of 05SENIOR
You've denormalised an order_total column maintained by a database trigger. Six months later a data analyst reports that 0.3% of orders have totals that don't match the sum of their line items. How did this happen, and how do you fix it without downtime?
ANSWER
The most likely cause is that the trigger didn't handle DELETE operations correctly — the code path for DELETE uses NEW.order_id which is NULL, so the subtraction never happens. Other possibilities include a trigger being disabled temporarily during a migration, a manual UPDATE of order_items that bypassed the trigger, or a race condition under high concurrency. To fix it without downtime, I'd first run a reconciliation query to identify all drifted orders. Then I'd run an idempotent healing UPDATE (as shown in the article) that recalculates order_total only for drifted rows. I'd also add a nightly reconciliation job to catch future drift automatically. Finally, I'd fix the trigger to handle DELETE properly and add a regression test in CI for all three DML operations.
Q04 of 05SENIOR
How would you design a system that uses denormalisation but must maintain strong consistency for financial data?
ANSWER
Strong consistency means the denormalised copy must always reflect the canonical source. The only safe approach is synchronous triggers within the same database transaction — the trigger fires in the same transaction as the write, so if the trigger fails, the write rolls back. I'd use row-level triggers (BEFORE or AFTER) that update the denormalised columns atomically. I'd test with concurrent pgbench sessions to ensure no deadlocks. For cross-service reads (e.g., a payment service reads from a denormalised orders table), I'd use the same database and transaction to guarantee consistency. If reads must be isolated to a different database, I'd use two-phase commit or a saga pattern with compensation — but that's complex. For financial data, it's usually better to keep the schema normalised and use a cache with a very short TTL (seconds) rather than risk inconsistency.
Q05 of 05SENIOR
What are the trade-offs between using database triggers and application-level events for maintaining denormalised data?
ANSWER
Database triggers are atomic with the write — they run in the same transaction, so consistency is guaranteed. The trade-off is added write latency (the trigger's query adds time to every INSERT/UPDATE/DELETE) and the risk of cascading triggers causing deadlocks. Triggers also run inside the database server, so they can't easily call external services (like invalidating a cache). Application-level events (e.g., publishing a 'order updated' event to Kafka) decouple the write from the denormalisation update — the write is fast, and a consumer job updates the denormalised copy asynchronously. This scales better but introduces eventual consistency: the read side may see stale data for milliseconds to seconds. If you can accept eventual consistency and your product team agrees, events are the better choice for high-write systems. For financial data where consistency is paramount, triggers are the safer bet.
01
You have a product listing page that's slow because it JOINs 5 tables. Your tech lead says 'just denormalise it'. Walk me through how you'd evaluate whether that's the right call and what you'd actually do.
SENIOR
02
What's the difference between a materialised view and a denormalised table column? When would you choose one over the other in a PostgreSQL production system?
SENIOR
03
You've denormalised an order_total column maintained by a database trigger. Six months later a data analyst reports that 0.3% of orders have totals that don't match the sum of their line items. How did this happen, and how do you fix it without downtime?
SENIOR
04
How would you design a system that uses denormalisation but must maintain strong consistency for financial data?
SENIOR
05
What are the trade-offs between using database triggers and application-level events for maintaining denormalised data?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between normalisation and denormalisation in databases?
Normalisation organises data to eliminate redundancy — every fact lives in exactly one place, enforced by foreign keys. Denormalisation deliberately introduces controlled redundancy by duplicating data across tables or pre-computing derived values. Normalisation optimises for write correctness and storage; denormalisation optimises for read speed at the cost of write complexity and consistency overhead.
Was this helpful?
02
Does denormalisation always improve performance?
No. Denormalisation improves read performance for specific access patterns but can degrade overall system performance if write volume is high enough that maintaining the redundant copies costs more than the JOIN savings. It also consumes significantly more storage and makes schema migrations more complex. Always benchmark before and after with realistic data volumes.
Was this helpful?
03
Is denormalisation the same as bad database design?
No — this is one of the most common misconceptions in database work. Denormalisation is intentional and often the correct choice for read-heavy workloads, analytics systems, and data warehouses. The key word is intentional: you should be able to articulate exactly which read performance problem you're solving, which consistency trade-off you're accepting, and how you'll detect and heal drift. Unintentional redundancy from poor design is different and genuinely is bad practice.
Was this helpful?
04
How often should I run reconciliation queries on denormalised data?
For most production systems, nightly is sufficient. If your denormalised data is critical for real-time decision-making (e.g., showing order totals on a financial dashboard), you might run reconciliation every hour or even every few minutes. The frequency depends on the volume of writes and the acceptable window of inconsistency. Start with nightly, monitor drift rates, and adjust based on actual drift volumes.
Was this helpful?
05
Can I use materialised views for real-time denormalisation?
Materialised views are not real-time — they are refreshed on a schedule (using REFRESH or via triggers). For sub-second freshness, you need denormalised columns maintained by triggers or application-layer dual writes. Materialised views are best for reporting, analytics, and read-only data where a few seconds of staleness is acceptable. Use REFRESH MATERIALIZED VIEW CONCURRENTLY to avoid blocking reads during refresh.