Practical Patterns#
Database theory gets you through schema design and query writing. This section covers the patterns you encounter when databases meet production code — migrations, pagination, connection pooling, multi-tenancy, and the N+1 query problem.
Schema Migrations#
The Problem#
Database schemas evolve. Adding columns, changing types, creating indexes — these changes must be applied consistently across dev, staging, and production.
Migration Tools#
| Tool | Language | Approach |
|---|---|---|
| Flyway | Java, CLI | Versioned SQL files (V1__create_users.sql) |
| Liquibase | Java, CLI | XML/YAML/SQL changesets |
| Alembic | Python | Autogenerated from SQLAlchemy models |
| golang-migrate | Go | Numbered SQL files |
| Prisma Migrate | TypeScript | Schema-first, auto-generated SQL |
| Rails Migrations | Ruby | Ruby DSL for schema changes |
| dbmate | Any | Plain SQL files, lightweight CLI |
Migration Best Practices#
| Practice | Why |
|---|---|
| Every change is a migration file | Reproducible, version-controlled |
| Migrations are forward-only | Never edit a migration that has been applied |
| Test migrations on a copy of production data | Catch data-dependent failures |
| Separate schema changes from data changes | Schema DDL is fast; data migration can be slow |
| Make changes backwards-compatible | Deploy code that works with both old and new schema |
Zero-Downtime Migration Pattern#
1. Add new column (nullable) ← migration 1
2. Deploy code that writes to BOTH old and new columns
3. Backfill new column from old column ← migration 2
4. Deploy code that reads from new column only
5. Drop old column ← migration 3Never rename or remove a column in a single deployment — the old code still running during rollout will break.
Pagination#
Offset-Based (Simple, Problematic)#
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 40;| Pros | Cons |
|---|---|
| Simple to implement | Slow for large offsets (DB scans and discards rows) |
| Jump to any page | Inconsistent if data changes between pages |
| Works with any query | O(offset + limit) per query |
Cursor-Based (Keyset Pagination)#
-- First page
SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT 20;
-- Next page (cursor = last item's created_at + id)
SELECT * FROM products
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;| Pros | Cons |
|---|---|
| Consistent O(limit) regardless of page depth | Cannot jump to arbitrary page |
| Stable under concurrent inserts/deletes | Requires a unique, ordered column |
| Index-friendly | Cursor must be opaque to client (encode it) |
Use cursor-based pagination for APIs. Offset-based is acceptable for admin UIs with small datasets.
The N+1 Query Problem#
# N+1: 1 query for orders + N queries for users
orders = db.query("SELECT * FROM orders") # 1 query
for order in orders:
user = db.query("SELECT * FROM users WHERE id = ?", order.user_id) # N queriesSolutions#
-- JOIN (single query)
SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id;
-- Subquery / IN clause (2 queries)
-- Query 1: SELECT * FROM orders
-- Query 2: SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5)
-- ORM eager loading
-- SQLAlchemy: .options(joinedload(Order.user))
-- Django: .select_related('user')
-- ActiveRecord: .includes(:user)Connection Pooling#
Opening a database connection is expensive (~20-50ms for PostgreSQL with TLS). A connection pool maintains a set of reusable connections.
Pool Configuration#
| Parameter | Meaning | Typical Value |
|---|---|---|
min_connections | Minimum idle connections | 2-5 |
max_connections | Maximum total connections | 10-20 per app instance |
idle_timeout | Close idle connections after | 300s |
max_lifetime | Recycle connections after | 1800s |
connection_timeout | Wait time for available connection | 5s |
Sizing Rule#
max_connections_per_instance × number_of_instances ≤ database_max_connectionsPostgreSQL default max_connections is 100. With 10 app instances at 20 connections each = 200 — exceeded. Use PgBouncer or RDS Proxy to multiplex.
Connection Proxies#
| Tool | Type |
|---|---|
| PgBouncer | PostgreSQL connection pooler (transaction mode) |
| RDS Proxy | AWS-managed proxy for RDS/Aurora |
| ProxySQL | MySQL connection pooler |
Multi-Tenancy Patterns#
Shared Database, Shared Schema (Column Discriminator)#
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL, -- discriminator column
amount DECIMAL(10, 2),
-- ...
INDEX idx_tenant (tenant_id)
);
-- Every query must include tenant_id
SELECT * FROM orders WHERE tenant_id = 42 AND status = 'active';Risk: A missed WHERE tenant_id = ? leaks data across tenants. Use Row-Level Security (RLS) in PostgreSQL to enforce:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::int);Shared Database, Separate Schemas#
CREATE SCHEMA tenant_42;
CREATE TABLE tenant_42.orders (...);
SET search_path = tenant_42;
SELECT * FROM orders; -- automatically scopedSeparate Databases#
One database per tenant. Maximum isolation, simplest security, hardest to manage at scale.
| Pattern | Isolation | Complexity | Scale |
|---|---|---|---|
| Column discriminator | Low | Low | 1000s of tenants |
| Schema per tenant | Medium | Medium | 100s of tenants |
| Database per tenant | High | High | 10s of tenants |
Soft Deletes#
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;
-- "Delete"
UPDATE users SET deleted_at = NOW() WHERE id = 42;
-- Query (exclude deleted)
SELECT * FROM users WHERE deleted_at IS NULL;
-- Create a view for convenience
CREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL;| Pros | Cons |
|---|---|
| Recoverable (undelete = set null) | Every query must filter deleted_at IS NULL |
| Audit trail | Table grows unbounded |
| Foreign keys still resolve | Partial unique indexes needed |
Key Takeaways#
- Migrations must be forward-only, backwards-compatible, and tested on production-like data.
- Use cursor-based pagination for APIs. Offset-based is O(offset) and inconsistent under writes.
- The N+1 problem is the #1 ORM performance pitfall. Fix with JOINs, IN queries, or eager loading.
- Connection pools are essential. Size them so
instances × pool_size ≤ DB max_connections. - Multi-tenancy: column discriminator for most SaaS, schema/DB isolation for compliance-heavy tenants.
- Soft deletes are convenient but add complexity to every query. Consider an archive table instead.