Database schema design, migrations, indexing, and query optimization for relational databases. Use this skill when designing schemas, writing migrations, optimizing queries, or making data modeling decisions. Covers PostgreSQL patterns applicable to most SQL databases.
snake_case for all identifiers: tables, columns, indexes, constraints.users, posts, order_items.user_id, parent_comment_id -- the referenced table (singular) + _id.is_ or has_ prefix: is_active, has_verified_email._at suffix: created_at, updated_at, deleted_at.idx_{table}_{columns}. Unique: uniq_{table}_{columns}.Every table should include:
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- domain columns here
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
BIGINT GENERATED ALWAYS AS IDENTITY for primary keys, not SERIAL.TIMESTAMPTZ (with time zone), never TIMESTAMP without time zone.updated_at with a trigger or application-level update.deleted_at TIMESTAMPTZ for soft deletes when business rules require auditability.TEXT instead of VARCHAR(n) unless you need a hard length constraint enforced at the DB level. Validate lengths in the application.BOOLEAN not integers for true/false.NUMERIC(precision, scale) for money and exact decimals, never FLOAT or DOUBLE.JSONB for semi-structured data. Avoid it for data you need to query or join on frequently.UUID for public-facing identifiers (API responses, URLs). Keep integer IDs as internal primary keys for performance.ENUM types for fixed, rarely-changing sets. Use a reference table for sets that change.CREATE TABLE posts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_posts_user_id ON posts(user_id);
ON DELETE behavior deliberately: CASCADE, SET NULL, RESTRICT.CREATE TABLE post_tags (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
assigned_at, role) to the junction table when the relationship carries data.Use a foreign key with a unique constraint. Decide which table owns the relationship.
WHERE clauses used frequently.JOIN conditions (foreign keys).ORDER BY on large tables.UNIQUE constraints (automatically indexed).-- B-tree (default, good for equality and range)
CREATE INDEX idx_users_email ON users(email);
-- Partial index (index only matching rows)
CREATE INDEX idx_users_active ON users(email) WHERE is_active = true;
-- Composite index (column order matters)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);
-- GIN index (for JSONB, arrays, full-text search)
CREATE INDEX idx_products_metadata ON products USING gin(metadata);
-- Covering index (includes columns to avoid table lookup)
CREATE INDEX idx_orders_lookup ON orders(user_id) INCLUDE (status, total);
(a, b) supports queries on a alone, but NOT on b alone.EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...
actual rows vs estimated rows -- indicates stale statistics. Run ANALYZE.EXISTS instead of IN for subqueries with large result sets.SELECT *. Select only needed columns.OFFSET:
-- Instead of: SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 1000;
SELECT * FROM posts WHERE id > :last_seen_id ORDER BY id LIMIT 20;
WITH) for readability but be aware they may fence off optimization in some databases.INSERT INTO ... VALUES (...), (...), (...) instead of individual inserts.up and down operations.Adding a column:
-- Safe: nullable column with no default (instant in PostgreSQL)
ALTER TABLE users ADD COLUMN bio TEXT;
-- Safe in PostgreSQL 11+: column with a default (uses metadata, not rewrite)
ALTER TABLE users ADD COLUMN is_verified BOOLEAN NOT NULL DEFAULT false;
Renaming or removing columns -- use a multi-step process:
Adding an index on a large table:
-- Use CONCURRENTLY to avoid locking the table
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
NOT NULL by default. Only allow NULL when absence of a value has specific meaning.CHECK constraints for business rules:
ALTER TABLE orders ADD CONSTRAINT chk_orders_total CHECK (total >= 0);
UNIQUE constraints to enforce business uniqueness rules at the database level.