| name | database-patterns |
| description | Use when designing PostgreSQL + Redis data models, indexes, caching strategies, JSONB usage, tiered storage, or cache consistency contracts. |
Database Patterns
Core Principles
- PostgreSQL Primary — Relational data, transactions, complex queries
- Redis Secondary — Caching, sessions, real-time data
- Index-First Design — Design queries before indexes
- JSONB Sparingly — Structured data prefers columns
- Cache-Aside Default — Read-through, write-around
- Tiered Storage — Hot/Warm/Cold data separation
- No backwards compatibility — Migrate data, don't keep legacy schemas
PostgreSQL
Data Type Selection
| Use Case | Type | Avoid |
|---|
| Primary Key | UUID / BIGSERIAL | INT (range limits) |
| Timestamps | TIMESTAMPTZ | TIMESTAMP (no timezone) |
| Money | NUMERIC(19,4) | FLOAT (precision loss) |
| Status | TEXT + CHECK | INT (unreadable) |
| Semi-structured | JSONB | JSON (no indexing) |
| Full-text | TSVECTOR | LIKE '%..%' |
Schema Design
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'inactive', 'suspended')),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
Indexing Strategy
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at DESC);
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';
CREATE INDEX idx_metadata ON users USING GIN (metadata jsonb_path_ops);
CREATE INDEX idx_user_role ON users ((metadata->>'role'));
CREATE INDEX idx_search ON products USING GIN (to_tsvector('english', name || ' ' || description));
JSONB Usage
CREATE TABLE products (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(19,4) NOT NULL,
category TEXT NOT NULL,
attributes JSONB DEFAULT '{}'
);
SELECT * FROM products
WHERE category = 'electronics'
AND attributes @> '{"brand": "Apple"}';
SELECT * FROM products
WHERE attributes->>'color' = 'black';
UPDATE products
SET attributes = attributes || '{"featured": true}'
WHERE id = '...';
Query Optimization
EXPLAIN ANALYZE
SELECT u.*, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.status = 'active'
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT 20;
Connection Pooling
import { Pool } from 'pg';
const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
Redis
Data Structure Selection
| Use Case | Structure | Example |
|---|
| Cache objects | String | user:123 → JSON |
| Counters | String + INCR | views:article:456 |
| Sessions | Hash | session:abc → {userId, ...} |
| Leaderboards | Sorted Set | scores → {userId: score} |
| Queues | List/Stream | tasks → LPUSH/RPOP |
| Unique sets | Set | online_users |
| Real-time | Pub/Sub/Stream | Notifications |
Key Naming
# Format: <entity>:<id>:<attribute>
user:123:profile
user:123:settings
order:456:items
session:abc123
# Use colons for hierarchy
# Enables pattern matching with SCAN
SCAN 0 MATCH "user:*:profile" COUNT 100
TTL Strategy
const TTL = {
SESSION: 24 * 60 * 60,
CACHE: 15 * 60,
RATE_LIMIT: 60,
LOCK: 30,
};
await redis.set(`cache:user:${id}`, JSON.stringify(user), 'EX', TTL.CACHE);
const remaining = await redis.ttl(`cache:user:${id}`);
Caching Patterns
Cache-Aside (Lazy Loading)
async function getUser(id: string): Promise<User> {
const cacheKey = `user:${id}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const user = await db.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundError('User not found');
}
await redis.set(cacheKey, JSON.stringify(user), 'EX', 900);
return user;
}
Write-Through
async function updateUser(id: string, data: UpdateInput): Promise<User> {
const user = await db.user.update({
where: { id },
data,
});
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 900);
return user;
}
Cache Invalidation
async function deleteUser(id: string): Promise<void> {
await db.user.delete({ where: { id } });
await redis.del(`user:${id}`);
const keys = await redis.keys(`user:${id}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
}
Cache Stampede Prevention
async function getUserWithLock(id: string): Promise<User> {
const cacheKey = `user:${id}`;
const lockKey = `lock:user:${id}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
if (!acquired) {
await sleep(100);
return getUserWithLock(id);
}
try {
const rechecked = await redis.get(cacheKey);
if (rechecked) {
return JSON.parse(rechecked);
}
const user = await db.user.findUnique({ where: { id } });
await redis.set(cacheKey, JSON.stringify(user), 'EX', 900);
return user;
} finally {
await redis.del(lockKey);
}
}
Cache Penetration Prevention
async function getUserSafe(id: string): Promise<User | null> {
const cacheKey = `user:${id}`;
const cached = await redis.get(cacheKey);
if (cached === 'NULL') {
return null;
}
if (cached) {
return JSON.parse(cached);
}
const user = await db.user.findUnique({ where: { id } });
if (!user) {
await redis.set(cacheKey, 'NULL', 'EX', 60);
return null;
}
await redis.set(cacheKey, JSON.stringify(user), 'EX', 900);
return user;
}
Tiered Storage
┌─────────────────────────────────────────────────┐
│ Application │
└─────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Redis │ │ Postgres │ │ Archive │
│ (Hot) │ │ (Warm) │ │ (Cold) │
└─────────┘ └─────────┘ └─────────┘
< 1ms ~10ms ~100ms+
Active data Recent data Historical
Memory SSD Object storage
Partitioning for Cold Data
CREATE TABLE orders (
id UUID NOT NULL,
user_id UUID NOT NULL,
total NUMERIC(19,4) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2025_q1 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE orders_2025_q2 PARTITION OF orders
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
CREATE TABLE orders_archive (LIKE orders INCLUDING ALL);
WITH moved AS (
DELETE FROM orders
WHERE created_at < NOW() - INTERVAL '1 year'
RETURNING *
)
INSERT INTO orders_archive SELECT * FROM moved;
Transactions
ACID Compliance
async function transferFunds(fromId: string, toId: string, amount: number) {
await db.$transaction(async (tx) => {
const from = await tx.account.update({
where: { id: fromId },
data: { balance: { decrement: amount } },
});
if (from.balance < 0) {
throw new Error('Insufficient funds');
}
await tx.account.update({
where: { id: toId },
data: { balance: { increment: amount } },
});
});
}
Optimistic Locking
ALTER TABLE products ADD COLUMN version INT DEFAULT 1;
UPDATE products
SET
stock = stock - 1,
version = version + 1
WHERE id = $1 AND version = $2
RETURNING *;
Checklist
## Schema
- [ ] UUID or BIGSERIAL for primary keys
- [ ] TIMESTAMPTZ for all timestamps
- [ ] NUMERIC for money, not FLOAT
- [ ] CHECK constraints for enums
- [ ] Foreign keys with ON DELETE
## Indexing
- [ ] Index for each WHERE clause pattern
- [ ] Composite indexes match query order
- [ ] GIN index for JSONB containment
- [ ] EXPLAIN ANALYZE for slow queries
## Caching
- [ ] Cache-aside as default pattern
- [ ] TTL on all cached data
- [ ] Cache invalidation on writes
- [ ] Stampede/penetration protection
## Operations
- [ ] Connection pooling configured
- [ ] Slow query logging enabled
- [ ] Backup and recovery tested
- [ ] Partition strategy for growth
See Also