When to break normalization deliberately for read performance, maintaining consistency for the resulting duplicated data, materialized views as a middle ground, and schema migration/soft-delete trade-offs.
Published September 23, 2026
Normalization (see Normalization) eliminates redundancy and the anomalies that come with it. This lesson is about when to deliberately give some of that up.
-- Normalized: requires a join every time
SELECT o.id, o.total, c.name, c.email
FROM orders o JOIN customers c ON o.customer_id = c.id
WHERE o.id = ?;
-- Denormalized: customer_name copied directly onto orders
SELECT id, total, customer_name, customer_email FROM orders WHERE id = ?;
The tradeoff is explicit: normalization avoids redundancy (a customer's name lives in exactly one place, so it's updated in exactly one place); denormalization trades that for avoiding a join on a hot read path. Worth denormalizing specifically when a query runs very frequently, the joined data changes rarely relative to how often it's read, and the join's cost is measurably significant — denormalizing a rarely-run report query for marginal speedup isn't worth the added consistency-maintenance burden.
Once customer_name is copied onto orders, a customer's name change needs to propagate to every order that copied it — three common mechanisms:
customers table automatically updates denormalized copies on write — keeps the logic in the database, close to the data, but can be harder to test/reason about and easy to forget exists when debugging unrelated issues.CREATE MATERIALIZED VIEW order_summary AS
SELECT o.id, o.total, c.name AS customer_name
FROM orders o JOIN customers c ON o.customer_id = c.id;
REFRESH MATERIALIZED VIEW order_summary; -- explicit or scheduled
A materialized view is a precomputed, stored query result (unlike a regular view, which re-runs its query on every access) — reads against it are as fast as reading a plain table, while the underlying join logic stays defined in exactly one place (the view's query definition), rather than duplicated across application code's denormalization logic. The cost: it needs explicit or scheduled refreshing to stay current, which is itself a form of the staleness tradeoff background reconciliation jobs make, just handled by the database rather than custom application code.
-- SAFE: additive, doesn't break code still using the old shape
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);
-- RISKIER: requires coordinating a deploy where no running code depends on the old column
ALTER TABLE users DROP COLUMN legacy_field;
Additive changes (new nullable column, new table) are safe to deploy without tight coordination — old application code simply ignores the new column, new code can start using it once deployed. Destructive/renaming changes need a deprecation window: stop writing to (and eventually stop reading) the old shape in application code first, confirm no running instance still depends on it, then remove it from the schema — dropping a column while any deployed instance still reads/writes it causes immediate errors in that instance, which is exactly why "deploy code that stops using it" and "drop the column" are kept as separate, sequenced steps, not one migration.
-- Soft delete: row stays, marked
UPDATE users SET deleted_at = NOW() WHERE id = ?;
SELECT * FROM users WHERE deleted_at IS NULL; -- every query must remember this filter
-- Hard delete: row is actually gone
DELETE FROM users WHERE id = ?;
Soft deletes preserve data (useful for audit trails, "undo delete" features, or references from other tables that would otherwise become dangling) at a real cost: every query against that table needs to remember to filter WHERE deleted_at IS NULL, and a forgotten filter silently includes "deleted" rows — plus, indexes on a heavily soft-deleted table need to account for the deleted_at filter to stay useful (a composite index including deleted_at, or a partial index — WHERE deleted_at IS NULL — that only indexes non-deleted rows). Hard deletes avoid that query-discipline burden entirely, at the cost of the data genuinely being gone, with no recovery path and no way for other tables' foreign keys to keep referencing it safely (they'd need ON DELETE CASCADE/SET NULL behavior defined up front).
Q: Is denormalization ever the RIGHT default, not just a tactical exception? A: In NoSQL/document databases, yes — as covered in Practical SQL & JDBC for Interviews' SQL-vs-NoSQL framework, designing a document schema around actual query patterns (often meaning embedding/duplicating related data directly in a document) is the idiomatic NoSQL approach, not an exception to a normalize-first default the way it is in a relational schema.
Q: What's a concrete failure mode of forgetting to update a denormalized copy? A: A customer's displayed name on old orders staying wrong forever after a legitimate name change (a real, visible, low-severity bug) — or worse, if the denormalized field feeds a business decision (a denormalized "current tier" field used for a discount calculation that never gets updated after a tier change), a real financial/logic bug, not just a cosmetic one — the severity of a missed sync depends entirely on what the stale data is used for.
Q: Why would you choose a background reconciliation job over synchronous application-level events for consistency? A: When the denormalized copy doesn't need to be correct immediately — a periodic job is simpler to build and more resilient to a missed event (an event-based approach can silently drift if a single event is ever lost/fails to process, with no self-healing mechanism unless explicitly built) — reconciliation jobs act as a self-correcting safety net, at the cost of a wider window of temporary staleness.
Q: Does a partial index on WHERE deleted_at IS NULL actually help query performance for soft-deleted tables? A: Significantly — a full index across all rows (deleted and not) wastes space and lookup cost on rows nearly every query filters out anyway; a partial index containing only the (typically much smaller, more relevant) set of non-deleted rows is both smaller and directly matches the WHERE clause nearly every real query already includes.