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

TypeExampleSortableURL-safeDistributed-safe
Auto-increment42❌ (single DB only)
UUID v4550e8400-...With formatting
UUID v70191b9a0-...✅ (timestamp-based)With formatting
ULID01ARZ3NDEKTSV4RRFFQ69G5FAV

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