withsoon
N
Data Models8/14

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.

~7 min read
Aurora PostgreSQL

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

usersAurora
FieldTypeKeys

user_id

UUID
PK

email

VARCHAR(255)

password_hash

VARCHAR(255)

subscription_tier

VARCHAR(20)

plan_type

VARCHAR(20)

region

VARCHAR(50)

country_code

CHAR(2)

device_limit

SMALLINT

created_at

TIMESTAMP

updated_at

TIMESTAMP

deleted_at

TIMESTAMP

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)

profilesAurora
FieldTypeKeys

profile_id

UUID
PK

user_id

UUID

name

VARCHAR(50)

avatar_url

TEXT

is_kids

BOOLEAN

max_rating

VARCHAR(10)

language_pref

VARCHAR(10)

pin_hash

VARCHAR(255)

created_at

TIMESTAMP

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.

Storage estimate~500 GB at current scale. At $0.10/GB/month on Aurora = ~$50/month storage (trivial vs compute).
DynamoDB

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

contentDynamoDB
FieldTypeKeys

PK: content_id

String
PK

SK: 'METADATA'

String
PK

title

String

type

String

genres

StringSet

cast

List

director

String

release_year

Number

maturity_rating

String

thumbnail_url

String

available_regions

StringSet

duration_secs

Number

duration_minutes

Number

description

String

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'

episodesDynamoDB
FieldTypeKeys

PK: series_id

String
PK

SK: 'S{n}E{n}'

String
PK

episode_id

String

title

String

duration_minutes

Number

thumbnail_url

String

description

String

release_date

String

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.

Storage estimate~10 GB (1.7M items Γ— ~6KB average). Negligible β€” DynamoDB costs are dominated by read/write units, not storage.
Apache Cassandra

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

watch_progressCassandra
FieldTypeKeys

profile_id

UUID
PK

content_id

TEXT
PK

episode_id

TEXT

progress_sec

INT

updated_at

TIMESTAMP

completed

BOOLEAN

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

watch_historyCassandra
FieldTypeKeys

profile_id

UUID
PK

year

INT
PK

watched_at

TIMESTAMP
PK

content_id

TEXT

episode_id

TEXT

progress_sec

INT

duration_sec

INT

bitrate_kbps

INT

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).

Storage estimate~16 TB per region. At current S3/EBS pricing for Cassandra nodes (~$0.10/GB/month), ~$1,600/month per region for data storage. Cheap for the throughput it delivers.
Redis + DynamoDB

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

active_session (Redis)Redis
FieldTypeKeys

Key: session:{session_id}

String
PK

user_id

String

content_id

String

device_id

String

quality_level

String

position_ms

Number

started_at

String

TTL: 90s

TTL

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}

playback_sessionsDynamoDB
FieldTypeKeys

PK: session_id

String
PK

SK: 'SESSION'

String
PK

user_id

String
IDX

content_id

String

device_id

String

started_at

String

last_heartbeat

String

position_ms

Number

quality_level

String

status

String

TTL

Number

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.

Storage estimateRedis: ~15M sessions Γ— 400B = ~6 GB. DynamoDB: ~15M active records Γ— 1 KB = ~15 GB (mostly TTL'd within hours).
DynamoDB

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

watch_history (DynamoDB)DynamoDB
FieldTypeKeys

PK: user_id

String
PK

SK: watched_at#content_id

String
PK

content_id

String
IDX

episode_id

String

watched_at

String

progress_secs

Number

duration_secs

Number

completed

Boolean

device_id

String

quality_level

String

TTL

Number

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.

Storage estimate~60 GB (300M users Γ— avg 20 watched items Γ— ~10 KB per item). At $0.25/GB, ~$15/month β€” dwarfed by WCU/RCU cost.
Aurora PostgreSQL

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

subscriptionsAurora
FieldTypeKeys

subscription_id

UUID
PK

user_id

UUID

plan_type

VARCHAR(20)

status

VARCHAR(20)

stripe_sub_id

VARCHAR(100)

current_period_start

TIMESTAMP

current_period_end

TIMESTAMP

cancelled_at

TIMESTAMP

created_at

TIMESTAMP

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

payment_methodsAurora
FieldTypeKeys

pm_id

UUID
PK

user_id

UUID

stripe_pm_id

VARCHAR(100)

card_last4

CHAR(4)

card_brand

VARCHAR(20)

card_exp_month

INT

card_exp_year

INT

is_default

BOOLEAN

created_at

TIMESTAMP

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).

Storage estimateMinimal β€” billing events are low frequency. ~50GB even at Netflix scale.
Redis + DynamoDB

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

refresh_tokens (Redis)Redis
FieldTypeKeys

Key: refresh:{uuid}

String
PK

Value: user_id

String

TTL: 2,592,000s

TTL

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)

sessionsDynamoDB
FieldTypeKeys

PK: user_id

String
PK

SK: session_id

String
PK

device_type

String

device_fingerprint

String

ip_address

String

last_active

String

created_at

String

TTL

Number

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

drm_licensesDynamoDB
FieldTypeKeys

PK: profile_id

String
PK

SK: license_id

String
PK

content_id

String

drm_system

String

device_fingerprint

String

issued_at

String

expires_at

String

TTL

Number

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.

Storage estimateRedis: ~7.5 GB for 15M active sessions Γ— 500B. DynamoDB licenses: ~1GB for active licenses (mostly TTL'd).
DynamoDB

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

notification_preferencesDynamoDB
FieldTypeKeys

PK: user_id

String
PK

email_new_episodes

Boolean

email_billing

Boolean

push_recommendations

Boolean

push_continue_watching

Boolean

sms_security_alerts

Boolean

marketing_emails

Boolean

updated_at

String

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

notification_logDynamoDB
FieldTypeKeys

PK: user_id

String
PK

SK: {type}#{timestamp}

String
PK

channel

String

template_id

String

status

String

sent_at

String

TTL

Number

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.

Storage estimateNegligible β€” ~300M users Γ— ~500B per item = ~150GB. At DynamoDB storage pricing ($0.25/GB), ~$37/month.