Database schema design: table/collection modeling, relationships, indexing strategy, migration planning. Use when: designing new tables or collections, choosing between normalization approaches, planning indexes, writing migration scripts, modeling many-to-many or hierarchical data, or deciding on soft delete vs hard delete. Skip when: writing queries against an already-defined schema with no design decisions.
snake_case for tables/columns, plural table names (users, orders)id (PK), created_at, updated_at
updated_atdeleted_at TIMESTAMP NULL only when data recovery or audit trail is required — not as a default on every table| Pattern | Example |
|---|---|
| Foreign key |
user_id, order_id |
| Boolean | is_active, has_verified |
| Timestamp | published_at, expires_at |
| Status enum | status with CHECK or ENUM |
| Soft delete | deleted_at (NULL = active) |
| Tenant scoping | tenant_id on every multi-tenant table |
order_items owned by order)assigned_user_id when user is deleted)When using deleted_at:
CREATE UNIQUE INDEX users_email_active ON users(email) WHERE deleted_at IS NULL;
WHERE deleted_at IS NULL — enforce via ORM scope or viewEvery table with tenant-scoped data must have tenant_id:
orders(id, tenant_id, user_id, status, created_at)
tenant_id on every tenant-scoped tabletenant_id first — (tenant_id, status), (tenant_id, created_at)One-to-many: FK on the "many" side
orders.user_id -> users.id
Many-to-many: junction table
user_roles(user_id, role_id, created_at) -- composite PK or surrogate id + unique constraint
Self-referential (e.g., category tree): parent_id REFERENCES self(id)
Polymorphic (avoid if possible): use separate FKs or a union table instead of entity_type + entity_id.
Add indexes for:
tenant_id on every multi-tenant tablestatus, user_id, created_at)Composite index column order: equality filters first, then range/sort columns.
-- Query: WHERE tenant_id = ? AND status = ? ORDER BY created_at
INDEX (tenant_id, status, created_at)
Avoid over-indexing — each index slows writes. Add when query patterns are known.
Zero-downtime migration rules:
CREATE INDEX CONCURRENTLY (Postgres) or equivalentMigration file naming: YYYYMMDD_NNN_description.sql — ordered, descriptive.
Audit log table (immutable event history):
audit_logs(id, entity_type, entity_id, action, actor_id, payload JSONB, created_at)
Status machine — use an enum/CHECK constraint, never a boolean per state:
status VARCHAR CHECK (status IN ('pending', 'active', 'cancelled', 'completed'))
Hierarchical data (e.g., categories, comments):
parent_id) — simple queries, slow deep traversalBefore finalizing a schema:
id, created_at, updated_at (note exceptions above)tenant_id indexed, composite indexes tenant-first