| name | schema-design |
| description | This skill should be used when the user asks about "database schema", "data modeling", "table design", "column types", "primary key", "foreign key", "indexes", "normalization", "denormalization", "database relationships", "one-to-many", "many-to-many", "entity relationship", "ER diagram", "database constraints", "NOT NULL", "unique constraint", "CHECK constraint", "soft delete", "deleted_at", "audit log", "audit table", "JSONB", "JSON column", "enum type", "PostgreSQL schema", "MySQL schema", "Prisma schema", "SQLAlchemy models", "ActiveRecord migration". Also trigger for "how should I store this data", "how do I model this relationship", "is this database design good", or "how to structure the database for". |
Database Schema Design
Production-proven patterns for relational database schema design in PostgreSQL/MySQL.
Foundational Principles
- Name things for humans — schema is read far more than written; optimize for readability
- Constraints are free documentation — every
NOT NULL, CHECK, and UNIQUE constraint documents a business rule in the database itself
- Normalize first, denormalize with evidence — start normalized; only denormalize when you have measured performance problems
- Schema changes are the hardest deployments — design carefully upfront; migrations on large tables are expensive
Table Naming and Column Conventions
CREATE TABLE orders
CREATE TABLE order_items
CREATE TABLE OrderTable
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES users(id),
...
);
is_active BOOLEAN NOT NULL DEFAULT true
has_subscription BOOLEAN NOT NULL DEFAULT false
can_edit BOOLEAN NOT NULL DEFAULT false
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
deleted_at TIMESTAMPTZ
Column Type Selection
UUID
BIGINT GENERATED ALWAYS AS IDENTITY
TEXT
TEXT
VARCHAR(n)
CHAR(n)
INTEGER
BIGINT
NUMERIC(10, 2)
DOUBLE PRECISION
REAL
TIMESTAMPTZ
DATE
TIME
INTERVAL
BOOLEAN
JSONB
JSON
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');
status TEXT NOT NULL CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled'))
CREATE TABLE order_statuses (id TEXT PRIMARY KEY, label TEXT NOT NULL);
INSERT INTO order_statuses VALUES ('pending', 'Awaiting payment'), ...;
status TEXT NOT NULL REFERENCES order_statuses(id)
Relationship Patterns
One-to-Many
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
Many-to-Many
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE
);
CREATE TABLE product_tags (
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (product_id, tag_id)
);
CREATE INDEX idx_product_tags_tag_id ON product_tags(tag_id);
Self-Referential (Tree / Hierarchy)
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL
);
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
path TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE INDEX idx_categories_path ON categories(path);
CREATE TABLE category_ancestors (
ancestor_id UUID NOT NULL REFERENCES categories(id),
descendant_id UUID NOT NULL REFERENCES categories(id),
depth INTEGER NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id)
);
Index Design
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
CREATE INDEX idx_orders_customer_date ON orders(customer_id, created_at DESC);
CREATE INDEX idx_orders_active ON orders(customer_id, created_at)
WHERE deleted_at IS NULL AND status IN ('pending', 'processing');
CREATE INDEX idx_orders_customer_covering ON orders(customer_id) INCLUDE (status);
CREATE INDEX idx_users_tags ON users USING GIN(tags);
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);
CREATE INDEX idx_posts_search ON posts USING GIN(to_tsvector('english', body));
CREATE INDEX CONCURRENTLY idx_name ON table(column);
When NOT to Add an Index
- Low-cardinality column alone (e.g.,
status with only 3 values on a small table)
- Column almost never queried in WHERE/JOIN/ORDER BY
- Table with under ~1,000 rows (sequential scan is faster)
- Write-heavy tables where each insert/update/delete must maintain all indexes
Constraints
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')),
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
discount_cents INTEGER NOT NULL DEFAULT 0 CHECK (discount_cents >= 0),
CHECK (discount_cents <= total_cents),
payment_card_id UUID REFERENCES payment_cards(id),
payment_account_id UUID REFERENCES payment_accounts(id),
CHECK (
(payment_card_id IS NOT NULL)::integer +
(payment_account_id IS NOT NULL)::integer = 1
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_orders_idempotency
ON orders(customer_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
Audit Logging Pattern
CREATE TABLE audit_logs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
old_data JSONB,
new_data JSONB,
changed_by UUID REFERENCES users(id),
ip_address INET,
user_agent TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_table_record ON audit_logs(table_name, record_id, occurred_at DESC);
CREATE INDEX idx_audit_user ON audit_logs(changed_by, occurred_at DESC);
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (table_name, record_id, operation, old_data, new_data)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE row_to_json(OLD)::JSONB END,
CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE row_to_json(NEW)::JSONB END
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_audit
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
Soft Delete Pattern
deleted_at TIMESTAMPTZ;
CREATE INDEX idx_orders_active ON orders(customer_id, created_at)
WHERE deleted_at IS NULL;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY orders_hide_deleted ON orders
USING (deleted_at IS NULL);
SELECT * FROM orders WHERE customer_id = ? AND deleted_at IS NULL;
UPDATE orders SET deleted_at = now() WHERE id = ?;
Avoiding Common Anti-Patterns
EAV (Entity-Attribute-Value) Anti-pattern
CREATE TABLE entity_attributes (
entity_id UUID,
attr_name TEXT,
attr_value TEXT
);
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);
Polymorphic Associations Anti-pattern
CREATE TABLE comments (
commentable_type TEXT,
commentable_id UUID,
...
);
CREATE TABLE post_comments (post_id UUID REFERENCES posts(id), ...);
CREATE TABLE video_comments (video_id UUID REFERENCES videos(id), ...);
CREATE TABLE article_comments (article_id UUID REFERENCES articles(id), ...);
CREATE TABLE comments (
post_id UUID REFERENCES posts(id),
video_id UUID REFERENCES videos(id),
article_id UUID REFERENCES articles(id),
CHECK (
(post_id IS NOT NULL)::int +
(video_id IS NOT NULL)::int +
(article_id IS NOT NULL)::int = 1
),
...
);
Storing Delimited Values
CREATE TABLE users (
tags TEXT
);
CREATE TABLE users (
tags TEXT[] NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_users_tags ON users USING GIN(tags);
CREATE TABLE user_tags ( user_id UUID, tag_id UUID, PRIMARY KEY (user_id, tag_id) );
Deeper Reference
For normalization decision guides and advanced schema patterns, see: