| name | database-design |
| description | Expert database design guidance — schema design, normalization, indexing strategy, zero-downtime migrations, transactions, and query optimization for production PostgreSQL systems. |
Database Design — Expert Reference
Schema Design Principles
Primary Keys
Every table gets a surrogate primary key. Natural keys become unique constraints.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(320) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_users_email UNIQUE (email)
);
CREATE TABLE users (
email VARCHAR(320) PRIMARY KEY,
...
);
Why surrogate keys:
- Natural keys change (email changes, SSNs get reassigned)
- Stable FK references — no cascading updates
- Join performance — integer vs varchar comparison
- Hide internal identifiers from external URLs (use UUID or ULID for external-facing IDs)
Naming Conventions
CREATE TABLE order_line_items ( ... );
id BIGSERIAL PRIMARY KEY
user_id BIGINT NOT NULL REFERENCES users(id),
product_id BIGINT NOT NULL REFERENCES products(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
has_verified_email BOOLEAN NOT NULL DEFAULT FALSE,
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
CONSTRAINT uq_users_email UNIQUE (email)
Normalization
Normal Forms
1NF: Each column holds atomic values; no repeating groups
Violation: tags VARCHAR = "python,java,sql"
Fix: tags table with FK
2NF: 1NF + every non-key attribute depends on the WHOLE primary key
(Only relevant when PK is composite)
Violation: order_items(order_id, product_id, product_name)
product_name depends only on product_id
Fix: move product_name to products table
3NF: 2NF + no transitive dependencies (non-key → non-key)
Violation: employees(id, dept_id, dept_name)
dept_name depends on dept_id, not on id
Fix: move dept_name to departments table
BCNF: 3NF + every determinant is a candidate key
(Edge case — rarely violated in practice)
When to Denormalize Deliberately
Denormalize when normalization causes unacceptable query complexity or performance cost:
SELECT o.id, SUM(li.quantity * li.unit_price) as total
FROM orders o
JOIN order_line_items li ON li.order_id = o.id
GROUP BY o.id;
ALTER TABLE orders ADD COLUMN total_amount NUMERIC(12,2) NOT NULL DEFAULT 0;
Deliberate denormalization is justified when:
- Read:Write ratio is very high (reporting tables)
- The denormalized value is expensive to recompute
- Data is immutable after creation (event tables, audit logs)
- Document this decision in a comment or ADR
Soft Delete
ALTER TABLE products ADD COLUMN deleted_at TIMESTAMPTZ;
UPDATE products SET deleted_at = NOW() WHERE id = 42;
SELECT * FROM products WHERE deleted_at IS NULL;
UPDATE products SET deleted_at = NULL WHERE id = 42;
Tradeoffs
| Concern | Impact |
|---|
| Query complexity | Every query needs WHERE deleted_at IS NULL |
| FK violations | FK references to soft-deleted rows still resolve |
| Index bloat | Deleted rows stay in all indexes |
| Unique constraints | Deleted email still blocks re-registration |
| GDPR/right to erasure | Soft-delete doesn't satisfy erasure requirements |
Fix for unique constraint with soft delete:
CREATE UNIQUE INDEX uq_users_active_email
ON users(email)
WHERE deleted_at IS NULL;
Alternative: archive table pattern
INSERT INTO users_archive SELECT *, NOW() as archived_at FROM users WHERE id = 42;
DELETE FROM users WHERE id = 42;
Indexing Strategy
Index Types (PostgreSQL)
B-Tree (default)
├── Equality: WHERE email = 'x@y.com'
├── Range: WHERE created_at > '2024-01-01'
├── ORDER BY, LIMIT
└── LIKE 'prefix%' (but NOT LIKE '%suffix')
Hash
├── Equality only: WHERE id = 42
└── Slightly faster than B-Tree for equality, no range support
GIN (Generalized Inverted Index)
├── JSONB columns: WHERE metadata @> '{"status": "active"}'
├── Full-text search: WHERE to_tsvector('english', body) @@ 'query'
└── Array columns: WHERE tags @> ARRAY['python']
GiST
└── Geometric types, range types, full-text (alternative to GIN)
BRIN (Block Range Index)
└── Very large tables with natural physical ordering (time-series)
Tiny index, trades precision for size
Partial Index
CREATE INDEX idx_users_active_email
ON users(email)
WHERE deleted_at IS NULL;
CREATE INDEX idx_orders_recent
ON orders(created_at DESC, user_id)
WHERE created_at > NOW() - INTERVAL '90 days';
Composite Index
Column order is critical. Rule: most selective first for equality predicates, then range/sort columns.
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);
CREATE INDEX idx_orders_created_status ON orders(created_at DESC, status);
Index column order guide:
1. Equality predicates (= , IN) — in order of selectivity, highest first
2. Range predicates (>, <, BETWEEN) — one range column max
3. ORDER BY columns — must match query sort direction
Covering Index (Index-Only Scan)
Include all columns the query needs — avoids heap access entirely.
CREATE INDEX idx_orders_date_covering
ON orders(order_date)
INCLUDE (user_id, status);
N+1 Query Problem
The problem: Fetching a list, then querying for each item individually.
orders = session.query(Order).limit(100).all()
for order in orders:
print(order.customer.name)
Fix 1: JOIN / eager load
from sqlalchemy.orm import joinedload
orders = (
session.query(Order)
.options(joinedload(Order.customer))
.limit(100)
.all()
)
Fix 2: IN query (batch load)
SELECT * FROM customers WHERE id IN (1, 2, 3, ..., 100);
Fix 3: DataLoader (GraphQL / async contexts)
from strawberry.dataloader import DataLoader
async def load_customers(customer_ids: list[int]) -> list[Customer]:
return await Customer.filter(id__in=customer_ids)
customer_loader = DataLoader(load_fn=load_customers)
EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, c.name, o.total_amount
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
AND o.created_at > NOW() - INTERVAL '7 days'
ORDER BY o.created_at DESC
LIMIT 50;
Reading Query Plans
Seq Scan on orders (cost=0.00..45000.00 rows=1200 width=40)
Filter: (status = 'pending' AND created_at > ...)
Rows Removed by Filter: 498800
| Plan node | Meaning | What to do |
|---|
Seq Scan | Full table scan | Add index if table is large and filter is selective |
Index Scan | Follows index, then fetches heap | Good; consider covering index if width is large |
Index Only Scan | Reads index only, no heap | Best for read-heavy queries |
Bitmap Heap Scan | Batch heap access after bitmap index scan | Good for moderate selectivity |
Hash Join | Build hash table on smaller relation | Fine; check if inner table fits in work_mem |
Nested Loop | For each outer row, scan inner | Good with small sets; bad with large |
Sort | In-memory or on-disk sort | Add index matching ORDER BY; or increase work_mem |
Key numbers to watch:
cost=0.00..45000.00 → estimated startup..total cost (arbitrary units)
rows=1200 → estimated rows; actual rows shown with ANALYZE
actual time=0.043..892.000 → actual milliseconds startup..total
Buffers: shared hit=4500 read=12000 → 12000 disk reads is expensive
Zero-Downtime Migrations
Guiding Principle
Deploy ≠ Migrate.
Run migrations separately from deploys.
New code must handle BOTH old schema (rollback) and new schema (forward).
Add Column — Safe
ALTER TABLE users ADD COLUMN middle_name VARCHAR(100);
ALTER TABLE users ADD COLUMN is_verified BOOLEAN NOT NULL DEFAULT FALSE;
Rename Column — Expand-Contract
ALTER TABLE orders ADD COLUMN order_total NUMERIC(12,2);
UPDATE orders SET order_total = amount;
ALTER TABLE orders DROP COLUMN amount;
Remove Column — Application First
ALTER TABLE orders DROP COLUMN deprecated_field;
Add Index — Always CONCURRENTLY
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
DROP INDEX CONCURRENTLY idx_orders_user_id;
Change Column Type — Never Directly
ALTER TABLE events ALTER COLUMN event_id TYPE BIGINT;
ALTER TABLE events ADD COLUMN event_id_new BIGINT;
UPDATE events SET event_id_new = event_id::BIGINT;
ALTER TABLE events ALTER COLUMN event_id_new SET NOT NULL;
ALTER TABLE events DROP COLUMN event_id;
ALTER TABLE events RENAME COLUMN event_id_new TO event_id;
Migration Decision Matrix
Operation Safe? Notes
────────────────────────────────────────────────────────────
ADD COLUMN nullable YES Instant (catalog only)
ADD COLUMN NOT NULL DEFAULT YES* PG11+: catalog only; PG<11: table rewrite
ADD COLUMN NOT NULL no default NO Requires backfill first
DROP COLUMN YES Instant (catalog only in PG)
RENAME COLUMN NO Use Expand-Contract
ADD INDEX NO Use CREATE INDEX CONCURRENTLY
ADD UNIQUE CONSTRAINT NO Use CREATE UNIQUE INDEX CONCURRENTLY first
CHANGE TYPE NO Use Expand-Contract
DROP TABLE NO Remove from app first, then drop
Transactions and Isolation
ACID
Atomicity — All operations in a transaction succeed, or none do.
Consistency — Transaction brings database from one valid state to another.
Isolation — Concurrent transactions behave as if serialized.
Durability — Committed transactions survive crashes.
Isolation Levels
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Practical example — bank transfer:
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Locking Strategies
Optimistic Locking
ALTER TABLE products ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
SELECT id, price, version FROM products WHERE id = 42;
UPDATE products
SET price = 109.99, version = version + 1
WHERE id = 42 AND version = 7;
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True)
price = Column(Numeric(10, 2))
version = Column(Integer, nullable=False, default=1)
__mapper_args__ = {"version_id_col": version}
Pessimistic Locking
BEGIN;
SELECT * FROM orders WHERE id = 42 FOR UPDATE;
SELECT * FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
Optimistic vs Pessimistic:
Optimistic locking:
+ No lock held during think time
+ Better throughput under low contention
- Requires retry logic in application
- Bad under high contention (many retries = wasted work)
Pessimistic locking:
+ Guarantees no conflict
- Deadlock risk (always acquire locks in same order)
- Serializes access — lower throughput under high load
Use for: inventory reservation, seat booking, anything where retries are expensive
Connection Pooling
PgBouncer Modes
Session mode — Connection assigned for client session lifetime.
Compatible with all PostgreSQL features.
Pool size = max concurrent sessions.
Transaction mode — Connection assigned for single transaction.
Cannot use: LISTEN/NOTIFY, server-side cursors,
prepared statements (by default), session-level settings.
Pool size = max concurrent transactions (much smaller).
RECOMMENDED for most web apps.
Statement mode — Connection returned after each statement.
Cannot use: transactions, multi-statement queries.
Use only for simple, single-statement workloads.
Pool Size Formula
pool_size = (CPU cores × 2) + effective_disk_spindles
For cloud/SSD: effective_disk_spindles = 1
Example: 8 core machine with SSD → pool_size = 17
Application pool size per instance:
total_db_pool_size / number_of_app_instances
Rule: Start with 10-20 connections per app instance. Monitor
pg_stat_activity and pool wait time. Increase only if wait
time is high and CPU is not saturated.
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
server_idle_timeout = 600
JSON Columns
When to Use JSONB
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(500) NOT NULL,
category VARCHAR(100) NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'
);
SELECT name FROM products
WHERE attributes @> '{"color": "red"}'
AND attributes->>'brand' = 'Nike';
CREATE INDEX idx_products_attributes ON products USING GIN(attributes);
When NOT to Use JSON
Use normalized columns when:
- The field is used in WHERE clauses, JOINs, or GROUP BY
- You need foreign key constraints on the field
- The schema is stable and well-understood
- You need CHECK constraints on the field value
Using JSON for these creates: slow queries, no constraint enforcement,
difficult schema evolution.
Partitioning
Use when tables exceed ~100M rows, or when archival/retention is needed.
CREATE TABLE events (
id BIGSERIAL,
occurred_at TIMESTAMPTZ NOT NULL,
type VARCHAR(100) NOT NULL,
payload JSONB
) PARTITION BY RANGE (occurred_at);
CREATE TABLE events_2024_q1 PARTITION OF events
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE events_2024_q2 PARTITION OF events
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
CREATE TABLE orders (
id BIGSERIAL,
region VARCHAR(20) NOT NULL
) PARTITION BY LIST (region);
CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('us-east', 'us-west');
CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('eu-central', 'eu-west');
CREATE TABLE users (id BIGSERIAL, ...) PARTITION BY HASH (id);
CREATE TABLE users_0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE users_1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1);
Partition benefits:
- Partition pruning — queries touching one partition skip all others
- Archival by dropping a partition (instant) instead of DELETE (slow)
- Parallel query across partitions
Migration Tooling
Alembic (Python)
def upgrade() -> None:
op.add_column(
"users",
sa.Column("phone_number", sa.String(20), nullable=True)
)
def downgrade() -> None:
op.drop_column("users", "phone_number")
alembic upgrade head
alembic current
alembic upgrade head --sql > migration.sql
alembic downgrade -1
Flyway (JVM)
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=migration_user
flyway.mixed=true
flyway.outOfOrder=false
flyway.validateOnMigrate=true
Query Optimization Checklist
[ ] EXPLAIN ANALYZE on any query taking > 100ms in prod
[ ] Seq Scan on large table (>10k rows) with selective filter → add index
[ ] Check rows estimate vs actual rows — large discrepancy → run ANALYZE
[ ] Sort node with large rows → add index matching ORDER BY
[ ] N+1 detected in ORM query log → eager load or batch query
[ ] Connection wait time high → increase pool size or reduce connection hold time
[ ] Index on low-cardinality column (status: active/inactive) → partial index
[ ] Unused indexes → DROP them (write overhead, no read benefit)
[ ] Text search → use GIN + tsvector, not LIKE '%pattern%'
[ ] Large IN() list → JOIN to a temp table or VALUES clause instead