Intentional Denormalization
Read-optimized copies with a documented refresh strategy trade join cost for query speed - only after normalized design is proven insufficient.
Recipe
Quick-reference recipe card - copy-paste ready.
-- Normalized source of truth
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (customer_id),
status text NOT NULL
);
CREATE TABLE customers (
customer_id bigint PRIMARY KEY,
email text NOT NULL
);
-- Denormalized read copy on orders (document the invariant)
ALTER TABLE orders ADD COLUMN customer_email text;
CREATE OR REPLACE FUNCTION sync_order_customer_email()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
SELECT email INTO NEW.customer_email FROM customers WHERE customer_id = NEW.customer_id;
RETURN NEW;
END;
$$;
CREATE TRIGGER orders_customer_email_trg
BEFORE INSERT OR UPDATE OF customer_id ON orders
FOR EACH ROW EXECUTE FUNCTION sync_order_customer_email();When to reach for this:
pg_stat_statementsshows a hot join blocking SLOs after proper indexes exist.- Read path needs stable snapshot columns (price at purchase time).
- Reporting replica still too slow for a specific dashboard query.
Working Example
BEGIN;
CREATE TABLE products (
product_id bigint PRIMARY KEY,
sku text NOT NULL,
name text NOT NULL,
price numeric(12, 2) NOT NULL
);
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
placed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (order_id),
product_id bigint NOT NULL REFERENCES products (product_id),
quantity integer NOT NULL CHECK (quantity > 0),
-- Snapshot denorm: catalog price may change later
unit_price numeric(12, 2) NOT NULL,
product_name text NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- Backfill denorm columns on insert via trigger
CREATE OR REPLACE FUNCTION order_items_snapshot_product()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
p products%ROWTYPE;
BEGIN
SELECT * INTO p FROM products WHERE product_id = NEW.product_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'product % not found', NEW.product_id;
END IF;
NEW.unit_price := COALESCE(NEW.unit_price, p.price);
NEW.product_name := COALESCE(NEW.product_name, p.name);
RETURN NEW;
END;
$$;
CREATE TRIGGER order_items_snapshot_trg
BEFORE INSERT ON order_items
FOR EACH ROW EXECUTE FUNCTION order_items_snapshot_product();
INSERT INTO products (product_id, sku, name, price)
VALUES (1, 'A1', 'Alpha', 19.99);
INSERT INTO orders (order_id) VALUES (100);
INSERT INTO order_items (order_id, product_id, quantity, unit_price, product_name)
VALUES (100, 1, 2, 19.99, 'Alpha');
-- Catalog rename does not rewrite historical line items
UPDATE products SET name = 'Alpha Pro' WHERE product_id = 1;
SELECT product_name, unit_price FROM order_items WHERE order_id = 100;
COMMIT;What this demonstrates:
unit_priceandproduct_nameare intentional snapshots on the fact row.- Trigger fills snapshots on insert so apps cannot forget denorm fields.
- Historical reporting stays correct when catalog changes.
Deep Dive
How It Works
- Denormalization duplicates data that could be joined from a dimension table.
- Invariants must be written down: "
order_items.product_nameis name at purchase time, not live catalog." - Refresh strategies: trigger on write, batch job, or materialized view refresh.
- Drift detection queries compare denorm columns to source tables.
Denorm Strategies
| Strategy | Freshness | Complexity |
|---|---|---|
| Trigger on write | Immediate | Medium |
| Application dual-write | Immediate | High (missed code paths) |
| Scheduled batch UPDATE | Lag acceptable | Low |
| Materialized view | Refresh interval | Medium |
SQL Notes
-- Drift detection: denorm email should match customer unless snapshot rules say otherwise
SELECT o.order_id, o.customer_email, c.email
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.customer_email IS DISTINCT FROM c.email;Gotchas
- Denorm before indexing joins - redundant columns without measuring. Fix: index FKs,
EXPLAIN, then denorm. - Live mirror columns -
customer_emailon orders expected to track email changes without refresh. Fix: document snapshot vs mirror; add update job. - Trigger drift - manual
UPDATEbypasses app triggers in admin SQL. Fix: periodic reconciliation job. - Wide rows for convenience - 40 nullable denorm columns. Fix: materialized view or JSONB snapshot with schema version.
- No rollback story - cannot recompute denorm from source. Fix: keep normalized source as authority; denorm is derived.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Indexed normalized joins | Data fits buffer cache | Proven multi-billion-row join |
| Materialized view | Many readers, same projection | Need row-level freshness on OLTP path |
| Read replica + covering index | Read/write split enough | Replica lag unacceptable |
| Application cache (Redis) | Ephemeral display data | Financial snapshots requiring audit trail |
FAQs
When is denormalization justified?
When you have evidence: pg_stat_statements, EXPLAIN (ANALYZE, BUFFERS), and failed attempts with covering indexes on normalized schema.
Should historical prices be denormalized?
Yes - unit_price on order_items is standard commerce modeling, not a performance hack.
How do I document invariants?
COMMENT ON COLUMN order_items.product_name IS
'SNAPSHOT: product name at line insert time; does not track catalog renames';Triggers vs application dual-write?
Triggers catch all insert paths including ad-hoc SQL. Applications are easier to test but easier to bypass inconsistently.
Can generated columns denormalize?
Yes when derivation is pure SQL:
ALTER TABLE order_items ADD COLUMN line_total numeric(12,2)
GENERATED ALWAYS AS (quantity * unit_price) STORED;How often should drift checks run?
Nightly for mirror columns; never for intentional snapshots unless business rules change retroactively.
Does denormalization break 3NF?
Yes by definition. Accept that consciously for measured read wins or required snapshots.
What about JSONB denormalization?
Store a versioned snapshot blob on insert for audit (product_snapshot jsonb). Index only fields you filter on.
How do I remove denorm later?
Prove join performance with indexes, migrate readers, drop column in contract migration phase.
Is caching the same as denormalization?
Conceptually similar - duplicate data for speed. Database denorm survives cache eviction and supports SQL reporting.
Related
- Normalization Basics - why duplicates hurt
- Materialized Views - batch denormalized projections
- Denormalization Best Practices - invariant checklist
- Index Design - try indexes first
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.