Contents
8 entity groups Β· scroll to follow along
Netflix System Design
Data Models
Read this like a compact ER diagram: each schema card keeps table name, fields, types, keys, storage reason, and access patterns close together.
User & Profile
User accounts and profiles need ACID transactions, referential integrity (CASCADE DELETE), and complex JOIN queries for billing dashboards. DynamoDB can't enforce FK constraints or run multi-table transactions without application-level orchestration. Billing errors that charge real money require strong consistency β Aurora is the only choice.
Schema
user_id
password_hash
subscription_tier
plan_type
region
country_code
device_limit
created_at
updated_at
deleted_at
Why Aurora
Aurora because billing identity needs ACID transactions, FK constraints to subscriptions/payment_methods, and GDPR hard-delete semantics β none of which DynamoDB can enforce natively.
Access
β get by email (login)
β get by user_id (profile load)
β list all for billing reports (rare)
profile_id
user_id
name
avatar_url
is_kids
max_rating
language_pref
pin_hash
created_at
Why Aurora
Aurora because profiles are a child entity of users (FK β ON DELETE CASCADE) and are always read with a JOIN to users for access-control checks β keeping them in the same RDBMS avoids cross-store transactions.
Access
β get profiles by user_id
β get profile_id for JWT
β check is_kids for catalog filtering
Anti-Patterns to Avoid
- βDon't store watch history here β 500K writes/sec would kill Aurora
- βDon't store content metadata here β 50K RPS read throughput needs DynamoDB
- βDon't store session tokens here β sub-ms access needs Redis
Interview Tip
Distinguish User (billing entity) from Profile (viewing persona) immediately. Interviewers test this.
Scaling Note
At 10Γ scale: read replicas + PgBouncer pooling stays the same pattern, just more replicas. Aurora Serverless v2 for burst workloads. The schema doesn't change.
Content & Episode
Content metadata is read-heavy at 50,000 RPS with well-defined access patterns: get-by-id, list-by-genre, get-episodes-of-series. DynamoDB delivers single-digit ms at any scale, deploys globally with zero ops, and the flexible document model handles the wide variety of content attributes (movie vs series vs short). No JOINs needed β all reads are single-entity lookups.
Schema
PK: content_id
SK: 'METADATA'
title
type
genres
cast
director
release_year
maturity_rating
thumbnail_url
available_regions
duration_secs
duration_minutes
description
Why DynamoDB
DynamoDB because content metadata is read-heavy at 50 K RPS with fully predictable access patterns (get-by-id, list-by-genre). Schemaless attributes handle the wide variation between movies, series, and shorts without NULL columns or ALTER TABLE migrations.
Access
β get content by ID β PK=content_id, SK=METADATA
β list by genre sorted by year β genre-index GSI
β get all episodes in series β PK=series_id, SK begins_with 'S'
PK: series_id
SK: 'S{n}E{n}'
episode_id
title
duration_minutes
thumbnail_url
description
release_date
Why DynamoDB
DynamoDB because episodes belong to a series (same partition key), so get-all-episodes is a single Query with no cross-partition scatter β impossible to match with a relational range scan at this read throughput.
Access
β get all episodes for a series β PK=series_id, SK begins_with 'S'
β get specific episode β PK=series_id, SK='S02E05'
β get latest season β sort by SK descending
Anti-Patterns to Avoid
- βDon't run ad-hoc analytics here β use OpenSearch or Redshift for 'find all titles released in 2022 with rating > 7'
- βDon't store watch history here β wrong access pattern (time-series) and wrong throughput profile
- βDon't run full-text search here β DynamoDB has no text index, use OpenSearch for that
Interview Tip
State access patterns before table design. Interviewers want to hear 'I defined 3 access patterns upfront: get-by-id, list-by-genre, get-episodes.' That's DynamoDB design thinking.
Scaling Note
At 10Γ scale: on-demand capacity auto-scales, no changes needed. Add a global secondary index if a new access pattern emerges β don't redesign the table.
Watch History
500K writes/second at peak (all concurrent viewers sending 30s heartbeats). Cassandra is purpose-built for this: linear write scale across nodes, time-series data partitions naturally by user, native TTL eliminates cleanup jobs, and eventual consistency is acceptable for watch progress.
Schema
profile_id
content_id
episode_id
progress_sec
updated_at
completed
Why Cassandra
Cassandra because this table absorbs 500 K heartbeat writes/sec β its LSM-tree engine turns random writes into sequential I/O, and partitioning by profile_id keeps each resume-position lookup a single-node O(1) read.
Access
β get resume position β PK=profile_id, CK=content_id (O(1))
β list all in-progress content for 'Continue Watching' row β PK=profile_id, limit 10
profile_id
year
watched_at
content_id
episode_id
progress_sec
duration_sec
bitrate_kbps
Why Cassandra
Cassandra because this is an append-only time-series log; year-bucketed composite partition key prevents hot partitions for power users, and native TTL auto-expires old records without a batch delete job.
Access
β get recent watch history for reco model β PK=(profile_id, current_year), newest first
β get watch history across years β query (profile_id, 2024) + (profile_id, 2023) and merge in app
Anti-Patterns to Avoid
- βDon't use a single partition key of profile_id alone β unbounded partition growth for power users
- βDon't use QUORUM reads β LOCAL_ONE is correct here, read freshness doesn't matter for 3-second-stale data
- βDon't store this in Aurora β 500K writes/sec would require extreme sharding and connection pool management
Interview Tip
Explain the TWO table design: watch_progress (upsert, current position) vs watch_history (append-only, full log). They serve different consumers. Mentioning this distinguishes senior candidates.
Scaling Note
At 10Γ scale: add nodes (Cassandra scales linearly). Bucket partition key by month instead of year. Consider Scylla DB (same CQL API, 10Γ throughput on the same hardware).
Playback Session
An active playback session is a hot, ephemeral object: the player pings it every 30 seconds, and once the user stops watching it should vanish automatically. Redis holds the live session for sub-ms reads on the streaming hot path; DynamoDB persists an audit record for device-limit enforcement, GDPR exports, and post-session analytics.
Schema
Key: session:{session_id}
user_id
content_id
device_id
quality_level
position_ms
started_at
TTL: 90s
Why Redis
Redis because the 30-second heartbeat from every concurrent viewer is the highest-write-rate operation in the system β Redis SETEX is the only store that absorbs this with sub-ms latency and automatic TTL expiry.
Access
β heartbeat upsert β SETEX session:{session_id} 90 {json} (every 30s)
β device-limit check β SCAN 0 MATCH session:* COUNT 100, filter by user_id (or use a secondary set key: user_sessions:{user_id})
β stop session β DEL session:{session_id}
PK: session_id
SK: 'SESSION'
user_id
content_id
device_id
started_at
last_heartbeat
position_ms
quality_level
status
TTL
Why DynamoDB
DynamoDB for durable audit: device-limit enforcement queries (all active sessions for a user), GDPR data exports, and post-session analytics fan-out to Kinesis β Redis alone cannot serve these multi-minute retention queries.
Access
β check device limit β query user-sessions-index GSI, PK=user_id, filter status=ACTIVE
β end session β UpdateItem status=COMPLETED, position_ms, last_heartbeat
β GDPR export β query user-sessions-index, all records for user_id
Anti-Patterns to Avoid
- βDon't store the in-flight session only in DynamoDB β even with DAX, the 30s heartbeat write cadence at 15M concurrent viewers overwhelms provisioned WCU budgets
- βDon't store the in-flight session only in Redis β a Redis restart during a popular event (World Cup final) would drop all active sessions simultaneously
- βDon't use a relational DB for session state β connection-count exhaustion at 15M concurrent writes is a known failure mode
Interview Tip
Explain the Redis + DynamoDB split by lifecycle: Redis owns the 'alive' window (30s TTL refresh), DynamoDB owns the audit record. Both writes happen on session start; only Redis is on the 30s heartbeat path.
Scaling Note
At 10Γ scale (150M concurrent): Redis cluster shards by session_id prefix across 32+ nodes. DynamoDB on-demand absorbs burst without provisioning changes. The critical insight: heartbeat writes go only to Redis β DynamoDB write rate is bounded by session starts/ends, not heartbeats.
Watch History (DynamoDB)
The DynamoDB watch history table complements the Cassandra write-path log: it is the queryable, durable, globally-replicated record for the recommendation engine, the 'My Activity' GDPR export, and the account settings page. DynamoDB Global Tables replicate to 3 regions with <1 s replication lag, and the composite sort key (watched_at#content_id) enables time-range queries and per-title deduplication in a single index.
Schema
PK: user_id
SK: watched_at#content_id
content_id
episode_id
watched_at
progress_secs
duration_secs
completed
device_id
quality_level
TTL
Why DynamoDB
DynamoDB because the recommendation service and 'My Activity' page need globally-replicated, durable, queryable watch records with time-range access β Cassandra is fine for the write path but DynamoDB Global Tables give zero-ops multi-region replication the reco service depends on.
Access
β get watch history for reco model β PK=user_id, SK begins_with '2024-' (last 90 days)
β check if user watched title β PK=user_id, SK begins_with '{watched_at_prefix}#{content_id}'
β GDPR full export β PK=user_id, all records (paginated)
Anti-Patterns to Avoid
- βDon't use user_id alone as the PK β unbounded item collections for power users create hot partitions
- βDon't store raw video analytics here β use Kinesis Data Firehose β S3 β Athena for event-level telemetry
- βDon't use scan for 'recently watched' β always Query with a time-range SK condition to avoid full-partition reads
Interview Tip
The composite SK (watched_at#content_id) is the interviewer's litmus test. It lets you answer 'did user watch title X last week?' with a single Query (no FilterExpression) AND prevent duplicate entries for the same title on the same day β two requirements, one key design.
Scaling Note
At 10Γ scale: partition by (user_id, year) composite key exactly like the Cassandra table β prevents unbounded growth for power users who watch 10 titles/day Γ 10 years. Or use DynamoDB time-to-live to keep only 12 months and archive older records to S3.
Subscription & Payment
Payment data requires ACID transactions β the state machine transition (ACTIVE β PAST_DUE β CANCELLED) and the corresponding billing action must be atomic. PCI DSS compliance is simplified by Stripe handling raw card data; we only ever store Stripe tokens.
Schema
subscription_id
user_id
plan_type
status
stripe_sub_id
current_period_start
current_period_end
cancelled_at
created_at
Why Aurora
Aurora because subscription state transitions (ACTIVE β PAST_DUE β CANCELLED) must be atomic with the Stripe webhook update β a DynamoDB conditional write can't enforce the state machine invariants that billing correctness requires.
Access
β check subscription status for streaming access gate β PK=subscription_id OR by user_id
β update status on Stripe webhook β by stripe_sub_id (unique index)
β list all past_due for grace period job β status index
pm_id
user_id
stripe_pm_id
card_last4
card_brand
card_exp_month
card_exp_year
is_default
created_at
Why Aurora
Aurora because we need a partial unique index (only one is_default=true per user) β a constraint only expressible in a relational engine; the write frequency is low (one write per card add/remove) so Aurora throughput is not a bottleneck.
Access
β get default payment method for user β user_id + is_default=true
β list all payment methods for account page β user_id
Anti-Patterns to Avoid
- βNEVER store raw card numbers β only Stripe payment_method tokens
- βDon't manage subscription retries yourself β Stripe Smart Retries does this better
- βDon't skip idempotency keys on Stripe API calls β crash-and-retry will double-charge without them
Interview Tip
Lead with PCI DSS: 'We never store card numbers β only Stripe tokens, so our PCI scope is minimal.' Then walk the state machine.
Scaling Note
At 10Γ scale: sharding by user_id prefix becomes necessary if Aurora write throughput is the bottleneck. Subscriptions table grows linearly with users but write rate is low (one write per billing event per month).
Session & DRM License
Sessions split across two stores by access pattern: refresh tokens live in Redis for sub-millisecond lookup on every token refresh (happens every 15 min per user). DRM licenses and device sessions live in DynamoDB for durability β we need license records to survive Redis restarts and for audit/revocation purposes.
Schema
Key: refresh:{uuid}
Value: user_id
TTL: 2,592,000s
Why Redis
Redis because token validation happens on every API call (every 15 min per active session at scale) β sub-ms GET is mandatory; TTL is automatic and DEL gives instant revocation without a full-table scan.
Access
β GET refresh:{token} β user_id (token refresh, every 15min per active session)
β DEL refresh:{token} (logout, single device)
β KEYS refresh:{user_id}:* + DEL all (logout all devices β use scan in prod)
PK: user_id
SK: session_id
device_type
device_fingerprint
ip_address
last_active
created_at
TTL
Why DynamoDB
DynamoDB because session records must survive Redis restarts (durability) while still delivering single-digit ms for list-sessions-by-user β the 'manage devices' page in account settings.
Access
β list all sessions for user (account settings page) β PK=user_id
β delete specific session (remote logout) β PK=user_id, SK=session_id
PK: profile_id
SK: license_id
content_id
drm_system
device_fingerprint
issued_at
expires_at
TTL
Why DynamoDB
DynamoDB because license records need durable audit trail (revocation, GDPR) plus a built-in TTL to auto-expire 8 h/48 h licenses β no cron job required; access pattern is always by profile_id making PK design trivial.
Access
β check active license for device (playback validation) β PK=profile_id, filter content_id+device
β revoke all licenses for user (GDPR / account compromise) β scan PK=profile_id, delete all
Anti-Patterns to Avoid
- βDon't store refresh tokens only in Redis β Redis restarts would log out all users simultaneously
- βDon't store DRM license keys in the application database β use CloudHSM, keys never leave hardware
- βDon't use long DRM license TTLs β short TTLs bound the blast radius of a compromised device
Interview Tip
Explain WHY two stores: Redis for speed (token refresh critical path), DynamoDB for durability (license audit, device revocation). Single-store designs fail either speed or durability.
Scaling Note
At 10Γ scale: Redis cluster shards by user_id prefix. DynamoDB on-demand scales automatically. No schema changes needed.
Notification Preferences
Notification preferences are read on every notification send (low throughput, simple key-value lookup by user_id). DynamoDB's flexible schema means adding a new notification type (e.g. 'live_sports_alert') = new attribute, no ALTER TABLE, no migration, no downtime.
Schema
PK: user_id
email_new_episodes
email_billing
push_recommendations
push_continue_watching
sms_security_alerts
marketing_emails
updated_at
Why DynamoDB
DynamoDB because preferences are a simple key-value lookup by user_id and the schemaless model allows adding new notification types (e.g. live_sports_alert) as new attributes without ALTER TABLE or deployment downtime.
Access
β get preferences for user β PK=user_id (on each notification send)
β update preference (user settings page) β PK=user_id, UpdateItem specific attribute
PK: user_id
SK: {type}#{timestamp}
channel
template_id
status
sent_at
TTL
Why DynamoDB
DynamoDB because this is an append-only deduplication log; composite SK (type#timestamp) enables idempotency checks with a single range Query, and 90-day TTL auto-purges without a cron job.
Access
β deduplication check β query PK=user_id, SK begins_with 'billing#' in last 24h β prevents duplicate billing emails
β list recent notifications for user (UI) β PK=user_id, sort by SK DESC, limit 20
Anti-Patterns to Avoid
- βDon't store notification content here β only preferences (boolean flags)
- βDon't put notification log in the same table as preferences β different access patterns, different TTL needs
- βDon't use a relational schema here β adding a new notification type requires a column migration in SQL
Interview Tip
The key insight: DynamoDB's schemaless flexibility means new notification types ship without schema migrations. At Netflix's release cadence (200+ deploys/day), zero-migration schema changes are operationally valuable.
Scaling Note
At 10Γ scale: throughput scales automatically (on-demand). Consider write-through cache if pref reads become a hot path.