Skip to content

Data Modeling Patterns

Normalization eliminates redundancy and update anomalies, but there is a point of diminishing Returns. The decision of when to stop depends on your read/write ratio, performance requirements, And complexity tolerance.

Normal FormEliminatesPractical Impact
1NFRepeating groups, non-atomic valuesFoundation; every table should be in 1NF
2NFPartial dependencies on composite keysEliminates redundant data in composite PKs
3NFTransitive dependencies (A → B → C)Most OLTP schemas stop here
BCNFAll candidate keys fully determinedSlight refinement over 3NF
4NFMulti-valued dependenciesRarely needed in practice
5NFJoin dependenciesTheoretical; almost never practical
-- 3NF: normalized (three tables)
CREATE TABLE customers (
customer_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
total NUMERIC(10,2) NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE order_items (
item_id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(10,2) NOT NULL
);
-- Denormalized for read-heavy dashboards (materialized view or cache table):
CREATE TABLE order_summary (
order_id INTEGER PRIMARY KEY,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
total NUMERIC(10,2) NOT NULL,
status TEXT NOT NULL,
item_count INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
ScenarioRecommendation
OLTP with frequent writesNormalize (3NF)
Read-heavy reporting dashboardsDenormalize (materialized view)
High-frequency reads with infrequent updatesCached/precomputed column
Data warehousing / OLAPStar/snowflake schema
Real-time aggregation requirementsPrecomputed aggregates

Store computed values directly in the table to avoid expensive joins or calculations:

CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
subtotal NUMERIC(10,2) NOT NULL,
tax NUMERIC(10,2) NOT NULL,
total NUMERIC(10,2) GENERATED ALWAYS AS (subtotal + tax) STORED,
item_count INTEGER NOT NULL DEFAULT 0
);
-- Update item_count on every order_items change (trigger or application logic)
CREATE OR REPLACE FUNCTION update_order_item_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = "INSERT' THEN
UPDATE orders SET item_count = item_count + NEW.quantity WHERE order_id = NEW.order_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE orders SET item_count = item_count - OLD.quantity WHERE order_id = OLD.order_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_order_items_count
AFTER INSERT OR DELETE ON order_items
FOR EACH ROW EXECUTE FUNCTION update_order_item_count();
CREATE TABLE customer_stats (
customer_id INTEGER PRIMARY KEY REFERENCES customers(customer_id),
total_orders INTEGER NOT NULL DEFAULT 0,
total_spent NUMERIC(12,2) NOT NULL DEFAULT 0,
avg_order_value NUMERIC(10,2),
last_order_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Refresh periodically or on order events
INSERT INTO customer_stats (customer_id, total_orders, total_spent, avg_order_value, last_order_at)
SELECT
customer_id,
COUNT(*),
SUM(total),
AVG(total),
MAX(created_at)
FROM orders
GROUP BY customer_id
ON CONFLICT (customer_id) DO UPDATE SET
total_orders = EXCLUDED.total_orders,
total_spent = EXCLUDED.total_spent,
avg_order_value = EXCLUDED.avg_order_value,
last_order_at = EXCLUDED.last_order_at,
updated_at = NOW();

Records when a fact was true in the real world:

CREATE TABLE employee_salary_history (
history_id SERIAL PRIMARY KEY,
employee_id INTEGER NOT NULL REFERENCES employees(employee_id),
salary NUMERIC(10,2) NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
CONSTRAINT no_overlaps EXCLUDE USING gist (
employee_id WITH =,
daterange(valid_from, valid_to, '[]') WITH &&
),
CONSTRAINT valid_range CHECK (valid_from < valid_to)
);

Records when a fact was recorded in the database (append-only, never modified):

CREATE TABLE employee_salary_tx (
sys_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sys_end TIMESTAMPTZ NOT NULL DEFAULT '9999-12-31',
employee_id INTEGER NOT NULL,
salary NUMERIC(10,2) NOT NULL,
PRIMARY KEY (employee_id, sys_start)
);
-- Current record: sys_end = '9999-12-31'
-- Historical record: sys_end = actual end timestamp

SCD Type 1 (Overwrite): Directly update the value. No history retained.

-- No special structure needed
UPDATE products SET category = 'Electronics' WHERE product_id = 42;

SCD Type 2 (Add Row): Insert a new row with version tracking:

CREATE TABLE products_scd2 (
product_sk SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT TRUE
);
-- On change: close current row, insert new row
UPDATE products_scd2 SET valid_to = CURRENT_DATE, is_current = FALSE
WHERE product_id = 42 AND is_current = TRUE;
INSERT INTO products_scd2 (product_id, name, category, valid_from, valid_to, is_current)
VALUES (42, 'Widget Pro', 'Electronics', CURRENT_DATE, '9999-12-31', TRUE);

SCD Type 3 (Add Column): Track previous value in a separate column:

CREATE TABLE products_scd3 (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
current_category TEXT NOT NULL,
previous_category TEXT
);
-- On change: move current to previous
UPDATE products_scd3
SET previous_category = current_category,
current_category = 'Electronics'
WHERE product_id = 42;
SCD TypeHistory RetainedStorageComplexityUse Case
Type 1NoneMinimalLowCorrections, insignificant changes
Type 2FullHighHighAudit trails, analytics
Type 3One previousLowMediumLimited history needed

Each row stores a reference to its parent:

CREATE TABLE categories (
category_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES categories(category_id)
);
-- Query: find all ancestors of a category
WITH RECURSIVE ancestors AS (
SELECT category_id, name, parent_id, 1 AS depth
FROM categories WHERE category_id = 42
UNION ALL
SELECT c.category_id, c.name, c.parent_id, a.depth + 1
FROM categories c JOIN ancestors a ON c.category_id = a.parent_id
)
SELECT * FROM ancestors;

Each node stores left and right bounds. The entire tree is encoded in a single table with no Recursion needed for many queries:

CREATE TABLE categories_ns (
category_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
lft INTEGER NOT NULL,
rgt INTEGER NOT NULL
);
-- Get all descendants (no recursion needed):
SELECT * FROM categories_ns WHERE lft BETWEEN 5 AND 12 ORDER BY lft;
-- Get all ancestors:
SELECT * FROM categories_ns WHERE lft < 5 AND rgt > 12 ORDER BY lft;
-- Count descendants:
(rgt - lft - 1) / 2

Store the full path from root to each node:

CREATE TABLE categories_path (
category_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL -- e.g., '/1/5/42/'
);
-- All descendants of category 5:
SELECT * FROM categories_path WHERE path LIKE '/1/5/%';
-- All ancestors of category 42:
-- Extract path segments: /1/5/42/ → ancestors are 1 and 5
SELECT * FROM categories_path
WHERE category_id = ANY(
SELECT unnest(string_to_array('/1/5/42/', '/')::INTEGER[])
)
AND category_id IS NOT NULL;

Store all ancestor-descendant pairs explicitly:

CREATE TABLE tree_nodes (
node_id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE tree_closure (
ancestor_id INTEGER NOT NULL REFERENCES tree_nodes(node_id),
descendant_id INTEGER NOT NULL REFERENCES tree_nodes(node_id),
depth INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (ancestor_id, descendant_id)
);
-- Insert a new node as child of node 5:
-- 1. Insert the node
INSERT INTO tree_nodes (name) VALUES ('New Node') RETURNING node_id;
-- 2. Insert self-reference and all ancestor paths
INSERT INTO tree_closure (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.node_id, depth + 1
FROM tree_closure WHERE descendant_id = 5
UNION ALL
VALUES (NEW.node_id, NEW.node_id, 0);
PatternInsertUpdate (move subtree)Read ancestorsRead descendantsStorage
Adjacency ListO(1)O(1)O(depth) CTEO(depth) CTEMinimal
Nested SetsO(n)O(n)O(1)O(1)Minimal
Path EnumerationO(1)O(n)O(1)O(1) LIKEPath column
Closure TableO(n)O(n*depth)O(depth)O(depth)O(n^2)

All types share one table with a type discriminator:

CREATE TABLE payments (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('credit_card', 'paypal', 'bank_transfer')),
amount NUMERIC(10,2) NOT NULL,
-- Credit card fields
card_last_four CHAR(4),
-- PayPal fields
paypal_email TEXT,
-- Bank transfer fields
bank_account TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Query specific type
SELECT * FROM payments WHERE type = 'credit_card' AND card_last_four IS NOT NULL;

Class Table Inheritance (One Table per Type)

Section titled “Class Table Inheritance (One Table per Type)”
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE credit_card_payments (
payment_id INTEGER PRIMARY KEY REFERENCES payments(id),
card_last_four CHAR(4) NOT NULL,
expiry_month INTEGER NOT NULL,
expiry_year INTEGER NOT NULL
);
CREATE TABLE paypal_payments (
payment_id INTEGER PRIMARY KEY REFERENCES payments(id),
paypal_email TEXT NOT NULL
);
-- Query with JOIN
SELECT p.*, c.card_last_four
FROM payments p
JOIN credit_card_payments c ON p.id = c.payment_id
WHERE p.type = 'credit_card';

Store type-specific attributes as JSON:

CREATE TABLE payments (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO payments (type, amount, metadata)
VALUES ('credit_card', 100.00, '{"card_last_four": "1234", "expiry": "12/25"}');
INSERT INTO payments (type, amount, metadata)
VALUES ('paypal', 50.00, '{"paypal_email": "user@example.com"}');
-- Query JSONB fields
SELECT * FROM payments WHERE metadata ->> 'card_last_four' = '1234';
CREATE INDEX idx_payments_metadata ON payments USING GIN (metadata);
ApproachProsCons
Shared tableSimple queries, no JOINsMany NULL columns, weak typing
Class tableStrong typing, no wasted spaceJOINs required, complex queries
JSON columnsFlexible, schemaless fieldsNo foreign keys, no type checking
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(order_id),
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10,2) NOT NULL,
discount NUMERIC(5,2) NOT NULL DEFAULT 0,
line_total NUMERIC(10,2) GENERATED ALWAYS AS (quantity * unit_price * (1 - discount / 100)) STORED,
PRIMARY KEY (order_id, product_id)
);
-- User follows user (social graph)
CREATE TABLE follows (
follower_id INTEGER NOT NULL REFERENCES users(user_id),
followee_id INTEGER NOT NULL REFERENCES users(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (follower_id, followee_id),
CHECK (follower_id != followee_id)
);
CREATE INDEX idx_follows_followee ON follows (followee_id);
-- Soft delete: mark as deleted but keep the row
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
deleted_at TIMESTAMPTZ, -- NULL = active, non-NULL = deleted
CONSTRAINT valid_email CHECK (
deleted_at IS NULL OR email LIKE '%_deleted_%'
)
);
-- All queries must filter out soft-deleted rows
SELECT * FROM users WHERE deleted_at IS NULL;
-- Hard delete: permanently remove the row
DELETE FROM users WHERE user_id = 42;
AspectSoft DeleteHard Delete
RecoveryReversible (set deleted_at=NULL)Not reversible
StorageRows accumulateSpace freed
Query perfAll queries need WHERE clauseNo filter overhead
UniquenessMust handle deleted emailsNatural uniqueness
ReferentialFK constraints still applyCASCADE removes dependents