The database is the hardest part of your system to change. Get it right first.
The database is the hardest part of your system to change. Get it right first.
Lesson outline
In 2012, Instagram was acquired by Facebook for $1 billion. At the time, they had 13 employees and 30 million users. Their entire backend ran on PostgreSQL. They never needed to switch.
The Migration Nightmare
Twitter spent 3 years migrating from MySQL to Manhattan (their custom storage system). Uber migrated from PostgreSQL to MySQL in 2016 and wrote a 6,000-word blog post explaining why. Database migrations are the most expensive engineering projects most companies undertake.
The question isn't "which database is best." It's "which database fits my data model, access patterns, and scale requirements."
| Database Type | Best For | Examples | Avoid When |
|---|---|---|---|
| Relational (SQL) | Structured data, complex queries, transactions | PostgreSQL, MySQL, SQLite | Massive scale with simple lookups, unstructured data |
| Document (NoSQL) | Flexible schemas, nested data, rapid iteration | MongoDB, Firestore, DynamoDB | Complex JOIN-heavy queries, strong consistency required |
| Key-Value | Session storage, caching, simple lookups | Redis, DynamoDB, etcd | Complex queries, data that needs relationships |
| Wide-Column | Time-series, high write throughput, append-heavy | Cassandra, HBase, BigTable | Complex queries, strong consistency, small datasets |
| Graph | Relationship-heavy data, social graphs, recommendations | Neo4j, Amazon Neptune | Non-graph data, simple queries |
| Search | Full-text search, faceted filtering | Elasticsearch, Solr, Typesense | Authoritative data source, complex transactions |
PostgreSQL and MySQL power the majority of the world's business data. The relational model — tables, rows, columns, foreign keys — has survived 50 years because it works.
ACID: The Guarantee That Makes Banks Trust Databases
Isolation Levels in Production
Most databases default to Read Committed. PostgreSQL uses it. MySQL InnoDB defaults to Repeatable Read. Serializable is the safest but slowest. Pick the lowest isolation level that still prevents your specific concurrency bugs.
1-- PostgreSQL schema with production-grade patterns23-- Users table: always use UUID primary keys for distributed systems4CREATE TABLE users (UUID over auto-increment: no coordination needed across database replicas5id UUID PRIMARY KEY DEFAULT gen_random_uuid(),6email TEXT UNIQUE NOT NULL,7name TEXT NOT NULL,TIMESTAMPTZ stores timezone — use it. TIMESTAMP without TZ causes daylight saving bugs8created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- timezone-aware!9deleted_at TIMESTAMPTZ -- soft delete, not hard delete10);1112-- Orders table: proper foreign keys with cascading13CREATE TABLE orders (14id UUID PRIMARY KEY DEFAULT gen_random_uuid(),ON DELETE RESTRICT prevents orphaned orders. ON DELETE CASCADE would delete orders with users15user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,Store money as integer cents, never as FLOAT. 0.1 + 0.2 = 0.30000000000000004 in floating point16status TEXT NOT NULL CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),17total_cents INTEGER NOT NULL CHECK (total_cents >= 0), -- store money as integers!18created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()19);20Composite index on (user_id, created_at) supports "get user orders sorted by date" in one index scan21-- Index for common query pattern: user's orders22CREATE INDEX idx_orders_user_id_created ON orders(user_id, created_at DESC);2324-- ACID transaction: transfer money atomically25BEGIN;26UPDATE accounts SET balance = balance - 10000 WHERE id = 'account-A';27UPDATE accounts SET balance = balance + 10000 WHERE id = 'account-B';28-- If any error here, entire transaction rolls back29COMMIT;3031-- Window functions: FAANG interview favorite32SELECT33user_id,34order_id,35total_cents,36SUM(total_cents) OVER (PARTITION BY user_id ORDER BY created_at) AS running_total,37RANK() OVER (PARTITION BY user_id ORDER BY total_cents DESC) AS order_rank38FROM orders;
Most junior engineers design schemas based on the real world. Senior engineers design schemas based on the queries that will run against them.
The Query-First Design Rule
Before designing any table, write the 5 most common queries your application needs. Then design the schema to answer those queries efficiently. Your indexes flow from your query patterns, not from your data model.
| Normal Form | Eliminates | Trade-Off | Use When |
|---|---|---|---|
| 1NF | Repeating groups, multi-valued columns | More tables | Always (minimum standard) |
| 2NF | Partial functional dependencies | More joins | Always for relational data |
| 3NF | Transitive dependencies | Even more joins | Standard for OLTP systems |
| Denormalized | Joins (intentionally) | Data duplication, update anomalies | Read-heavy, analytics, search indexes |
When to Denormalize Intentionally
Instagram stores the follower count directly on the user row (denormalized) instead of counting follower rows every time. The count is eventually consistent but the read is instant. This is the right trade-off for 10B reads/day vs occasional inconsistency.
Data Modeling Process at FAANG
01
Write the 5–10 most critical queries your system needs to answer
02
Identify the entities (User, Order, Product) and their relationships
03
Normalize to 3NF as a starting point
04
Identify which queries would require expensive JOINs or full table scans
05
Selectively denormalize those specific hot paths (add materialized counts, embed frequently co-read data)
06
Add indexes for every WHERE clause and JOIN column in your critical queries
07
Load test with production-scale data before shipping
Write the 5–10 most critical queries your system needs to answer
Identify the entities (User, Order, Product) and their relationships
Normalize to 3NF as a starting point
Identify which queries would require expensive JOINs or full table scans
Selectively denormalize those specific hot paths (add materialized counts, embed frequently co-read data)
Add indexes for every WHERE clause and JOIN column in your critical queries
Load test with production-scale data before shipping
The Database Scaling Ladder (climb before you jump)
Sharding Is a Last Resort
Sharding eliminates cross-shard JOINs, makes transactions spanning shards impossible, and requires a routing layer. Pinterest moved away from sharding to Vitess (MySQL with transparent sharding). Stripe and Shopify still run on single-region PostgreSQL with clever partitioning. Don't shard prematurely.
| Technique | Addresses | Trade-Off | When to Apply |
|---|---|---|---|
| Index optimization | Slow queries | Slightly slower writes | Always — first step |
| Connection pooling (PgBouncer) | Connection exhaustion | Config complexity | >100 concurrent connections |
| Read replicas | Read throughput | Replication lag, eventual consistency | >70% reads in workload |
| Partitioning | Table bloat, time-series data | Partition pruning must be planned | >100M rows in one table |
| Sharding | Write throughput, data size | No cross-shard JOINs, complex ops | After everything else fails |
| NewSQL (CockroachDB, Spanner) | Global scale with SQL | Latency, cost, operational complexity | Global consistency required |
1// Read replica routing with Prisma2import { PrismaClient } from '@prisma/client';34// Primary: all writes go here5const primary = new PrismaClient({Never mix connection pools — primary is for writes, replica for reads6datasources: { db: { url: process.env.DATABASE_PRIMARY_URL } }7});89// Replica: reads only10const replica = new PrismaClient({11datasources: { db: { url: process.env.DATABASE_REPLICA_URL } }12});1314// Route based on operation type15class DatabaseRouter {Replica reads are eventually consistent. Replication lag is typically 10-100ms16async findUser(id: string) {17// READ → replica (eventual consistency acceptable)18return replica.user.findUnique({ where: { id } });19}2021async createOrder(data: OrderCreateInput) {22// WRITE → primary (strong consistency required)23return primary.order.create({ data });Post-write reads should go to primary to avoid read-after-write inconsistency24}2526async getOrderForDisplay(id: string) {27// Reads that MUST be fresh (after a write) → primary28return primary.order.findUnique({29where: { id },30include: { user: true, items: true }31});32}33}3435// ⚠️ Read-after-write problem:36// User creates order → replica may not have it yet37// Solution: For 1 second after writes, route reads to primary38// Or: use a cache to store "just created" IDs
Database questions at FAANG assess whether you understand trade-offs, not just syntax. They want to see you reason about consistency vs availability, normalization vs performance, and when NOT to scale.
Common questions:
Strong answers include:
Red flags:
Quick check · Database Fundamentals: SQL, Scaling, and Data Modeling
1 / 3
Key takeaways
From the books
Designing Data-Intensive Applications — Martin Kleppmann (2017)
Part I: Foundations of Data Systems
The fundamentals of data systems (replication, partitioning, transactions) are more important than any specific database product. These principles apply across all systems.
The Art of PostgreSQL — Dimitri Fontaine (2019)
Chapter on Indexing and Query Optimization
PostgreSQL is more powerful than most engineers realize. Window functions, CTEs, and partial indexes can often replace application-layer complexity.
Ready to see how this works in the cloud?
Switch to Career Paths for structured paths (e.g. Developer, DevOps) and provider-specific lessons.
View role-based pathsSign in to track your progress and mark lessons complete.
Questions? Discuss in the community or start a thread below.
Join DiscordSign in to start or join a thread.