| name | scalable-data-schema |
| description | Design database schemas that scale from prototype to production. Use when planning data models,
designing tables, optimizing queries, or migrating schemas. Covers normalization, indexing,
partitioning, and evolution strategies for SQL and NoSQL databases.
|
Scalable Data Schema Design
Design database schemas that grow with your application—from prototype to millions of users.
When to Use
Use this skill when:
- Starting a new project and designing initial data models
- Existing schema is causing performance issues
- Planning to scale from thousands to millions of records
- Migrating between database systems
- Data model needs to evolve without breaking changes
Core Principles
1. Start Simple, Plan for Complex
Prototype Phase (< 10K records):
- Optimize for development speed
- Denormalize for convenience
- Use simple indexes
- Single database instance
Growth Phase (10K - 1M records):
- Normalize to reduce redundancy
- Add strategic indexes
- Introduce read replicas
- Monitor query performance
Scale Phase (> 1M records):
- Partition large tables
- Shard by logical boundaries
- Add caching layers
- Consider polyglot persistence
2. Query Patterns Drive Schema
Anti-pattern: Design schema, then write queries
Best practice: Identify queries, then design schema
Workflow:
1. List top 10 most frequent queries
2. List top 5 most expensive queries
3. Design indexes to support both
4. Benchmark with realistic data volumes
3. Plan for Evolution
Schema versioning strategy:
- Additive changes only (new columns, new tables)
- Deprecate old fields instead of deleting
- Use database migration tools (Flyway, Liquibase, Alembic)
- Version your schema in git
Design Patterns
Pattern 1: Event Sourcing for Audit Trails
Use when: You need complete history of changes
UPDATE users SET email = 'new@example.com' WHERE id = 123;
CREATE TABLE user_events (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by BIGINT
);
CREATE INDEX idx_user_events_user_id ON user_events(user_id);
CREATE INDEX idx_user_events_created_at ON user_events(created_at DESC);
CREATE VIEW current_users AS
SELECT DISTINCT ON (user_id)
user_id,
event_data->>'email' as email,
created_at as last_updated
FROM user_events
WHERE event_type = 'email_changed'
ORDER BY user_id, created_at DESC;
Trade-offs:
- ✓ Complete audit trail
- ✓ Time travel queries
- ✗ More storage
- ✗ Queries more complex
Pattern 2: Soft Deletes for Recovery
Use when: Data is valuable or deletion is rare
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(10,2),
deleted_at TIMESTAMPTZ,
deleted_by BIGINT
);
CREATE INDEX idx_products_active ON products(id) WHERE deleted_at IS NULL;
SELECT * FROM products WHERE deleted_at IS NULL;
SELECT * FROM products WHERE deleted_at IS NOT NULL;
Trade-offs:
- ✓ Easy recovery
- ✓ Audit compliance
- ✗ Every query needs
WHERE deleted_at IS NULL
- ✗ Unique constraints more complex
Pattern 3: Polymorphic Associations
Use when: Multiple entity types share a relationship
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
text TEXT NOT NULL,
post_id BIGINT,
photo_id BIGINT,
video_id BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
text TEXT NOT NULL,
commentable_type VARCHAR(50) NOT NULL,
commentable_id BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT valid_commentable_type
CHECK (commentable_type IN ('Post', 'Photo', 'Video'))
);
CREATE INDEX idx_comments_commentable
ON comments(commentable_type, commentable_id);
SELECT * FROM comments
WHERE commentable_type = 'Post'
AND commentable_id = 123;
Trade-offs:
- ✓ Flexible, extensible
- ✗ Can't use foreign key constraints
- ✗ Joins more complex
When to avoid: Use separate tables if types have very different fields.
Pattern 4: Hierarchical Data (Adjacency List vs Nested Sets)
Adjacency List (simple, common):
CREATE TABLE categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id BIGINT REFERENCES categories(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_categories_parent ON categories(parent_id);
SELECT * FROM categories WHERE parent_id = 5;
WITH RECURSIVE subcategories AS (
SELECT * FROM categories WHERE id = 5
UNION ALL
SELECT c.* FROM categories c
JOIN subcategories s ON c.parent_id = s.id
)
SELECT * FROM subcategories;
Nested Sets (fast reads, slow writes):
CREATE TABLE categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
lft INT NOT NULL,
rgt INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_categories_lft_rgt ON categories(lft, rgt);
SELECT * FROM categories
WHERE lft > 10 AND rgt < 20;
SELECT * FROM categories c1
JOIN categories c2 ON c1.lft BETWEEN c2.lft AND c2.rgt
WHERE c1.id = 15
ORDER BY c2.lft;
Choose:
- Adjacency List: Frequent updates, shallow hierarchies
- Nested Sets: Mostly reads, deep hierarchies
- Materialized Path: Mix of both (store path as string:
/1/5/12/)
Pattern 5: Time-Series Data (Partitioning)
Use when: Data grows continuously over time
CREATE TABLE metrics (
id BIGSERIAL,
metric_name VARCHAR(100) NOT NULL,
value DOUBLE PRECISION NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
tags JSONB,
PRIMARY KEY (id, recorded_at)
) PARTITION BY RANGE (recorded_at);
CREATE TABLE metrics_2026_01 PARTITION OF metrics
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE metrics_2026_02 PARTITION OF metrics
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE INDEX idx_metrics_2026_01_name ON metrics_2026_01(metric_name);
CREATE INDEX idx_metrics_2026_02_name ON metrics_2026_02(metric_name);
SELECT AVG(value) FROM metrics
WHERE recorded_at BETWEEN '2026-01-15' AND '2026-01-20'
AND metric_name = 'cpu_usage';
Benefits:
- ✓ Query only relevant partitions (partition pruning)
- ✓ Drop old partitions easily (instant delete)
- ✓ Parallel queries across partitions
- ✓ Manage storage per partition
Indexing Strategy
When to Index
Index when:
- Column used in WHERE clause frequently
- Column used in JOIN conditions
- Column used in ORDER BY
- Foreign keys (even if DB doesn't auto-create)
Don't index when:
- Table is small (< 1000 rows)
- Column has low cardinality (few distinct values)
- Column is updated frequently
- Index would be larger than table
Index Types
B-Tree (default, most common):
CREATE INDEX idx_users_email ON users(email);
Hash (exact matches only):
CREATE INDEX idx_users_email ON users USING HASH(email);
GIN (Generalized Inverted Index - for arrays/JSONB):
CREATE INDEX idx_products_tags ON products USING GIN(tags);
Partial Index (index subset of rows):
CREATE INDEX idx_active_users ON users(email) WHERE deleted_at IS NULL;
Composite Index (multiple columns):
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);
Index Maintenance
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE '%_pkey';
SELECT tablename, array_agg(indexname)
FROM pg_indexes
GROUP BY tablename, indexdef
HAVING COUNT(*) > 1;
REINDEX INDEX CONCURRENTLY idx_users_email;
Schema Evolution
Additive-Only Migrations
Safe (zero downtime):
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
CREATE TABLE user_preferences (...);
CREATE INDEX CONCURRENTLY idx_users_created ON users(created_at);
Risky (requires downtime or careful planning):
ALTER TABLE users DROP COLUMN old_field;
ALTER TABLE users RENAME COLUMN old_name TO new_name;
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
Safe Migration Pattern
3-Step Deploy:
ALTER TABLE users ADD COLUMN email_new VARCHAR(255);
UPDATE users SET email_new = email WHERE email_new IS NULL;
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users RENAME COLUMN email_new TO email;
Versioned Schema
CREATE TABLE schema_migrations (
version VARCHAR(50) PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
);
BEGIN;
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
INSERT INTO schema_migrations (version)
VALUES ('20260121_add_user_phone');
COMMIT;
Database Selection Guide
SQL (Relational)
PostgreSQL:
- ✓ Best for: Complex queries, ACID transactions, JSONB
- ✓ Advanced features: Full-text search, geospatial (PostGIS)
- Use when: You need strong consistency and rich query capabilities
MySQL/MariaDB:
- ✓ Best for: Read-heavy workloads, replication
- ✓ Widely supported, mature ecosystem
- Use when: You need simple replication or hosting constraints
SQLite:
- ✓ Best for: Embedded databases, single-user apps
- Use when: No separate DB server needed
NoSQL
MongoDB (Document):
- ✓ Best for: Flexible schema, rapid iteration
- ✓ Schema-less (actually schema-on-read)
- Use when: Schema changes frequently, hierarchical data
Redis (Key-Value):
- ✓ Best for: Caching, sessions, pub/sub
- Use when: Ultra-fast reads, temporary data
DynamoDB (Key-Value):
- ✓ Best for: Serverless, predictable performance
- Use when: AWS ecosystem, pay-per-request pricing
Cassandra (Wide-Column):
- ✓ Best for: Time-series, write-heavy workloads
- Use when: Multi-datacenter, massive scale
Decision Matrix
| Use Case | Database | Why |
|---|
| User accounts, transactions | PostgreSQL | ACID, relations |
| Product catalog (e-commerce) | PostgreSQL | Complex queries, inventory |
| Session storage | Redis | Fast, ephemeral |
| Activity feed | Cassandra | Write-heavy, time-series |
| Analytics events | ClickHouse | Columnar, OLAP |
| Document storage | MongoDB | Flexible schema |
| Geospatial queries | PostgreSQL + PostGIS | Best geo support |
Performance Optimization
Query Analysis
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2026-01-01'
GROUP BY u.id, u.name;
Common Fixes
Problem: Slow JOIN on large tables
SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending';
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
Problem: Slow COUNT(*) on large table
SELECT COUNT(*) FROM orders;
SELECT reltuples::BIGINT AS estimate
FROM pg_class
WHERE relname = 'orders';
CREATE TABLE table_stats (
table_name VARCHAR(50) PRIMARY KEY,
row_count BIGINT,
updated_at TIMESTAMPTZ
);
Problem: N+1 queries
users = query("SELECT * FROM users LIMIT 10")
for user in users:
orders = query("SELECT * FROM orders WHERE user_id = ?", user.id)
SELECT u.*, o.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LIMIT 10;
user_ids = [1, 2, 3, ...]
SELECT * FROM orders WHERE user_id IN (?, ?, ?)
Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Better Approach |
|---|
| ENUM in column type | Hard to change | Lookup table with foreign key |
| Storing arrays as strings | Can't query efficiently | Array column or join table |
| GUID/UUID as primary key | Fragmented indexes, 16 bytes | BIGSERIAL (8 bytes, sequential) |
| Premature optimization | Complexity without benefit | Start simple, optimize when needed |
| Missing foreign keys | Data integrity issues | Always use FK constraints |
| No created_at/updated_at | Can't audit or debug | Add to all tables |
| Over-normalization | Too many JOINs | Denormalize for read patterns |
| Under-normalization | Data duplication, update anomalies | Normalize until 3NF, then denormalize strategically |
Output Format
When helping with schema design:
## Schema Analysis
### Current Pain Points
- [Specific issue 1]
- [Specific issue 2]
### Recommended Schema
[SQL DDL for new tables/changes]
### Indexes
[Recommended indexes with rationale]
### Migration Strategy
[Step-by-step migration plan]
### Trade-offs
- ✓ Advantages
- ✗ Disadvantages
### Expected Performance Impact
- Reads: [faster/slower, by how much]
- Writes: [faster/slower, by how much]
- Storage: [increase/decrease]
Integration
Works with:
- database-schema - Initial schema design
- api-design - Align schema with API needs
- systems-decompose - Feature-driven schema design
- data-infrastructure-at-scale - Infrastructure for scaled schemas