Intentional Denormalization
Read-optimized copies with a documented refresh strategy trade join cost for query speed - only after normalized design is proven insufficient.
Search across all documentation pages
Read-optimized copies with a documented refresh strategy trade join cost for query speed - only after normalized design is proven insufficient.
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_statements shows a hot join blocking SLOs after proper indexes exist.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_price and product_name are intentional snapshots on the fact row.order_items.product_name is name at purchase time, not live catalog."| 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 |
-- 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;EXPLAIN, then denorm.customer_email on orders expected to track email changes without refresh. Fix: document snapshot vs mirror; add update job.UPDATE bypasses app triggers in admin SQL. Fix: periodic reconciliation job.| 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 |
When you have evidence: pg_stat_statements, EXPLAIN (ANALYZE, BUFFERS), and failed attempts with covering indexes on normalized schema.
Yes - unit_price on order_items is standard commerce modeling, not a performance hack.
COMMENT ON COLUMN order_items.product_name IS
'SNAPSHOT: product name at line insert time; does not track catalog renames';Triggers catch all insert paths including ad-hoc SQL. Applications are easier to test but easier to bypass inconsistently.
Yes when derivation is pure SQL:
ALTER TABLE order_items ADD COLUMN line_total numeric(12,2)
GENERATED ALWAYS AS (quantity * unit_price) STORED;Nightly for mirror columns; never for intentional snapshots unless business rules change retroactively.
Yes by definition. Accept that consciously for measured read wins or required snapshots.
Store a versioned snapshot blob on insert for audit (product_snapshot jsonb). Index only fields you filter on.
Prove join performance with indexes, migrate readers, drop column in contract migration phase.
Conceptually similar - duplicate data for speed. Database denorm survives cache eviction and supports SQL reporting.
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+.
Reviewed by Chris St. John·Last updated Jul 19, 2026