Good schema design is not about theory. It is about patterns that solve real problems — audit trails, multi-tenancy, concurrent updates, and data history. These patterns work with any relational database.
Soft Deletes
Hard deleting rows permanently removes data. Soft deletes mark rows as deleted without removing them — useful for audit trails, undo functionality, and regulatory compliance.
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
-- Soft delete
UPDATE users SET deleted_at = NOW() WHERE id = 42;
-- Query active users only
SELECT * FROM users WHERE deleted_at IS NULL;
-- Include deleted users
SELECT * FROM users;
-- Restore
UPDATE users SET deleted_at = NULL WHERE id = 42;
Create a partial index for performance on active records:
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;
Use a database view to simplify queries:
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;
Audit Log Pattern
Track who changed what and when:
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id BIGINT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
old_data JSONB,
new_data JSONB,
changed_by BIGINT REFERENCES users (id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_log_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_time ON audit_log (changed_at DESC);
Auto-populate with a trigger:
CREATE OR REPLACE FUNCTION log_changes() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END,
CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE to_jsonb(NEW) END
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_audit
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION log_changes();
Multi-Tenancy
Three common approaches for isolating data per tenant (customer, organization):
1. Row-level isolation — simplest, add a tenant_id column:
ALTER TABLE users ADD COLUMN tenant_id BIGINT NOT NULL REFERENCES tenants (id);
ALTER TABLE orders ADD COLUMN tenant_id BIGINT NOT NULL REFERENCES tenants (id);
-- All queries filter by tenant
SELECT * FROM users WHERE tenant_id = :current_tenant;
Enable Row-Level Security in PostgreSQL to enforce this at the database level:
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::BIGINT);
-- Set the tenant for each connection
SET app.tenant_id = '42';
2. Schema-level isolation — each tenant gets their own schema (100s of tenants):
CREATE SCHEMA tenant_42;
CREATE TABLE tenant_42.users (...);
3. Database-level isolation — each tenant gets their own database (10s of tenants, strict isolation needed).
ULID vs UUID vs Auto-Increment
| Type | Example | Sortable | URL-safe | Distributed-safe |
|---|---|---|---|---|
| Auto-increment | 42 | ✅ | ✅ | ❌ (single DB only) |
| UUID v4 | 550e8400-... | ❌ | With formatting | ✅ |
| UUID v7 | 0191b9a0-... | ✅ (timestamp-based) | With formatting | ✅ |
| ULID | 01ARZ3NDEKTSV4RRFFQ69G5FAV | ✅ | ✅ | ✅ |
For distributed systems or public-facing IDs, use ULIDs or UUID v7:
-- PostgreSQL: use gen_random_uuid() (UUID v4)
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
...
);
-- Or install pg_ulid extension for ULIDs
CREATE EXTENSION IF NOT EXISTS pg_ulid;
CREATE TABLE orders (
id TEXT PRIMARY KEY DEFAULT gen_ulid(),
...
);
In Node.js:
npm install ulid
import { ulid } from "ulid";
const id = ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
Optimistic Locking with Version Column
Detect concurrent modifications without locking rows:
ALTER TABLE products ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
-- Read
SELECT id, name, price, stock, version FROM products WHERE id = 5;
-- Returns: version = 3
-- Update — only succeeds if version hasn't changed
UPDATE products
SET price = 99.99, version = version + 1
WHERE id = 5 AND version = 3;
-- Check affected rows in your application
-- 0 rows affected = concurrent update detected → retry
Polymorphic Associations
One table references multiple other tables (e.g., comments on posts, videos, and products):
Option 1: Nullable foreign keys (simple, but messy):
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
text TEXT NOT NULL,
post_id BIGINT REFERENCES posts (id),
video_id BIGINT REFERENCES videos (id),
product_id BIGINT REFERENCES products (id),
-- Only one should be non-null
CONSTRAINT chk_one_parent CHECK (
(post_id IS NOT NULL)::int +
(video_id IS NOT NULL)::int +
(product_id IS NOT NULL)::int = 1
)
);
Option 2: Generic association table (better for many types):
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
target_type TEXT NOT NULL CHECK (target_type IN ('post', 'video', 'product')),
target_id BIGINT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_comments_target ON comments (target_type, target_id);
Pagination Patterns
Offset pagination — simple but slow on large tables:
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 200;
-- Gets slower as offset increases (reads 220 rows, discards 200)
Cursor pagination — fast for large datasets:
-- First page
SELECT * FROM posts ORDER BY id DESC LIMIT 20;
-- Last id returned: 580
-- Next page (use last id as cursor)
SELECT * FROM posts WHERE id < 580 ORDER BY id DESC LIMIT 20;
Cursor pagination is O(log n) with an index. Offset pagination is O(n).
What’s Next?
You know design patterns that apply to any schema. Next: ORMs vs raw SQL — when to use Prisma, SQLAlchemy, or GORM, and when to write SQL directly.
Next: Database Tutorial #19: ORMs vs Raw SQL — Prisma, SQLAlchemy, GORM
Related Articles
- Database Tutorial #17: SQLite — When and How to Use It
- Database Tutorial #19: ORMs vs Raw SQL — Prisma, SQLAlchemy, GORM
- Database Tutorial #7: PostgreSQL Migrations and Schema Design