| name | database-ops |
| description | Unified database operations toolkit — B-tree/GIN/GiST/partial/composite indexing, seeding and fixtures, transaction patterns (isolation levels, sagas, distributed), backup strategies (pg_dump, WAL, PITR), query optimization (EXPLAIN ANALYZE, materialized views), replication (read replicas, streaming, logical), migration patterns (zero-downtime, expand-contract, backfill), connection pooling, sharding, partitioning. Vendor-agnostic DB ops. |
| layer | utility |
| category | database |
| triggers | ["b-tree","backfill","backup strategy","database backup","database index","database seed","db restore","deadlock","expand contract","explain analyze","failover","faker","fixtures","gin index","indexing","isolation level","logical replication","materialized view","migration pattern","optimistic locking","partitioning","pg_dump","point in time recovery","primary-replica","query optimization","query plan","read replica","replication","saga pattern","schema migration","seed data","seed script","slow query","streaming replication","table partition","test data","transaction","two phase commit","wal archiving","zero downtime migration"] |
database-ops
Unified database operations toolkit — B-tree/GIN/GiST/partial/composite indexing, seeding and fixtures, transaction patterns (isolation levels, sagas, distributed), backup strategies (pg_dump, WAL, PITR), query optimization (EXPLAIN ANALYZE, materialized views), replication (read replicas, streaming, logical), migration patterns (zero-downtime, expand-contract, backfill), connection pooling, sharding, partitioning. Vendor-agnostic DB ops.
Absorbs
database-indexing
database-seeding
database-transactions
database-backup
database-optimization
database-replication
database-migration-patterns
database-connection-pooling
database-sharding
database-partitioning
From database-indexing
Database indexing strategies — B-tree, GIN, GiST, hash, BRIN index types, EXPLAIN ANALYZE interpretation, query plan optimization, partial and composite indexes, index maintenance for PostgreSQL
Database Indexing Specialist
Purpose
Indexes are the single most impactful tool for query performance. A missing index turns a 2ms query into a 20-second table scan. A redundant index wastes storage and slows writes. This skill covers index types, reading query plans, designing optimal indexes, and maintaining index health — focused on PostgreSQL but with principles applicable to any RDBMS.
Key Concepts
Index Types and When to Use Them
| Index Type | Best For | Example |
|---|
| B-tree (default) | Equality, range, sorting, LIKE 'prefix%' | WHERE created_at > '2024-01-01' |
| Hash | Equality only (faster than B-tree for =) | WHERE id = 'abc123' |
| GIN | Arrays, JSONB, full-text search, trgm | WHERE tags @> '{postgres}' |
| GiST | Geometry, ranges, nearest-neighbor, trgm | WHERE location <-> point(x,y) < 1000 |
| BRIN | Large tables with naturally ordered data | WHERE created_at BETWEEN ... on append-only tables |
| SP-GiST | Non-balanced tree structures, phone numbers, IP ranges | WHERE ip << '192.168.0.0/16' |
Anatomy of a Query Plan
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 AND status = 'shipped';
QUERY PLAN
---------------------------------------------------------------------------------------------
Index Scan using idx_orders_user_status on orders (cost=0.43..8.45 rows=1 width=120)
Index Cond: ((user_id = 42) AND (status = 'shipped'))
Buffers: shared hit=4
Planning Time: 0.152 ms
Execution Time: 0.065 ms
Key fields to read:
| Field | Meaning |
|---|
Seq Scan | Full table scan — usually bad on large tables |
Index Scan | Uses index, then fetches rows from table |
Index Only Scan | Answered entirely from index (best case) |
Bitmap Index Scan | Builds bitmap from index, then scans table |
cost=X..Y | Startup cost..total cost (arbitrary units) |
rows=N | Estimated rows returned |
actual time=X..Y | Real milliseconds (only with ANALYZE) |
Buffers: shared hit=N | Pages read from cache (good) vs read=N from disk (slow) |
loops=N | How many times this node executed |
Workflow
Step 1: Identify Slow Queries
SELECT
substring(query, 1, 100) AS short_query,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS avg_ms,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct,
rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
SELECT
schemaname,
relname AS table_name,
seq_scan,
seq_tup_read,
idx_scan,
CASE WHEN seq_scan > 0
THEN round(seq_tup_read::numeric / seq_scan, 0)
ELSE 0
END AS avg_rows_per_seq_scan
FROM pg_stat_user_tables
WHERE seq_scan > 100
ORDER BY seq_tup_read DESC
LIMIT 20;
Step 2: Analyze the Query Plan
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending'
AND o.created_at > now() - interval '7 days'
ORDER BY o.created_at DESC
LIMIT 20;
Red flags in query plans:
| Red Flag | Meaning | Action |
|---|
Seq Scan on large table | No usable index | Add an index on the filtered/joined column |
rows=1 but actual rows=50000 | Bad row estimate | Run ANALYZE tablename to update statistics |
Sort with Sort Method: external merge | Sort spills to disk | Add index matching ORDER BY, or increase work_mem |
Nested Loop with high loops= | N+1 join pattern | Ensure inner table has index on join column |
Bitmap Heap Scan with Recheck Cond | Lossy bitmap | Acceptable, but check if a direct Index Scan is possible |
Filter: (rows removed=N) | Index fetches too many rows, then filters | Make the index more selective (composite/partial) |
Step 3: Design the Right Index
Single-Column Index
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_orders_created_at ON orders (created_at DESC);
Composite Index (Multi-Column)
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
The Equality-Sort-Range (ESR) rule:
Composite index column order:
1. Equality columns first (WHERE x = ?)
2. Sort columns next (ORDER BY y)
3. Range columns last (WHERE z > ?)
Example query:
WHERE user_id = 42 AND created_at > '2024-01-01' ORDER BY priority DESC
Optimal index:
CREATE INDEX ON orders (user_id, priority DESC, created_at);
-- user_id (equality) -> priority (sort) -> created_at (range)
Partial Index
CREATE INDEX idx_orders_pending
ON orders (created_at DESC)
WHERE status = 'pending';
Covering Index (Index-Only Scans)
CREATE INDEX idx_orders_user_covering
ON orders (user_id)
INCLUDE (status, total, created_at);
GIN Index for JSONB
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
CREATE INDEX idx_products_metadata_color
ON products USING BTREE ((metadata->>'color'));
GIN Index for Full-Text Search
CREATE INDEX idx_articles_search
ON articles USING GIN (to_tsvector('english', title || ' ' || body));
SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'postgres & indexing');
GIN Trigram Index for LIKE/ILIKE
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING GIN (name gin_trgm_ops);
SELECT * FROM users WHERE name ILIKE '%garcia%';
SELECT * FROM users WHERE name % 'Garcia' ORDER BY similarity(name, 'Garcia') DESC;
BRIN Index for Time-Series Data
CREATE INDEX idx_events_created_brin
ON events USING BRIN (created_at)
WITH (pages_per_range = 32);
Step 4: Validate the Improvement
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
CREATE INDEX CONCURRENTLY idx_orders_user_status
ON orders (user_id, status);
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
Step 5: Ongoing Index Maintenance
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
idx_scan AS times_used,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE '%_pkey'
AND indexrelname NOT LIKE '%_unique'
ORDER BY pg_relation_size(indexrelid) DESC;
SELECT
a.indexrelid::regclass AS index_a,
b.indexrelid::regclass AS index_b,
pg_size_pretty(pg_relation_size(a.indexrelid)) AS size_a,
pg_size_pretty(pg_relation_size(b.indexrelid)) AS size_b
FROM pg_index a
JOIN pg_index b ON a.indrelid = b.indrelid
AND a.indexrelid < b.indexrelid
AND a.indkey::text = left(b.indkey::text, length(a.indkey::text))
WHERE a.indrelid::regclass::text NOT LIKE 'pg_%';
SELECT
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size,
idx_scan,
idx_tup_read
FROM pg_stat_user_indexes
JOIN pg_indexes USING (indexname)
ORDER BY pg_relation_size(indexname::regclass) DESC
LIMIT 20;
REINDEX INDEX CONCURRENTLY idx_orders_user_status;
Best Practices
- Always use
CREATE INDEX CONCURRENTLY in production to avoid table locks
- Follow the ESR rule for composite indexes: Equality, Sort, Range
- Use
INCLUDE columns to enable Index Only Scans for frequent queries
- Use partial indexes when queries filter on a low-cardinality condition (e.g.,
WHERE status = 'active')
- Run
ANALYZE after bulk inserts so the planner has accurate statistics
- Audit unused indexes quarterly — each unused index slows every INSERT/UPDATE/DELETE
- Use
EXPLAIN (ANALYZE, BUFFERS) not just EXPLAIN — estimated vs actual can differ wildly
- For JSONB queries, use expression indexes on specific paths over full GIN when possible
- Set
random_page_cost = 1.1 on SSDs (default 4.0 assumes spinning disk)
Common Pitfalls
| Pitfall | Fix |
|---|
Creating index without CONCURRENTLY in production | Always use CONCURRENTLY — standard CREATE INDEX locks the table for writes |
| Wrong column order in composite index | Put equality columns first, then sort, then range (ESR rule) |
| Indexing low-cardinality columns alone (e.g., boolean) | Use a partial index or composite index instead — B-tree on booleans is nearly useless |
| Planner ignores the index | Run ANALYZE; check enable_seqscan; verify query matches index columns; check for type mismatches or function calls on indexed columns |
| Too many indexes on write-heavy tables | Each index adds overhead to INSERT/UPDATE/DELETE — audit and remove unused ones |
Using != or NOT IN expecting index use | B-tree indexes do not accelerate negative conditions — restructure the query |
| Indexing expression but querying raw column | CREATE INDEX ON t (lower(name)) only works for WHERE lower(name) = ..., not WHERE name = ... |
| BRIN on randomly ordered data | BRIN needs physical correlation — use on append-only or time-ordered tables only |
Examples
Real-World Index Design Session
SELECT id, title, status, created_at
FROM orders
WHERE user_id = 1234
AND status IN ('pending', 'processing')
AND created_at > now() - interval '30 days'
ORDER BY created_at DESC
LIMIT 10;
CREATE INDEX CONCURRENTLY idx_orders_user_status_created
ON orders (user_id, status, created_at DESC)
INCLUDE (id, title);
Monitoring Index Health with pg_stat_statements
SELECT
substring(query, 1, 80) AS query,
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric / 1000, 2) AS total_seconds
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat%'
AND mean_exec_time > 100
ORDER BY total_exec_time DESC
LIMIT 10;
Drizzle ORM Index Definitions
import { index, pgTable, text, timestamp, uuid, integer } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
status: text('status').notNull().default('pending'),
total: integer('total').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('idx_orders_user_status_created')
.on(table.userId, table.status, table.createdAt.desc()),
index('idx_orders_status_partial')
.on(table.createdAt)
.where(sql`status = 'pending'`),
]);
From database-seeding
Database seeding — seed scripts, faker data generation, deterministic seeds, test data patterns, and environment-specific seeding strategies
Database Seeding Skill
Purpose
Seed scripts populate databases with realistic data for development, testing, and staging environments. Good seeds are deterministic (reproducible), fast, respect foreign key constraints, and generate data that looks real. This skill covers seed architecture, faker patterns, and environment-specific strategies.
Key Concepts
Seeding Environments
| Environment | Data Volume | Realism | Determinism | Speed Priority |
|---|
| Development | Small (100s) | High (realistic) | Optional | Fast |
| Testing | Minimal | Relevant to tests | Required | Fastest |
| Staging | Production-like (1000s+) | Very high | Optional | Less important |
| Demo | Curated | Perfect | Required | N/A |
Seed Architecture
seeds/
index.ts # Main entry point
config.ts # Seed counts, deterministic seed value
factories/ # Data generators per entity
user.factory.ts
post.factory.ts
order.factory.ts
scenarios/ # Pre-built data scenarios
demo.ts # Demo account with curated data
load-test.ts # Large volume for perf testing
empty.ts # Schema only, no data
utils/
reset.ts # Truncate/clean database
helpers.ts # Shared utilities
Workflow
Step 1: Install Dependencies
npm install --save-dev @faker-js/faker
Step 2: Factory Pattern with Faker
import { faker } from '@faker-js/faker';
export interface UserSeed {
email: string;
name: string;
avatarUrl: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: Date;
}
export function createUser(overrides: Partial<UserSeed> = {}): UserSeed {
return {
email: faker.internet.email().toLowerCase(),
name: faker.person.fullName(),
avatarUrl: faker.image.avatar(),
role: faker.helpers.weightedArrayElement([
{ value: 'viewer', weight: 7 },
{ value: 'editor', weight: 2 },
{ value: 'admin', weight: 1 },
]),
createdAt: faker.date.between({
from: '2023-01-01',
to: new Date(),
}),
...overrides,
};
}
export function createUsers(count: number, overrides: Partial<UserSeed> = {}): UserSeed[] {
return Array.from({ length: count }, () => createUser(overrides));
}
import { faker } from '@faker-js/faker';
export interface PostSeed {
title: string;
slug: string;
body: string;
status: 'draft' | 'published' | 'archived';
publishedAt: Date | null;
authorId: string;
}
export function createPost(overrides: Partial<PostSeed> = {}): Omit<PostSeed, 'authorId'> & { authorId?: string } {
const title = faker.lorem.sentence({ min: 4, max: 10 });
const status = faker.helpers.arrayElement(['draft', 'published', 'published', 'published', 'archived']);
return {
title,
slug: faker.helpers.slugify(title).toLowerCase(),
body: faker.lorem.paragraphs({ min: 3, max: 8 }, '\n\n'),
status,
publishedAt: status === 'published'
? faker.date.between({ from: '2023-06-01', to: new Date() })
: null,
...overrides,
};
}
Step 3: Deterministic Seeds (Reproducible Data)
import { faker } from '@faker-js/faker';
export function initSeed(seed: number = 42) {
faker.seed(seed);
}
initSeed(42);
const user1 = createUser();
const user2 = createUser();
export function withSeed<T>(seed: number, fn: () => T): T {
faker.seed(seed);
const result = fn();
faker.seed();
return result;
}
Step 4: Main Seed Script (Prisma)
import { PrismaClient } from '@prisma/client';
import { faker } from '@faker-js/faker';
import { createUser, createUsers } from '../seeds/factories/user.factory';
import { createPost } from '../seeds/factories/post.factory';
const prisma = new PrismaClient();
async function main() {
const startTime = Date.now();
faker.seed(42);
console.log('Cleaning database...');
await cleanDatabase();
console.log('Seeding users...');
const users = await seedUsers();
console.log('Seeding posts...');
await seedPosts(users.map(u => u.id));
console.log('Seeding demo account...');
await seedDemoAccount();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`Seeding complete in ${elapsed}s`);
}
async function cleanDatabase() {
await prisma.$executeRaw`TRUNCATE TABLE comments, posts, user_roles, users CASCADE`;
}
async function seedUsers() {
const admin = await prisma.user.create({
data: {
email: 'admin@localhost.dev',
name: 'Admin User',
avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
passwordHash: await hashPassword('admin123'),
emailVerifiedAt: new Date(),
},
});
await prisma.userRole.create({
data: { userId: admin.id, roleId: await getRoleId('admin') },
});
const userData = createUsers(50);
const users = await prisma.user.createManyAndReturn({
data: userData.map(u => ({
...u,
passwordHash: '$2b$10$fixedhashfordevseeding',
emailVerifiedAt: faker.datatype.boolean(0.8) ? faker.date.past() : null,
})),
});
for (const user of users) {
const role = faker.helpers.weightedArrayElement([
{ value: 'viewer', weight: 6 },
{ value: 'editor', weight: 3 },
{ value: 'admin', weight: 1 },
]);
await prisma.userRole.create({
data: { userId: user.id, roleId: await getRoleId(role) },
});
}
return [admin, ...users];
}
async function seedPosts(userIds: string[]) {
const posts: any[] = [];
for (let i = 0; i < 200; i++) {
const post = createPost();
posts.push({
...post,
authorId: faker.helpers.arrayElement(userIds),
});
}
await prisma.post.createMany({ data: posts });
const publishedPosts = await prisma.post.findMany({
where: { status: 'published' },
select: { id: true },
});
const comments: any[] = [];
for (const post of publishedPosts) {
const commentCount = faker.number.int({ min: 0, max: 10 });
for (let i = 0; i < commentCount; i++) {
comments.push({
postId: post.id,
authorId: faker.helpers.arrayElement(userIds),
body: faker.lorem.paragraph(),
createdAt: faker.date.recent({ days: 30 }),
});
}
}
await prisma.comment.createMany({ data: comments });
}
async function seedDemoAccount() {
const demo = await prisma.user.create({
data: {
email: 'demo@localhost.dev',
name: 'Jane Cooper',
avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=demo',
passwordHash: await hashPassword('demo123'),
emailVerifiedAt: new Date(),
},
});
await prisma.post.createMany({
data: [
{
title: 'Getting Started with Our Platform',
slug: 'getting-started',
body: 'Welcome to the platform! This guide walks you through...',
status: 'published',
publishedAt: new Date('2024-01-15'),
authorId: demo.id,
},
{
title: 'Advanced Tips and Tricks',
slug: 'advanced-tips',
body: 'Once you have mastered the basics, try these advanced features...',
status: 'published',
publishedAt: new Date('2024-02-20'),
authorId: demo.id,
},
{
title: 'Draft: Upcoming Feature Preview',
slug: 'upcoming-features-preview',
body: 'We are working on exciting new features...',
status: 'draft',
publishedAt: null,
authorId: demo.id,
},
],
});
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
Step 5: Configure Prisma to Run Seeds
{
"prisma": {
"seed": "npx tsx prisma/seed.ts"
},
"scripts": {
"db:seed": "npx prisma db seed",
"db:reset": "npx prisma migrate reset",
"db:fresh": "npx prisma migrate reset --force && npx prisma db seed"
}
}
Step 6: Drizzle Seed Script
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import { faker } from '@faker-js/faker';
import { users, posts, comments } from '../src/db/schema';
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);
async function seed() {
faker.seed(42);
await db.delete(comments);
await db.delete(posts);
await db.delete(users);
const insertedUsers = await db.insert(users).values(
createUsers(50).map(u => ({
email: u.email,
name: u.name,
avatarUrl: u.avatarUrl,
createdAt: u.createdAt,
}))
).returning({ id: users.id });
const userIds = insertedUsers.map(u => u.id);
await db.insert(posts).values(
Array.from({ length: 200 }, () => ({
...createPost(),
authorId: faker.helpers.arrayElement(userIds),
}))
);
console.log('Seeded successfully');
}
seed().catch(console.error);
Step 7: Test Fixtures (Lightweight Seeds for Tests)
import { faker } from '@faker-js/faker';
import { createUser } from '../seeds/factories/user.factory';
export async function createTestUser(db: PrismaClient, overrides: Partial<UserSeed> = {}) {
const data = createUser(overrides);
return db.user.create({ data });
}
export async function createTestPostWithAuthor(db: PrismaClient) {
const author = await createTestUser(db);
const post = await db.post.create({
data: {
...createPost(),
authorId: author.id,
},
});
return { author, post };
}
describe('Post API', () => {
beforeEach(async () => {
faker.seed(123);
await cleanDatabase();
});
it('returns published posts', async () => {
const { post } = await createTestPostWithAuthor(prisma);
const response = await app.get('/api/posts');
expect(response.body.data).toHaveLength(1);
});
});
Common Pitfalls
- Non-deterministic seeds breaking tests — If tests depend on seeded data, they must be deterministic. Always call
faker.seed(N) before generating data.
- Foreign key order — Seed parent tables before children. Users before posts, posts before comments. Truncate in reverse order.
- Using production seed scripts in tests — Full seeds are slow. Tests should use minimal fixtures scoped to what they need.
- Hardcoded IDs — Never hardcode UUIDs unless necessary for demo accounts. Let the database generate IDs and reference them via variables.
- Seeding passwords in plain text — Even in dev, use hashed passwords. Store a known dev password hash as a constant to avoid hashing per-user (which is slow).
- Not cleaning before seeding — Always truncate before seeding. Running seed twice should produce the same result (idempotent).
- Forgetting
CASCADE on truncate — TRUNCATE users fails if posts reference users. Use TRUNCATE users CASCADE or truncate in correct order.
Best Practices
- Factory + Scenario pattern: Factories generate individual entities, scenarios compose them into meaningful datasets
- Deterministic for tests: Always
faker.seed(N) in test fixtures
- Dev login credentials: Always create a known admin account (
admin@localhost.dev / admin123) for easy dev login
- Batch inserts: Use
createMany or raw INSERT for performance, not individual create calls
- Realistic volumes: Dev seeds should be small (fast), staging seeds should match production scale
- Weighted distributions: Use
faker.helpers.weightedArrayElement for realistic role/status distributions
- Separate demo data: Curated, handcrafted data for demos — not random faker output
From database-transactions
Transaction isolation levels, distributed transactions, saga pattern, optimistic/pessimistic locking, and ACID guarantees across SQL and NoSQL databases
Database Transactions Skill
Purpose
Correct transaction handling is the difference between a system that works and one that silently corrupts data under load. This skill covers isolation levels, distributed transaction patterns (2PC, sagas), optimistic and pessimistic locking, and deadlock prevention. It prioritizes correctness first, then performance.
Key Concepts
ACID Properties
| Property | Meaning | Failure Mode Without It |
|---|
| Atomicity | All or nothing | Partial writes leave inconsistent state |
| Consistency | DB moves from one valid state to another | Constraint violations, orphaned records |
| Isolation | Concurrent transactions don't interfere | Dirty reads, phantom reads, lost updates |
| Durability | Committed data survives crashes | Data loss on restart |
Isolation Levels (Weakest to Strongest)
READ UNCOMMITTED ── Dirty reads possible (almost never use this)
READ COMMITTED ── Default in Postgres; no dirty reads
REPEATABLE READ ── Default in MySQL InnoDB; no non-repeatable reads
SERIALIZABLE ── Full isolation; transactions behave as if sequential
Postgres Isolation Behavior:
| Level | Dirty Read | Non-Repeatable Read | Phantom Read | Serialization Anomaly |
|---|
| Read Committed | No | Possible | Possible | Possible |
| Repeatable Read | No | No | No (in PG) | Possible |
| Serializable | No | No | No | No |
Workflow
Step 1: Determine Consistency Requirements
Is this a financial transaction or inventory decrement?
-> Serializable or explicit locking
Is this a read-heavy dashboard query?
-> Read Committed is fine
Are multiple services writing to the same resource?
-> Distributed transaction pattern (saga)
Is this a user-facing update with low contention?
-> Optimistic locking with version column
Step 2: Implement the Appropriate Pattern
Basic Transaction with Proper Error Handling (Postgres + Drizzle)
import { db } from '@/db';
import { accounts } from '@/db/schema';
import { eq, sql } from 'drizzle-orm';
async function transferFunds(
fromId: string,
toId: string,
amount: number
): Promise<void> {
await db.transaction(async (tx) => {
const [from, to] = fromId < toId
? [
await tx.select().from(accounts).where(eq(accounts.id, fromId)).for('update'),
await tx.select().from(accounts).where(eq(accounts.id, toId)).for('update'),
]
: [
...(await (async () => {
const t = await tx.select().from(accounts).where(eq(accounts.id, toId)).for('update');
const f = await tx.select().from(accounts).where(eq(accounts.id, fromId)).for('update');
return [f, t] as const;
})()),
];
if (!from[0] || !to[0]) {
throw new Error('Account not found');
}
if (from[0].balance < amount) {
throw new Error('Insufficient funds');
}
await tx.update(accounts)
.set({ balance: sql`${accounts.balance} - ${amount}` })
.where(eq(accounts.id, fromId));
await tx.update(accounts)
.set({ balance: sql`${accounts.balance} + ${amount}` })
.where(eq(accounts.id, toId));
});
}
Optimistic Locking with Version Column
import { eq, and } from 'drizzle-orm';
async function updateProductPrice(
productId: string,
newPrice: number,
expectedVersion: number
): Promise<boolean> {
const result = await db.update(products)
.set({
price: newPrice,
version: expectedVersion + 1,
updatedAt: new Date(),
})
.where(
and(
eq(products.id, productId),
eq(products.version, expectedVersion)
)
)
.returning();
if (result.length === 0) {
return false;
}
return true;
}
async function withOptimisticRetry<T>(
fn: () => Promise<T | null>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const result = await fn();
if (result !== null) return result;
await new Promise((r) =>
setTimeout(r, Math.random() * 50 * Math.pow(2, attempt))
);
}
throw new Error('Optimistic locking failed after max retries');
}
Pessimistic Locking (SELECT FOR UPDATE)
async function decrementInventory(
productId: string,
quantity: number
): Promise<void> {
await db.transaction(async (tx) => {
const [product] = await tx
.select()
.from(products)
.where(eq(products.id, productId))
.for('update');
if (!product) throw new Error('Product not found');
if (product.stock < quantity) throw new Error('Insufficient stock');
await tx.update(products)
.set({ stock: product.stock - quantity })
.where(eq(products.id, productId));
});
}
async function claimNextJob(): Promise<Job | null> {
return db.transaction(async (tx) => {
const [job] = await tx
.select()
.from(jobs)
.where(eq(jobs.status, 'pending'))
.orderBy(jobs.createdAt)
.limit(1)
.for('update', { skipLocked: true });
if (!job) return null;
await tx.update(jobs)
.set({ status: 'processing', claimedAt: new Date() })
.where(eq(jobs.id, job.id));
return job;
});
}
Raw SQL Transaction with Serializable Isolation (Postgres)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
INSERT INTO account_summaries (account_id, total_transactions, total_amount)
SELECT
account_id,
COUNT(*),
SUM(amount)
FROM transactions
WHERE account_id = $1
AND processed = false
GROUP BY account_id
ON CONFLICT (account_id) DO UPDATE SET
total_transactions = account_summaries.total_transactions + EXCLUDED.total_transactions,
total_amount = account_summaries.total_amount + EXCLUDED.total_amount;
UPDATE transactions SET processed = true WHERE account_id = $1 AND processed = false;
COMMIT;
Step 3: Distributed Transactions — The Saga Pattern
When transactions span multiple services, traditional ACID is not possible. Use sagas instead.
Choreography-Based Saga (Event-Driven)
async function createOrder(orderData: OrderInput) {
const order = await db.insert(orders).values({
...orderData,
status: 'pending',
}).returning();
await messageQueue.publish('order.created', {
orderId: order[0].id,
amount: orderData.total,
customerId: orderData.customerId,
});
return order[0];
}
async function handleOrderCreated(event: OrderCreatedEvent) {
try {
const payment = await processPayment(event.customerId, event.amount);
await messageQueue.publish('payment.completed', {
orderId: event.orderId,
paymentId: payment.id,
});
} catch (error) {
await messageQueue.publish('payment.failed', {
orderId: event.orderId,
reason: error.message,
});
}
}
Orchestration-Based Saga (Central Coordinator)
interface SagaStep<T> {
name: string;
execute: (context: T) => Promise<T>;
compensate: (context: T) => Promise<void>;
}
class SagaOrchestrator<T> {
private steps: SagaStep<T>[] = [];
private completedSteps: SagaStep<T>[] = [];
addStep(step: SagaStep<T>): this {
this.steps.push(step);
return this;
}
async execute(initialContext: T): Promise<T> {
let context = initialContext;
for (const step of this.steps) {
try {
context = await step.execute(context);
this.completedSteps.push(step);
} catch (error) {
console.error(`Saga failed at step "${step.name}":`, error);
await this.compensate(context);
throw new Error(`Saga failed at "${step.name}": ${error.message}`);
}
}
return context;
}
private async compensate(context: T): Promise<void> {
for (const step of [...this.completedSteps].reverse()) {
try {
await step.compensate(context);
} catch (compError) {
console.error(`Compensation failed for "${step.name}":`, compError);
}
}
}
}
const orderSaga = new SagaOrchestrator<OrderContext>()
.addStep({
name: 'reserve-inventory',
execute: async (ctx) => {
ctx.reservationId = await inventoryService.reserve(ctx.items);
return ctx;
},
compensate: async (ctx) => {
await inventoryService.release(ctx.reservationId);
},
})
.addStep({
name: 'charge-payment',
execute: async (ctx) => {
ctx.paymentId = await paymentService.charge(ctx.customerId, ctx.total);
return ctx;
},
compensate: async (ctx) => {
await paymentService.refund(ctx.paymentId);
},
})
.addStep({
name: 'create-shipment',
execute: async (ctx) => {
ctx.shipmentId = await shippingService.create(ctx.address, ctx.items);
return ctx;
},
compensate: async (ctx) => {
await shippingService.cancel(ctx.shipmentId);
},
});
await orderSaga.execute({ customerId, items, total, address });
Best Practices
- Always order lock acquisition — Acquire locks on rows/tables in a consistent order (e.g., by ID ascending) to prevent deadlocks.
- Keep transactions short — Long transactions hold locks and block other queries. Do all non-DB work (API calls, file I/O) outside the transaction.
- Use the weakest isolation level that is correct — Serializable is safest but slowest. Read Committed is sufficient for most CRUD operations.
- Retry serialization failures — Postgres SERIALIZABLE isolation will throw error code
40001 on conflicts. Your application must catch and retry.
- Prefer optimistic locking for low-contention writes — A version column is cheaper than holding row locks when conflicts are rare.
- Design sagas with idempotent steps — Compensations and retries may fire multiple times. Every step must be safe to re-run.
- Set statement_timeout in Postgres — Prevent runaway transactions:
SET statement_timeout = '5s';
- Monitor lock waits — Query
pg_stat_activity for wait_event_type = 'Lock' to detect contention.
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|
| Deadlock from inconsistent lock order | ERROR: deadlock detected | Always lock rows in a deterministic order (e.g., sort by PK) |
| Long-running transaction holding locks | Other queries timeout or queue up | Move non-DB work outside the transaction; set idle_in_transaction_session_timeout |
| Lost update (no locking) | Two users edit the same row, last write wins silently | Add a version column and use optimistic locking |
| Saga without idempotency | Double-charge on retry, duplicate inventory deduction | Use idempotency keys; make every saga step re-runnable |
| Catching errors inside transaction without re-throwing | Transaction commits despite a failed step | Always re-throw or explicitly call tx.rollback() |
| N+1 lock acquisition | Locking rows one at a time in a loop causes serialization failures | Batch lock acquisition: SELECT ... WHERE id IN (...) FOR UPDATE |
| Using SERIALIZABLE everywhere | Massive retry overhead, throughput collapse | Reserve SERIALIZABLE for financial/inventory operations; use Read Committed elsewhere |
| Forgetting to handle serialization retries | App crashes on 40001 errors under load | Wrap serializable transactions in a retry loop with exponential backoff |
From database-backup
PostgreSQL backup strategies including pg_dump, WAL archiving, point-in-time recovery (PITR), and automated backup pipelines
Database Backup & Recovery
Purpose
Provide comprehensive guidance on PostgreSQL backup strategies from logical dumps to continuous archiving with point-in-time recovery. Covers self-hosted, managed (RDS, Cloud SQL), and serverless (Neon) environments with automated pipeline patterns.
Key Patterns
pg_dump -- Logical Backups
Basic full backup:
pg_dump \
--format=custom \
--compress=zstd:6 \
--jobs=4 \
--file="/backups/mydb_$(date +%Y%m%d_%H%M%S).dump" \
--verbose \
"postgresql://user:pass@host:5432/mydb"
pg_restore \
--jobs=4 \
--clean \
--if-exists \
--no-owner \
--dbname="postgresql://user:pass@host:5432/mydb_restored" \
/backups/mydb_20250310_120000.dump
Schema-only and data-only backups:
pg_dump --schema-only --format=plain \
--file="/backups/schema_$(date +%Y%m%d).sql" \
"$DATABASE_URL"
pg_dump --data-only --format=custom \
--exclude-table='audit_logs' \
--exclude-table='sessions' \
--file="/backups/data_$(date +%Y%m%d).dump" \
"$DATABASE_URL"
Selective table backup:
pg_dump --format=custom \
--table='public.users' \
--table='public.orders' \
--table='public.order_items' \
--file="/backups/orders_$(date +%Y%m%d).dump" \
"$DATABASE_URL"
WAL Archiving -- Continuous Archiving
postgresql.conf setup:
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /archive/%f && cp %p /archive/%f'
archive_timeout = 300
Using wal-g for cloud archiving:
export WALG_S3_PREFIX=s3://my-backups/wal-archive
export AWS_REGION=us-east-1
export PGHOST=/var/run/postgresql
wal-g backup-push $PGDATA
wal-g backup-list
wal-g backup-fetch $PGDATA LATEST
Point-in-Time Recovery (PITR)
Recovery to a specific timestamp:
sudo systemctl stop postgresql
mv $PGDATA ${PGDATA}.old
pg_basebackup -D $PGDATA -h backup-host -U replicator
wal-g backup-fetch $PGDATA LATEST
cat > $PGDATA/postgresql.auto.conf << 'EOF'
restore_command = 'cp /archive/%f %p'
recovery_target_time = '2025-03-10 14:30:00 UTC'
recovery_target_action = 'promote'
EOF
touch $PGDATA/recovery.signal
sudo systemctl start postgresql
Automated Backup Pipeline
Shell script with rotation:
#!/usr/bin/env bash
set -euo pipefail
DB_URL="${DATABASE_URL:?DATABASE_URL not set}"
BACKUP_DIR="/backups/postgres"
RETENTION_DAYS=30
S3_BUCKET="s3://myapp-backups/postgres"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/backup_${TIMESTAMP}.dump"
mkdir -p "$BACKUP_DIR"
echo "[$(date)] Starting backup..."
pg_dump \
--format=custom \
--compress=zstd:6 \
--jobs=4 \
--file="$BACKUP_FILE" \
"$DB_URL"
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo "[$(date)] Backup complete: $BACKUP_FILE ($BACKUP_SIZE)"
aws s3 cp "$BACKUP_FILE" "$S3_BUCKET/$(basename $BACKUP_FILE)" \
--storage-class STANDARD_IA
pg_restore --list "$BACKUP_FILE" > /dev/null 2>&1
echo "[$(date)] Backup verified"
find "$BACKUP_DIR" -name "backup_*.dump" -mtime +$RETENTION_DAYS -delete
echo "[$(date)] Rotated backups older than $RETENTION_DAYS days"
aws s3 ls "$S3_BUCKET/" | \
awk '{print $4}' | \
head -n -$RETENTION_DAYS | \
xargs -I{} aws s3 rm "$S3_BUCKET/{}"
Cron schedule:
# Daily full backup at 2 AM UTC
0 2 * * * /opt/scripts/backup-postgres.sh >> /var/log/pg-backup.log 2>&1
# Hourly WAL push (if not using continuous archiving)
0 * * * * wal-g wal-push /var/lib/postgresql/data/pg_wal/ >> /var/log/wal-push.log 2>&1
Docker-Based Backup
services:
pg-backup:
image: postgres:16-alpine
environment:
PGHOST: db
PGUSER: postgres
PGPASSWORD_FILE: /run/secrets/db_password
volumes:
- backup-data:/backups
- ./scripts/backup.sh:/backup.sh:ro
entrypoint: ["crond", "-f", "-d", "8"]
configs:
- source: backup-cron
target: /var/spool/cron/crontabs/root
configs:
backup-cron:
content: |
0 2 * * * /backup.sh
volumes:
backup-data:
Managed Service Backups
Neon (serverless Postgres):
import { neonClient } from './neon';
async function createRecoveryBranch(timestamp: string) {
const branch = await neonClient.createBranch({
projectId: process.env.NEON_PROJECT_ID!,
parentId: 'main',
parentTimestamp: timestamp,
name: `recovery-${Date.now()}`,
});
return branch.connectionUri;
}
AWS RDS:
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier mydb-prod \
--target-db-instance-identifier mydb-recovery \
--restore-time "2025-03-10T14:30:00Z" \
--db-instance-class db.t3.medium
Best Practices
- Define RPO and RTO first -- Recovery Point Objective (max data loss) and Recovery Time Objective (max downtime) drive your backup strategy.
- Test restores regularly -- An untested backup is not a backup. Schedule monthly restore drills.
- Use custom format for pg_dump -- It compresses well, supports parallel restore, and allows selective table restore.
- Combine logical + physical backups -- pg_dump for portability, WAL archiving for minimal data loss.
- Encrypt backups at rest and in transit -- Use
--compress=zstd with GPG encryption or S3 server-side encryption.
- Store backups in a different region -- Cross-region replication protects against regional outages.
- Monitor backup jobs -- Alert on failures. A silent backup failure is worse than no backup at all.
- Version your backup scripts -- Keep backup and restore procedures in version control alongside your application.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|
| Never testing restores | Discover corrupt backups during an actual emergency | Schedule automated monthly restore verification |
| Plain SQL format for large DBs | Cannot restore in parallel, extremely slow | Use --format=custom with --jobs=N for parallel restore |
| No WAL archiving | RPO limited to backup frequency (hours of data loss) | Enable WAL archiving for near-zero RPO |
| Backups on the same disk | Disk failure loses both data and backups | Store backups on separate storage, ideally different region |
Missing --no-owner on restore | Restore fails due to missing roles | Use --no-owner --no-privileges when restoring to a different environment |
| Unmonitored backup cron | Backup silently fails for weeks | Send alerts on failure; check backup age in monitoring |
| No retention policy | Disk fills up with old backups | Implement rotation: 7 daily, 4 weekly, 12 monthly |
| Backing up during peak load | Locks and performance degradation | Schedule backups during low-traffic windows; use --no-synchronized-snapshots for standbys |
From database-optimization
Database query optimization — EXPLAIN ANALYZE, query plans, index hints, materialized views, partitioning
Database Query Optimization
Purpose
Provide expert guidance on diagnosing and resolving database performance issues through query plan analysis, strategic indexing, materialized views, and table partitioning. Focused on PostgreSQL but principles apply broadly to relational databases.
Key Patterns
Reading EXPLAIN ANALYZE Output
Always use EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) for real execution statistics, not just estimates.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2025-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 20;
What to look for in the output:
| Signal | Meaning | Action |
|---|
Seq Scan on large table | Missing index | Add index on filter/join columns |
actual rows >> rows (estimated) | Stale statistics | Run ANALYZE tablename |
Buffers: shared read high | Cold cache / large scan | Add index or materialize |
Sort Method: external merge | Sort spilling to disk | Increase work_mem or add index for ordering |
Hash Join with high Batches | Hash spilling to disk | Increase work_mem |
Nested Loop on large sets | O(n*m) explosion | Rewrite to use hash/merge join or add indexes |
Query Optimization Techniques
Avoid SELECT * -- project only needed columns:
SELECT * FROM orders WHERE status = 'pending';
SELECT id, created_at, total FROM orders WHERE status = 'pending';
Push filters early with CTEs vs subqueries:
WITH all_orders AS (
SELECT * FROM orders
)
SELECT * FROM all_orders WHERE status = 'pending' AND created_at > NOW() - INTERVAL '7 days';
SELECT id, created_at, total
FROM orders
WHERE status = 'pending'
AND created_at > NOW() - INTERVAL '7 days';
Use EXISTS instead of IN for correlated checks:
SELECT * FROM users u
WHERE u.id IN (SELECT user_id FROM orders WHERE total > 1000);
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.total > 1000
);
Materialized Views
Use materialized views for expensive aggregations that tolerate staleness.
CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT
date_trunc('day', created_at) AS day,
COUNT(*) AS order_count,
SUM(total) AS revenue,
AVG(total) AS avg_order_value
FROM orders
WHERE status = 'completed'
GROUP BY date_trunc('day', created_at);
CREATE UNIQUE INDEX idx_mv_daily_revenue_day ON mv_daily_revenue (day);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue;
Refresh strategies:
| Strategy | Use When |
|---|
REFRESH MATERIALIZED VIEW CONCURRENTLY | Read-heavy, can tolerate minutes of staleness |
| Trigger-based refresh | Near real-time needed, low write volume |
| pg_cron scheduled refresh | Regular intervals (hourly, daily) |
| Application-level cache | Sub-second freshness needed |
Table Partitioning
Use partitioning when tables exceed tens of millions of rows and queries filter on the partition key.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
event_type text NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2025_01 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE logs (
id bigint GENERATED ALWAYS AS IDENTITY,
level text NOT NULL,
message text,
ts timestamptz DEFAULT NOW()
) PARTITION BY LIST (level);
CREATE TABLE logs_error PARTITION OF logs FOR VALUES IN ('error', 'fatal');
CREATE TABLE logs_info PARTITION OF logs FOR VALUES IN ('info', 'debug');
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id bigint NOT NULL,
data jsonb
) PARTITION BY HASH (user_id);
CREATE TABLE sessions_0 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_1 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_2 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_3 PARTITION OF sessions FOR VALUES WITH (MODULUS 4, REMAINDER 3);
Partition maintenance with pg_partman:
CREATE EXTENSION IF NOT EXISTS pg_partman;
SELECT partman.create_parent(
p_parent_table := 'public.events',
p_control := 'created_at',
p_type := 'range',
p_interval := '1 month',
p_premake := 3
);
Query Plan Anti-Patterns
N+1 at the database level:
SELECT * FROM users WHERE id = ANY($1::int[]);
Over-indexing:
CREATE INDEX idx_orders_user ON orders (user_id);
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
Best Practices
- Always measure before optimizing -- use
EXPLAIN (ANALYZE, BUFFERS) with realistic data volumes, not empty dev databases.
- Run
ANALYZE after bulk data changes to keep planner statistics accurate.
- Prefer partial indexes for queries that filter to a subset:
CREATE INDEX idx_pending ON orders (created_at) WHERE status = 'pending'.
- Use
pg_stat_statements to find the most time-consuming queries in production.
- Set appropriate
work_mem per query for sorts and hash joins: SET LOCAL work_mem = '256MB' for analytics queries.
- Partition early if you know data will grow -- retrofitting partitioning is painful.
- Monitor index usage with
pg_stat_user_indexes -- drop indexes with zero scans.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|
| Optimizing on dev data | Plans differ with 100 rows vs 10M rows | Test with production-like data volumes |
Missing ANALYZE after migrations | Planner uses default statistics, picks bad plans | Run ANALYZE on affected tables after bulk inserts/schema changes |
| Index on low-cardinality column | Full table scan is faster than index scan on boolean/enum | Use partial indexes or composite indexes instead |
| Materialized view without unique index | REFRESH CONCURRENTLY fails | Always create a unique index on materialized views |
| Partitioning without partition key in queries | Planner scans all partitions | Ensure WHERE clause always includes partition key |
| Too many partitions | Planning overhead increases per partition | Keep under ~1000 partitions; use larger intervals |
From database-replication
Database replication patterns — read replicas, streaming replication, logical replication, and failover.
Database Replication Patterns
Purpose
Provide expert guidance on database replication architectures including streaming replication, logical replication, read replicas, failover strategies, and application-level routing. Primarily focused on PostgreSQL but patterns apply broadly. This is a high-risk skill — replication misconfiguration can cause data loss.
Key Patterns
Replication Types
| Type | Mechanism | Use Case | Lag |
|---|
| Streaming (Physical) | WAL shipping byte-for-byte | Read replicas, HA failover | Sub-second |
| Logical | Row-level changes decoded from WAL | Selective replication, version upgrades | Seconds |
| Synchronous | Commit waits for replica ACK | Zero data loss (RPO=0) | Higher latency |
| Asynchronous | Commit returns immediately | Read scaling, best performance | Sub-second typical |
PostgreSQL Streaming Replication
Primary configuration (postgresql.conf):
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1GB
max_replication_slots = 10
pg_hba.conf — allow replication connections:
# TYPE DATABASE USER ADDRESS METHOD
host replication replicator 10.0.0.0/24 scram-sha-256
Replica setup:
pg_basebackup -h primary-host -U replicator -D /var/lib/postgresql/data \
--checkpoint=fast --wal-method=stream --progress
touch /var/lib/postgresql/data/standby.signal
Replica configuration (postgresql.conf):
primary_conninfo = 'host=primary-host port=5432 user=replicator password=secret application_name=replica1'
primary_slot_name = 'replica1_slot'
hot_standby = on
hot_standby_feedback = on
Logical Replication
For selective table replication, cross-version upgrades, or multi-primary setups:
On the publisher (primary):
CREATE PUBLICATION order_pub FOR TABLE orders, order_items;
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
CREATE PUBLICATION insert_only_pub FOR TABLE audit_log
WITH (publish = 'insert, update');
On the subscriber (replica):
CREATE SUBSCRIPTION order_sub
CONNECTION 'host=primary-host port=5432 dbname=mydb user=replicator password=secret'
PUBLICATION order_pub
WITH (
copy_data = true,
create_slot = true,
synchronous_commit = 'off'
);
Application-Level Read/Write Routing
Node.js with connection pool routing:
import { Pool } from "pg";
const primaryPool = new Pool({
host: process.env.DB_PRIMARY_HOST,
port: 5432,
database: "mydb",
max: 20,
});
const replicaPool = new Pool({
host: process.env.DB_REPLICA_HOST,
port: 5432,
database: "mydb",
max: 40,
});
type QueryIntent = "read" | "write";
export async function query(sql: string, params: unknown[], intent: QueryIntent = "read") {
const pool = intent === "write" ? primaryPool : replicaPool;
return pool.query(sql, params);
}
export async function queryPrimary(sql: string, params: unknown[]) {
return primaryPool.query(sql, params);
}
Django with database routers:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': os.environ['DB_PRIMARY_HOST'],
'NAME': 'mydb',
},
'replica': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': os.environ['DB_REPLICA_HOST'],
'NAME': 'mydb',
},
}
DATABASE_ROUTERS = ['myapp.routers.PrimaryReplicaRouter']
class PrimaryReplicaRouter:
def db_for_read(self, model, **hints):
return 'replica'
def db_for_write(self, model, **hints):
return 'default'
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
return db == 'default'
Spring Boot with read/write routing:
@Configuration
public class DataSourceConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("replicaDataSource") DataSource replica) {
var router = new ReadWriteRoutingDataSource();
router.setTargetDataSources(Map.of(
DataSourceType.PRIMARY, primary,
DataSourceType.REPLICA, replica
));
router.setDefaultTargetDataSource(primary);
return router;
}
}
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? DataSourceType.REPLICA
: DataSourceType.PRIMARY;
}
}
Failover Strategies
Automated failover with Patroni (PostgreSQL):
scope: my-cluster
name: node1
restapi:
listen: 0.0.0.0:8008
postgresql:
listen: 0.0.0.0:5432
data_dir: /var/lib/postgresql/data
parameters:
max_connections: 200
wal_level: replica
max_wal_senders: 10
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
etcd:
hosts: etcd1:2379,etcd2:2379,etcd3:2379
Health check for replica lag:
SELECT
now() - pg_last_xact_replay_timestamp() AS replication_lag,
pg_is_in_recovery() AS is_replica,
pg_last_wal_receive_lsn() AS received_lsn,
pg_last_wal_replay_lsn() AS replayed_lsn;
Monitoring Replication
Primary-side monitoring:
SELECT
client_addr,
application_name,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes,
sync_state
FROM pg_stat_replication;
SELECT
slot_name,
active,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes
FROM pg_replication_slots;
Prometheus alerting rules:
groups:
- name: replication
rules:
- alert: ReplicationLagHigh
expr: pg_replication_lag_seconds > 30
for: 5m
labels:
severity: warning
annotations:
summary: "Replication lag exceeds 30s on {{ $labels.instance }}"
- alert: ReplicationSlotInactive
expr: pg_replication_slots_active == 0
for: 10m
labels:
severity: critical
annotations:
summary: "Inactive replication slot {{ $labels.slot_name }} — WAL may bloat"
Best Practices
- Use replication slots — Prevents WAL cleanup before replicas consume it. Without slots, slow replicas can fall behind and require re-initialization.
- Monitor replication lag continuously — Alert on lag exceeding your tolerance. For critical reads, route to primary.
- Use synchronous replication for zero RPO — But accept the latency cost. Use
synchronous_commit = remote_apply for strongest guarantee.
- Enable
hot_standby_feedback — Prevents long-running replica queries from conflicting with vacuum on the primary.
- Handle read-after-write consistency — After a write, route subsequent reads to the primary for a short window (sticky session or explicit routing).
- Test failover regularly — Automate failover drills. Use Patroni or PgBouncer for automated promotion.
- Monitor replication slot WAL retention — Inactive slots prevent WAL cleanup and can fill the disk. Alert and drop stale slots.
- Use logical replication for selective sync — When you only need certain tables replicated, logical replication avoids full WAL shipping overhead.
- Size replica connections separately — Read replicas typically need more connections than the primary since they handle high read volume.
- Plan for split-brain — Use consensus-based tools (etcd/ZooKeeper with Patroni) to prevent two nodes from both believing they are primary.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|
| No replication slots | Replica falls behind, needs full re-sync | Always create replication slots |
| Inactive replication slots | WAL accumulates, disk fills up | Monitor and drop orphaned slots |
| Read-after-write inconsistency | User writes data, reads stale from replica | Route to primary for a few seconds after writes |
Missing hot_standby_feedback | Long queries on replica cancelled by vacuum conflicts | Enable hot_standby_feedback = on |
| Synchronous replication without tuning | Every commit waits for network round trip | Use synchronous_commit = remote_write for balanced performance |
| No failover automation | Manual promotion takes minutes during outage | Use Patroni or cloud-managed HA |
| Schema changes breaking logical replication | ALTER TABLE on publisher not replicated | Apply DDL on both publisher and subscriber |
| WAL bloat from long transactions | pg_wal directory grows unbounded | Monitor oldest_xact_age, kill long transactions |
From database-migration-patterns
Database migration strategies — zero-downtime migrations, expand-contract, backfill, and rollback patterns.
Database Migration Patterns
Purpose
Guide safe, zero-downtime database schema migrations for production systems. Covers the expand-contract pattern, safe column operations, backfill strategies, index management, and rollback procedures. Focused on PostgreSQL but principles apply broadly.
Key Patterns
Expand-Contract Pattern
The gold standard for zero-downtime migrations. Every migration goes through three phases:
Phase 1 — EXPAND: Add new structure alongside old (backward compatible)
Phase 2 — MIGRATE: Backfill data, update application code to use new structure
Phase 3 — CONTRACT: Remove old structure once fully migrated
Example: Renaming a column (email -> email_address):
ALTER TABLE users ADD COLUMN email_address TEXT;
UPDATE users SET email_address = email WHERE email_address IS NULL;
CREATE OR REPLACE FUNCTION sync_email_columns()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.email_address IS NULL THEN
NEW.email_address := NEW.email;
END IF;
IF NEW.email IS NULL THEN
NEW.email := NEW.email_address;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_email
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_email_columns();
DROP TRIGGER trg_sync_email ON users;
DROP FUNCTION sync_email_columns();
ALTER TABLE users DROP COLUMN email;
Safe Column Operations
Adding a column (safe):
ALTER TABLE orders ADD COLUMN tracking_number TEXT;
ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
Adding NOT NULL constraint (careful):
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders ADD CONSTRAINT orders_status_not_null
CHECK (status IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;
Changing column type (dangerous — use expand-contract):
ALTER TABLE users ADD COLUMN age_v2 BIGINT;
UPDATE users SET age_v2 = age WHERE age_v2 IS NULL;
ALTER TABLE users DROP COLUMN age;
ALTER TABLE users RENAME COLUMN age_v2 TO age;
Safe Index Creation
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);
SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY idx_orders_status;
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);
Backfill Strategies
Batched backfill — Avoid long-running transactions:
DO $$
DECLARE
batch_size INT := 10000;
affected INT;
BEGIN
LOOP
UPDATE users
SET email_address = email
WHERE email_address IS NULL
AND ctid IN (
SELECT ctid FROM users
WHERE email_address IS NULL
LIMIT batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS affected = ROW_COUNT;
RAISE NOTICE 'Updated % rows', affected;
IF affected = 0 THEN
EXIT;
END IF;
PERFORM pg_sleep(0.1);
COMMIT;
END LOOP;
END $$;
Application-level backfill (for complex transformations):
async function backfillInBatches(
db: Pool,
batchSize = 5000,
delayMs = 100
) {
let totalUpdated = 0;
while (true) {
const result = await db.query(
`UPDATE users
SET email_address = email
WHERE id IN (
SELECT id FROM users
WHERE email_address IS NULL
ORDER BY id
LIMIT $1
)
RETURNING id`,
[batchSize]
);
totalUpdated += result.rowCount ?? 0;
console.log(`Backfilled ${totalUpdated} rows so far`);
if ((result.rowCount ?? 0) < batchSize) break;
await new Promise((r) => setTimeout(r, delayMs));
}
return totalUpdated;
}
Drizzle ORM Migration Example
import { sql } from 'drizzle-orm';
import type { MigrationMeta } from 'drizzle-orm/migrator';
export async function up(db: any) {
await db.execute(sql`
ALTER TABLE users ADD COLUMN email_address TEXT;
`);
await db.execute(sql`
UPDATE users SET email_address = email WHERE email_address IS NULL;
`);
}
export async function down(db: any) {
await db.execute(sql`
ALTER TABLE users DROP COLUMN IF EXISTS email_address;
`);
}
Rollback Procedures
Pre-migration checklist:
SELECT count(*) FROM users;
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users';
pg_dump
Rollback-safe migration template:
interface Migration {
name: string;
up: string[];
down: string[];
verify: string;
}
const migration: Migration = {
name: '0015_add_tracking_number',
up: [
'ALTER TABLE orders ADD COLUMN tracking_number TEXT;',
'CREATE INDEX CONCURRENTLY idx_orders_tracking ON orders(tracking_number);',
],
down: [
'DROP INDEX CONCURRENTLY IF EXISTS idx_orders_tracking;',
'ALTER TABLE orders DROP COLUMN IF EXISTS tracking_number;',
],
verify: `
SELECT column_name FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'tracking_number';
`,
};
Advisory Locks for Migration Safety
SELECT pg_advisory_lock(12345);
SELECT pg_advisory_unlock(12345);
async function withMigrationLock(db: Pool, fn: () => Promise<void>) {
const LOCK_ID = 839274;
try {
await db.query('SELECT pg_advisory_lock($1)', [LOCK_ID]);
await fn();
} finally {
await db.query('SELECT pg_advisory_unlock($1)', [LOCK_ID]);
}
}
Best Practices
- Never run migrations directly on production first — Always test on a staging environment with production-sized data.
- Use expand-contract for destructive changes — Column renames, type changes, and removes should always go through three phases.
- Create indexes concurrently —
CREATE INDEX CONCURRENTLY avoids blocking writes.
- Batch all backfills — Never run a single
UPDATE ... SET on millions of rows; use batches of 5k-10k with brief pauses.
- Write rollback SQL before the migration — Every
up must have a corresponding down written and tested.
- Monitor lock contention — Use
pg_stat_activity and pg_locks during migrations to catch blocking.
- Use advisory locks — Prevent two migration processes from running simultaneously.
- Separate schema changes from data changes — Deploy schema additions first, then backfill in a separate step.
- Validate constraints separately — Use
NOT VALID + VALIDATE CONSTRAINT to avoid long exclusive locks.
- Keep migrations small and focused — One logical change per migration file; never combine unrelated changes.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|
| Adding NOT NULL without default | Entire table rewrite + exclusive lock | Use NOT VALID CHECK constraint + validate later |
| Non-concurrent index creation | Blocks all writes during build | Always use CREATE INDEX CONCURRENTLY |
| Backfill in single transaction | Long-running transaction blocks vacuum, bloats table | Batch with COMMIT between batches |
| No rollback plan | Stuck with a broken migration in production | Write and test down migration before running up |
| Dropping column before code deploy | Application errors on missing column | Deploy code changes first, then drop column |
| Running migration during peak traffic | Lock contention causes timeouts | Schedule migrations during low-traffic windows |
| Forgetting to validate NOT VALID constraints | Constraint exists but not enforced on old rows | Run ALTER TABLE ... VALIDATE CONSTRAINT after backfill |