| name | database-design |
| description | Use when designing relational database schemas from requirements, normalizing existing schemas, planning zero-downtime migrations for large tables, implementing multi-tenancy patterns, designing audit trails or soft-delete strategies, configuring row-level security (RLS), planning index strategies, or generating TypeScript/Python types from schema. Use for schema normalization, relationship design, migration planning, index optimization, and data integrity. |
Database Design
Production-quality relational database design: schema, migrations, indexing, security, and multi-tenancy.
Core principle: Get the schema right before the application. Fixing schema design after data accumulates is expensive and risky.
When to Use
- Designing tables for a new feature
- Reviewing a schema for normalization or performance problems
- Planning zero-downtime migrations on large tables
- Adding multi-tenancy to a single-tenant schema
- Implementing audit trails, soft deletes, or row-level security
- Choosing index strategy for slow queries
When NOT to Use
- Writing application-level SQL queries (use coding-principles skill)
- ORM configuration only
- NoSQL database design (different skill domain)
Schema Design Fundamentals
Normalization
Goal: Aim for 3NF by default. Denormalize only with measured justification.
- 1NF: No repeating groups — atomic column values, unique rows
- 2NF: All non-key columns depend on the entire primary key
- 3NF: No transitive dependencies between non-key columns
Data Type Choices
| Concept | Use | Avoid |
|---|
| Primary keys | UUID/CUID (opaque, immutable) | Sequential integers (leaks count) |
| Money | DECIMAL(19,4) or integer cents | FLOAT (rounding errors) |
| Timestamps | TIMESTAMPTZ (timezone-aware) | DATE unless time is irrelevant |
| Enums | CHECK constraint + VARCHAR, or native enum | Free text (inconsistent values) |
| JSON blobs | JSONB in PostgreSQL (indexed) | TEXT or VARCHAR |
| Booleans | Native BOOLEAN | 0/1 without constraint |
Core Design Patterns
Soft Deletes
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
SELECT * FROM users WHERE deleted_at IS NULL;
CREATE INDEX idx_users_active ON users(email) WHERE deleted_at IS NULL;
Audit Trail
created_by_id UUID NOT NULL REFERENCES users(id),
updated_by_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
CREATE TABLE task_audit_log (
id UUID PRIMARY KEY,
task_id UUID NOT NULL REFERENCES tasks(id),
operation VARCHAR(10),
before_data JSONB,
after_data JSONB,
changed_by UUID NOT NULL REFERENCES users(id),
changed_at TIMESTAMPTZ DEFAULT NOW()
);
Multi-Tenancy
Add organization_id to all tenant-scoped tables. Denormalize it (even into child tables) so RLS policies can be simple.
CREATE TABLE projects (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL REFERENCES organizations(id),
name VARCHAR(255) NOT NULL,
UNIQUE(organization_id, name)
);
CREATE TABLE tasks (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL REFERENCES organizations(id),
project_id UUID NOT NULL REFERENCES projects(id),
title VARCHAR(255)
);
Optimistic Locking (Concurrent Updates)
CREATE TABLE accounts (
id UUID PRIMARY KEY,
balance DECIMAL(19,2),
version INT DEFAULT 1
);
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = $1 AND version = $2;
Migration Planning
Zero-Downtime: Expand-Contract Pattern
Never rename a column or change a type in one step on a live system.
Phase 1 (Expand): Add new column alongside old — no downtime
Phase 2 (Backfill): Migrate data in batches of 5,000 rows
Phase 3 (Dual-write): App writes both old + new columns
Phase 4 (Cutover): App reads new column only — deploy
Phase 5 (Contract): Drop old column after 48h stability
Batch backfill (avoids table locks):
UPDATE users SET full_name = name
WHERE id IN (SELECT id FROM users WHERE full_name IS NULL LIMIT 5000);
Migration Checklist
Before:
After:
Index Strategy
Decision Matrix
| Query Pattern | Index Type |
|---|
WHERE email = ? | Single-column B-tree |
WHERE org_id = ? AND status = ? | Composite B-tree (most selective first) |
WHERE deleted_at IS NULL | Partial index |
WHERE created_at BETWEEN ? AND ? | B-tree range |
| Full-text search | GIN (to_tsvector) |
SELECT id, name without table lookup | Covering index with INCLUDE |
Reading EXPLAIN Plans
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 123;
| Signal | Meaning | Action |
|---|
Seq Scan on large table | Missing index | Add index |
Nested Loop with 1M+ rows | Missing join index | Add FK index |
High Buffers: reads | Data not cached | Tune shared_buffers or query |
Low Buffers: hits ratio | Cold cache | Normal on first run |
Index Anti-Patterns
- Over-indexing (15+ indexes per table → slow writes)
- Foreign keys without indexes (slow JOINs and cascades)
- Redundant indexes (col1,col2) AND (col1) — second is redundant
- Non-sargable predicates:
WHERE YEAR(created_at) = 2025 (can't use index)
Row-Level Security (RLS)
Enforce multi-tenancy at the database layer — defense-in-depth against application bugs.
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tasks_org_isolation ON tasks
FOR ALL TO app_user
USING (
organization_id IN (
SELECT organization_id FROM organization_members
WHERE user_id = current_setting('app.current_user_id')::uuid
)
);
CREATE POLICY tasks_no_deleted ON tasks
FOR SELECT TO app_user
USING (deleted_at IS NULL);
SELECT set_config('app.current_user_id', $1, true);
Critical: Test RLS with a non-superuser role. Superuser bypasses all RLS policies.
Type Generation
Derive TypeScript/Python types from schema to stay in sync:
model Task {
id String @id @default(cuid())
organizationId String
title String
deletedAt DateTime?
createdAt DateTime @default(now())
}
Common Mistakes
| Mistake | Impact |
|---|
| FK columns without indexes | Slow JOINs and cascade operations |
| Soft delete without partial index | Full table scan on every query |
| Mutable surrogate keys (email as PK) | Cascading updates across all FKs |
NOT NULL column added without default | Breaks existing INSERT statements |
| No optimistic locking | Silent data loss from concurrent updates |
| RLS not tested with app_user role | Superuser bypasses policies — false safety |
| Sequential integer PKs | Leaks business data (user count, order volume) |
| Money stored as FLOAT | Rounding errors in financial calculations |
Verification Checklist