Denormalization in Cassandra
Master the art of data duplication! Learn why Cassandra requires denormalization, how to design tables for specific queries, and avoid common anti-patterns with visual examples.
📖 How Instagram Handles 500+ Million Posts Daily
When you open Instagram and scroll your feed, you see posts from people you follow. Behind this simple interaction lies a critical design question: How do we efficiently serve your personalized feed?
❌ The Traditional SQL Approach (Doesn't Scale)
In a normalized SQL database, you might have:
-- SQL Tables (Normalized) Users Table: Posts Table: Follows Table: user_id | name post_id | user_id follower_id | following_id --------|------ --------|-------- ------------|------------- 123 | Alice 1 | 456 123 | 456 456 | Bob 2 | 789 123 | 789
To show Alice's feed, SQL must:
- JOIN Follows table to find who Alice follows (456, 789...)
- JOIN Posts table to get posts from those users
- Sort by timestamp, apply filters, paginate
- Result: Multiple tables scanned, slow for 500M+ posts!
✅ The Cassandra Approach (Denormalized)
Instead, Instagram stores a denormalized feed table per user:
-- Cassandra Table (Denormalized) user_feed Table: user_id | timestamp | post_id | author_id | author_name | photo_url | caption | likes --------|-----------|---------|-----------|-------------|-----------|---------|------ 123 | 2025-... | 1 | 456 | Bob | url1 | "..." | 1500 123 | 2025-... | 2 | 789 | Charlie | url2 | "..." | 2300
Now to show Alice's feed:
- ✅ Single query:
SELECT * FROM user_feed WHERE user_id = 123 LIMIT 50 - ✅ All data pre-computed and ready
- ✅ No JOINs, no sorting (already sorted by timestamp)
- ✅ Sub-millisecond response, scales to billions!
🎯 The Tradeoff
Yes, data is duplicated (Bob's post appears in every follower's feed).
But this duplication enables instant reads for 2+ billion users!
Write complexity → Read simplicity
📊 What is Denormalization?
Simple Definition
Denormalization is the intentional duplication of data across multiple tables to optimize for specific query patterns. Instead of storing data once and joining tables, you store complete query results in each table.
📘 Normalization (SQL)
Store data once, no duplication
- Goal: Eliminate redundancy
- Method: Split into many tables
- Reads: JOIN tables together
- Writes: Simple (single location)
- Best for: OLTP, complex queries
📗 Denormalization (Cassandra)
Store data many times, embrace duplication
- Goal: Optimize for fast reads
- Method: One table per query
- Reads: No JOINs (single table)
- Writes: Complex (multiple tables)
- Best for: High-scale reads, known queries
The Mindset Shift
From SQL thinking: "Store data once, query flexibly"
To Cassandra thinking: "Know your queries first, then duplicate data to serve each query optimally"
Storage is cheap, read latency is expensive! Cassandra trades disk space for speed.
🤔 Why Does Cassandra Require Denormalization?
Understanding the technical reasons behind denormalization.
No JOINs Allowed
Cassandra doesn't support JOINs between tables.
Why?
- Data distributed across nodes
- JOINs require gathering data from multiple nodes
- Network overhead kills performance
- Unpredictable latency at scale
Solution: Pre-compute joins via denormalization
Query-Driven Design
Tables are designed for specific queries
The Rule:
- Each query gets its own table
- Table structure matches query pattern
- Primary key enables efficient lookup
- All needed data in one row
Result: Every query = single partition read
Fast Read Performance
Reads must be lightning fast
How denormalization helps:
- Single partition read = O(1) time
- No disk seeks across tables
- Data co-located on same node
- Predictable sub-millisecond latency
Tradeoff: Slower writes, blazing reads
Horizontal Scalability
Enables linear scaling
Why it matters:
- Each query hits single partition
- Partitions distributed across nodes
- Add nodes = add capacity linearly
- No cross-node coordination
Benefit: Scale to billions of rows
The Cost of Denormalization
- More Storage: Same data stored multiple times (but storage is cheap)
- Write Complexity: Must update multiple tables for one logical change
- Eventual Consistency: Updates may not be immediately consistent across tables
- Data Integrity: Application must maintain consistency (no DB constraints)
Worth it? Yes! When you need to serve millions of reads per second.
👀 Visual Comparison: Normalized vs Denormalized
Let's see the difference with a real example: a music streaming app.
Performance Impact
SQL Approach (Normalized):
- Query time: 50-200ms (with indexes, on small data)
- Scales poorly: JOINs get slower as data grows
- Requires powerful single server or complex sharding
Cassandra Approach (Denormalized):
- Query time: 1-5ms (consistent, even at massive scale)
- Scales linearly: Add nodes → add capacity
- Runs on commodity hardware distributed globally
Result: 10-100x faster reads at web scale!
🐦 Real Data Example: Twitter Timeline
Let's see how denormalization works with actual data. Imagine you're building Twitter's timeline feature.
The Scenario
User @alice follows: @bob, @charlie, @david (3 people)
When Alice opens Twitter, she should see: All tweets from people she follows, sorted by time
❌ SQL Approach (Normalized)
Three separate tables, requires JOINs:
📋 users
| user_id | username |
|---|---|
| 1 | alice |
| 2 | bob |
| 3 | charlie |
| 4 | david |
👥 follows
| follower | following |
|---|---|
| 1 (alice) | 2 (bob) |
| 1 (alice) | 3 (charlie) |
| 1 (alice) | 4 (david) |
🐦 tweets
| tweet_id | user_id | text |
|---|---|---|
| 101 | 2 | Hello! |
| 102 | 3 | Great day |
| 103 | 4 | Learning |
-- ❌ SQL Query (Slow - requires 2 JOINs) SELECT t.tweet_id, u.username, t.text, t.created_at FROM tweets t JOIN follows f ON t.user_id = f.following_id JOIN users u ON t.user_id = u.user_id WHERE f.follower_id = 1 -- alice's ID ORDER BY t.created_at DESC LIMIT 50; -- Problem: Scans follows table (3 rows) + tweets table (millions!) + users table
✅ Cassandra Approach (Denormalized)
Single table with all data pre-computed:
📱 timeline_by_user (Alice's Timeline)
| user_id (PK) | created_at (CK) | tweet_id | author_id | author_name | text | likes |
|---|---|---|---|---|---|---|
| 1 | 2025-01-15 14:30 | 103 | 4 | david | Learning Cassandra! | 42 |
| 1 | 2025-01-15 12:15 | 102 | 3 | charlie | Great day today! | 128 |
| 1 | 2025-01-15 10:00 | 101 | 2 | bob | Hello world! | 256 |
✨ Notice: All data Alice needs is in ONE partition! Author names, likes, everything!
-- ✅ Cassandra Query (Fast - single partition read!) SELECT * FROM timeline_by_user WHERE user_id = 1 -- alice's ID LIMIT 50; -- Result: Instant! All data in one partition, pre-sorted by timestamp
📊 SQL Performance
- Query time: 50-500ms
- Tables scanned: 3
- Rows examined: 1000s-millions
- JOINs required: 2
- Scales: Poorly (gets slower)
⚡ Cassandra Performance
- Query time: 1-3ms
- Tables scanned: 1
- Rows examined: 50 (exactly what's needed)
- JOINs required: 0
- Scales: Linearly (stays fast)
"But how does the data get there?"
Great question! When Bob posts a tweet:
- Step 1: Store tweet in tweets_by_author table (Bob's tweets)
- Step 2: Look up Bob's followers (Alice, Emma, Frank...)
- Step 3: Write tweet to EACH follower's timeline table:
- INSERT INTO timeline_by_user (user_id=alice, ...) VALUES (...)
- INSERT INTO timeline_by_user (user_id=emma, ...) VALUES (...)
- INSERT INTO timeline_by_user (user_id=frank, ...) VALUES (...)
Result: Bob has 10,000 followers? Write tweet 10,000 times!
This is called "fan-out on write" - do the hard work ONCE so millions of reads are instant!
💡 Practical Example: E-commerce Order System
Let's design tables for an e-commerce system to see denormalization in action.
Step 1: Identify Query Patterns
Our Application Needs
- Q1: Get all orders for a specific user (user dashboard)
- Q2: Get order details by order ID (order confirmation page)
- Q3: Get all orders by status (admin panel - "show pending orders")
Step 2: Create One Table Per Query
Table 1: orders_by_user
Use Case: User dashboard - "Show me all MY orders"
📦 Sample Data
| user_id | order_date | order_id | total | status | products |
|---|---|---|---|---|---|
| john_123 | 2025-01-15 | ORD-789 | $299.99 | shipped | ['Laptop', 'Mouse'] |
| john_123 | 2025-01-10 | ORD-456 | $49.99 | delivered | ['Headphones'] |
| john_123 | 2025-01-05 | ORD-123 | $899.00 | delivered | ['iPhone', 'Case'] |
CREATE TABLE orders_by_user (
user_id UUID,
order_date TIMESTAMP,
order_id UUID,
total_amount DECIMAL,
status TEXT,
shipping_address TEXT,
-- Duplicated product info
product_names LIST<TEXT>,
product_quantities LIST<INT>,
PRIMARY KEY (user_id, order_date, order_id)
) WITH CLUSTERING ORDER BY (order_date DESC);
-- Query: Get John's last 10 orders
SELECT * FROM orders_by_user
WHERE user_id = 'john_123'
LIMIT 10;
-- Returns: All 3 orders instantly! Already sorted by date!
Table 2: orders_by_id
Use Case: Order details page - "Show me order ORD-789"
📋 Sample Data (Single Order View)
| order_id | user_id | user_name | user_email | total | status | products |
|---|---|---|---|---|---|---|
| ORD-789 | john_123 | John Doe | john@email.com | $299.99 | shipped | ['Laptop'=$279, 'Mouse'=$20] |
✨ Notice: User info (name, email) is DUPLICATED here! No need to look up user table.
CREATE TABLE orders_by_id (
order_id UUID,
user_id UUID,
order_date TIMESTAMP,
total_amount DECIMAL,
status TEXT,
shipping_address TEXT,
-- Duplicated user info (no lookup needed!)
user_name TEXT,
user_email TEXT,
-- Duplicated product info
product_names LIST<TEXT>,
product_quantities LIST<INT>,
product_prices LIST<DECIMAL>,
PRIMARY KEY (order_id)
);
-- Query: Get complete order details
SELECT * FROM orders_by_id
WHERE order_id = 'ORD-789';
-- Returns: Everything in ONE read! User name, email, products, prices!
Table 3: orders_by_status
Use Case: Admin panel - "Show me all PENDING orders"
🔧 Sample Data (Admin View)
| status | order_date | order_id | user_id | user_name | total |
|---|---|---|---|---|---|
| pending | 2025-01-15 14:30 | ORD-999 | sarah_456 | Sarah Smith | $599.00 |
| pending | 2025-01-15 12:00 | ORD-888 | mike_789 | Mike Johnson | $149.99 |
| pending | 2025-01-15 09:30 | ORD-777 | emma_321 | Emma Wilson | $89.99 |
⚠️ Same order data appears in ALL 3 tables! That's denormalization!
CREATE TABLE orders_by_status (
status TEXT,
order_date TIMESTAMP,
order_id UUID,
user_id UUID,
total_amount DECIMAL,
-- Duplicated data again!
user_name TEXT,
shipping_address TEXT,
PRIMARY KEY (status, order_date, order_id)
) WITH CLUSTERING ORDER BY (order_date DESC);
-- Query: Get all pending orders
SELECT * FROM orders_by_status
WHERE status = 'pending';
-- Returns: All 3 pending orders sorted by date! Admin can process them.
Notice the Duplication?
The SAME order data appears in 3 different tables!
- orders_by_user: Stores order for user's dashboard
- orders_by_id: Stores order for order details page
- orders_by_status: Stores order for admin filtering
When an order is created: Write to all 3 tables!
When an order is updated: Update all 3 tables!
This is intentional! Each query reads from exactly ONE table.
🔄 How Writes Work Across All Tables
When Sarah places an order for $599, here's what happens:
✅ Read Benefits
- User dashboard: 1 query to orders_by_user
- Order details: 1 query to orders_by_id
- Admin panel: 1 query to orders_by_status
- Result: All queries ⚡ instant!
⚠️ Write Cost
- 3 tables to update
- 3x write load on database
- Consistency must be maintained
- Tradeoff: Worth it for fast reads!
🎵 Another Real Example: Spotify Playlists
How does Spotify show "Songs in a Playlist" AND "Playlists containing a Song"?
📱 songs_by_playlist
Query: "Show songs in 'Workout Mix'"
| playlist_id | song |
|---|---|
| workout_mix | Eye of Tiger |
| workout_mix | Lose Yourself |
| workout_mix | Stronger |
🎵 playlists_by_song
Query: "Which playlists have 'Stronger'?"
| song_id | playlist |
|---|---|
| stronger | Workout Mix |
| stronger | Party Hits |
| stronger | 2000s Classics |
The Pattern
Many-to-many relationship? Create 2 tables!
- Table 1: songs_by_playlist (partition key: playlist_id)
- Table 2: playlists_by_song (partition key: song_id)
- Both directions of the relationship get their own table!
- When you add a song to a playlist → write to BOTH tables
📈 Real-World Performance Metrics
Let's look at actual numbers from production systems to understand the impact.
🏢 Company: Netflix
| Users: | 260+ million |
| Read latency: | < 5ms (p99) |
| Tables per query: | 1 table |
| Duplication factor: | 3-5x |
| Storage cost: | < 1% of infrastructure |
🛒 Company: Uber Eats
| Orders per day: | 6+ million |
| Read latency: | < 10ms |
| Write amplification: | 4-6x |
| Tables per entity: | 3-4 tables |
| Availability: | 99.99% |
Comparison: Before vs After Denormalization
A real e-commerce company's migration story:
| Metric | Before (SQL + JOINs) | After (Cassandra Denormalized) | Improvement |
|---|---|---|---|
| User Dashboard Load | 250ms average | 8ms average | 31x faster ⚡ |
| Order Details Page | 180ms average | 5ms average | 36x faster ⚡ |
| Peak Hour Throughput | 5,000 req/sec | 50,000 req/sec | 10x capacity 📈 |
| Database Servers | 12 powerful servers | 15 commodity nodes | 60% cost reduction 💰 |
| Storage Used | 2 TB | 6 TB (3x duplication) | 3x more storage 📦 |
| Write Latency | 5ms | 12ms (3 tables) | 2.4x slower writes ⏱️ |
| Customer Complaints | 450/month (slow pages) | 12/month | 97% reduction 😊 |
30-40x
Faster read queries
10x
Higher throughput capacity
3-5x
More storage needed
The Math: Is Denormalization Worth It?
Let's calculate the total cost for a mid-sized app (1M users, 5M requests/day):
Option 1: SQL with JOINs (Normalized)
- Storage: 500GB * $0.10/GB = $50/month
- Compute (powerful servers): $2,000/month
- Slow queries → Need caching layer: $500/month
- Engineering time fixing performance: $5,000/month
- Total: ~$7,550/month
Option 2: Cassandra Denormalized
- Storage: 1.5TB (3x duplication) * $0.10/GB = $150/month
- Compute (commodity nodes): $1,200/month
- No caching needed: $0/month
- Minimal performance tuning: $500/month
- Total: ~$1,850/month
💰 Result: $5,700/month savings + 30x faster performance!
The "expensive" 3x storage costs $100 extra, but saves $5,800 in other costs!
✅ Denormalization Best Practices
Follow these guidelines to denormalize effectively.
Know Your Queries First
Start with application requirements
Process:
- List all queries your app needs
- Define access patterns
- Understand filtering requirements
- Then design tables
❌ Don't: Design tables first like SQL
One Table Per Query Pattern
Each unique query gets its own table
Example:
- "User's orders" → orders_by_user
- "Order by ID" → orders_by_id
- "Pending orders" → orders_by_status
✅ Result: Every query = single partition read
Store Complete Data in Each Row
Include all data needed for the query
Don't require:
- Secondary lookups
- Application-side joins
- Additional queries
✅ One query should return everything needed
Use Batches for Consistency
Write to multiple tables atomically
BEGIN BATCH INSERT INTO orders_by_user ... INSERT INTO orders_by_id ... INSERT INTO orders_by_status ... APPLY BATCH;
✅ All-or-nothing consistency
Accept Eventual Consistency
Different tables may be briefly out of sync
Why:
- Distributed system realities
- Network delays between writes
- Cassandra's AP design (CAP)
✅ Usually consistent within milliseconds
Avoid Unbounded Growth
Don't let partitions grow forever
Solution:
- Time-bucket data (daily/monthly)
- Set TTLs for old data
- Archive to cold storage
⚠️ Partitions > 100MB slow down
❌ Common Denormalization Anti-Patterns
Mistakes to avoid when denormalizing data.
Anti-Pattern #1: Normalizing in Cassandra
Mistake: Trying to normalize data like you would in SQL
-- ❌ BAD: Normalized structure requiring "joins" CREATE TABLE users (user_id UUID PRIMARY KEY, name TEXT); CREATE TABLE orders (order_id UUID PRIMARY KEY, user_id UUID); -- Application must do 2 queries: 1. SELECT * FROM orders WHERE order_id = X; 2. SELECT * FROM users WHERE user_id = Y; -- lookup from step 1
Problem:
- Requires multiple queries
- Application-side "joins"
- Defeats Cassandra's strengths
✅ Fix: Duplicate user data in orders table
Anti-Pattern #2: One Giant Table
Mistake: Putting all data in one massive table and trying to query it different ways
-- ❌ BAD: Single table for all queries
CREATE TABLE everything (
user_id UUID,
order_id UUID,
product_id UUID,
...
PRIMARY KEY (user_id, order_id)
);
-- Can query by user_id, but NOT by order_id alone!
SELECT * FROM everything WHERE order_id = X; -- ❌ Requires ALLOW FILTERING
✅ Fix: Create separate tables for different query patterns
Anti-Pattern #3: Not Writing to All Tables
Mistake: Forgetting to update all denormalized copies when data changes
Example:
- Update order status in orders_by_id
- Forget to update orders_by_user
- Now data is inconsistent!
✅ Fix: Use batches, or application logic to update all tables
Anti-Pattern #4: Using ALLOW FILTERING
Mistake: Relying on ALLOW FILTERING for queries
-- ❌ BAD: Filtering on non-primary-key column SELECT * FROM orders_by_user WHERE status = 'pending' ALLOW FILTERING; -- ❌ Scans all partitions!
Problem:
- Scans entire table (slow!)
- Kills performance at scale
- Sign of wrong data model
✅ Fix: Create orders_by_status table with status as partition key
Anti-Pattern #5: Unbounded Partition Growth
Mistake: Letting a single partition grow without limits
-- ❌ BAD: User's ALL orders in one partition forever
CREATE TABLE orders_by_user (
user_id UUID,
order_date TIMESTAMP,
...
PRIMARY KEY (user_id, order_date)
);
-- After 10 years, user has 10,000 orders in one partition!
-- Partition size > 100MB → performance degradation
✅ Fix: Time-bucket partitions
-- ✅ GOOD: Partition per month
CREATE TABLE orders_by_user (
user_id UUID,
month TEXT, -- "2025-01"
order_date TIMESTAMP,
...
PRIMARY KEY ((user_id, month), order_date)
);
💼 Top 15 Interview Questions - Denormalization
Master these questions to demonstrate expert-level understanding of Cassandra data modeling!
Answer:
Denormalization is the intentional duplication of data across multiple tables to optimize read performance by avoiding JOINs.
Why Required in Cassandra:
- No JOINs Support: Cassandra doesn't support JOINs between tables - data would need to be gathered from multiple nodes
- Query-Driven Design: Each table is designed for a specific query pattern
- Fast Reads: All data needed for a query is in one partition - single read operation
- Horizontal Scalability: Each query hits one partition on one node - scales linearly
- Distributed Architecture: JOINs across distributed nodes would kill performance
Tradeoff: Accept write complexity and data duplication to achieve blazing-fast reads at web scale.
Answer:
| Aspect | Normalization (SQL) | Denormalization (Cassandra) |
|---|---|---|
| Data Storage | Store data once, no duplication | Store data multiple times intentionally |
| Table Count | Many normalized tables | One table per query pattern |
| Reads | Use JOINs across tables | Single table, no JOINs |
| Writes | Simple, one location | Complex, multiple tables |
| Design Process | Model entities and relationships | Model queries and access patterns |
| Goal | Eliminate redundancy | Optimize read performance |
Key Insight: SQL trades read complexity for write simplicity. Cassandra trades write complexity for read simplicity.
Answer:
Query-driven data modeling means designing your database schema based on how you plan to query the data, not on the structure of the entities themselves.
The Process:
- Step 1: List all queries your application needs to perform
- Step 2: For each query, design a table optimized for that specific access pattern
- Step 3: Structure the primary key to enable efficient lookup
- Step 4: Include all necessary data in each row (denormalize)
Example:
- Query: "Get user's orders" → orders_by_user table with user_id as partition key
- Query: "Get order by ID" → orders_by_id table with order_id as partition key
- Query: "Get pending orders" → orders_by_status table with status as partition key
Rule: If you can't answer "What queries will use this table?" you're doing it wrong!
Answer:
Use BATCH statements to write to multiple tables atomically:
BEGIN BATCH INSERT INTO orders_by_user (user_id, order_id, ...) VALUES (...); INSERT INTO orders_by_id (order_id, user_id, ...) VALUES (...); INSERT INTO orders_by_status (status, order_id, ...) VALUES (...); APPLY BATCH;
How Batches Help:
- Atomicity: All writes succeed or all fail (within same partition)
- Isolation: Batch appears as single operation
- Timestamp Consistency: All writes get same timestamp
Important Caveats:
- Batches are NOT transactions (no rollback)
- Only use for writes to same partition or denormalized data
- Eventual consistency still applies across replicas
- Application must handle batch failures and retries
Alternative: Use application logic with retry mechanisms to ensure all tables updated.
Answer:
Main Downsides:
- Increased Storage: Same data stored multiple times - requires more disk space
- Write Complexity: Single logical change requires updating multiple tables
- Write Amplification: One user action = multiple database writes (performance cost)
- Consistency Challenges: Keeping duplicate data in sync is application's responsibility
- Eventual Consistency: Different tables may temporarily show different values
- No Referential Integrity: Database doesn't enforce consistency - application must
- Schema Changes: Updating structure requires changing multiple tables
- Data Anomalies: Risk of insert/update/delete anomalies if not handled carefully
When It's Worth It:
- Read-heavy workloads (90%+ reads)
- Need for consistent low latency at massive scale
- Known query patterns (not ad-hoc)
- Storage cost acceptable tradeoff for performance
Answer:
Decision Framework:
- Rule 1: Denormalize data needed together
- If query needs user name + order details, store both in orders table
- Avoid requiring secondary lookups
- Rule 2: Consider query frequency
- Frequently accessed data → definitely denormalize
- Rarely accessed → may not be worth duplication
- Rule 3: Analyze update frequency
- Rarely changing data (product categories) → safe to denormalize
- Frequently changing data (real-time prices) → be cautious
- Rule 4: Consider data size
- Small data (names, IDs) → duplicate freely
- Large data (images, files) → store references only
Example Decision:
Order table includes user_name (small, rarely changes, needed for display) but NOT user_profile_picture (large, store URL reference instead).
Answer:
"One table per query" means creating a separate table optimized for each distinct access pattern in your application.
The Principle:
- Each query pattern gets its own table
- Table's primary key matches the query's WHERE clause
- Table contains all data needed by that query
- Query can be satisfied with single partition read
Example - User Activity System:
- Query 1: "Get activities by user" → activities_by_user (PK: user_id)
- Query 2: "Get activities by type" → activities_by_type (PK: activity_type)
- Query 3: "Get today's activities" → activities_by_date (PK: date)
Why It Works:
- Each table optimized for its specific use case
- No need to compromise primary key for multiple access patterns
- Guaranteed fast reads for all queries
- Clear mapping: query → table
Cost: More tables, more writes, more storage. But reads are always fast!
Answer:
Scenario: Real-time stock trading platform
Problem:
- Stock prices change multiple times per second
- Price stored in: user_portfolios, order_history, price_alerts, analytics_table
- One price update → must update 4+ tables immediately
- Write amplification = 4x or more
- Risk of inconsistent prices across tables
Better Approach:
- Don't denormalize: Store only stock_id in other tables
- Separate price table: current_stock_prices with real-time updates
- Application joins: Fetch price separately when displaying
- Cache: Use Redis/Memcached for frequently accessed prices
Other "Don't Denormalize" Cases:
- Real-time sensor data (temperature, location)
- Live sports scores
- Inventory counts with high turnover
- Currency exchange rates
Rule: If data changes more frequently than it's read, reconsider denormalization!
Answer:
Write amplification occurs when a single logical update requires multiple physical writes to the database.
How Denormalization Causes It:
- Same data duplicated across multiple tables
- Changing one piece of data requires updating all copies
- 1 logical write → N physical writes (N = number of tables)
Example:
User changes their name: 1 logical update → must write to: - users_by_id - users_by_email - orders_by_user (all user's orders) - comments_by_user (all user's comments) - reviews_by_user (all user's reviews) Result: 1 name change = 100+ physical writes!
Impact:
- More I/O: Higher disk write load
- Higher Latency: Writes take longer
- Network Traffic: More data transferred to replicas
- Cost: More cloud storage I/O charges
Mitigation Strategies:
- Only denormalize rarely-changing data
- Use batches to write to multiple tables atomically
- Consider eventual consistency for non-critical updates
- Accept the tradeoff for read-heavy workloads
Answer:
Challenge: When data is duplicated, deletions must be propagated to all copies.
Strategies:
- Option 1: Batch Deletes
BEGIN BATCH DELETE FROM orders_by_user WHERE user_id = X AND order_id = Y; DELETE FROM orders_by_id WHERE order_id = Y; DELETE FROM orders_by_status WHERE status = 'pending' AND order_id = Y; APPLY BATCH;
- Option 2: Soft Deletes (Recommended)
- Add `deleted BOOLEAN` column to all tables
- SET deleted = true instead of DELETE
- Filter deleted rows in application
- Periodically clean up with TTL or batch job
- Option 3: TTL-Based Cleanup
- Set TTL when inserting data
- Data auto-expires after time period
- Good for time-series or temporary data
Best Practice: Use soft deletes for most cases - easier to handle, recoverable, and avoids deletion anomalies.
Answer:
Already covered in Question 7 - see above for complete answer about "One Table Per Query" principle.
Answer:
Increased Storage Requirements:
- Data Duplication: Same data stored N times (N = number of tables)
- Multiplication Factor: 3 tables with duplicated data = 3x storage
- Replication Factor: With RF=3, actual storage = 3 * N * data_size
Example Calculation:
Normalized SQL: 100GB data * RF=3 = 300GB total storage Denormalized (3 tables): 100GB * 3 tables * RF=3 = 900GB total storage Result: 3x more storage required!
Cost-Benefit Analysis:
- Storage Cost: ~$0.02-0.10/GB/month (cheap!)
- Compute Cost: Servers to handle slow queries (expensive!)
- Latency Cost: User drop-off from slow loads (very expensive!)
Reality Check:
- 900GB cloud storage = $9-90/month
- Additional servers for slow queries = $500-5000/month
- Lost revenue from poor performance = $$$$
Conclusion: Storage cost increase is trivial compared to performance gains. Modern storage is cheap!
Answer:
| Aspect | Manual Denormalization | Materialized Views |
|---|---|---|
| Definition | Application creates and maintains multiple tables | Cassandra automatically maintains derived tables |
| Control | Full control over schema and updates | Limited control, schema auto-derived |
| Writes | Application handles batch writes | Cassandra handles automatically |
| Consistency | Application responsible | Eventually consistent (guaranteed) |
| Flexibility | Can denormalize differently per table | Must match base table columns |
| Performance | Optimized as needed | Some overhead for view maintenance |
When to Use Each:
- Manual Denormalization: Complex business logic, need full control, different data in each table
- Materialized Views: Simple alternate primary key on same data, reduce boilerplate code
Best Practice: Start with materialized views for simplicity, switch to manual if you need more control.
Answer:
Challenge: In SQL, many-to-many requires junction table. In Cassandra, we denormalize based on query patterns.
Example: Students ↔ Courses (many students take many courses)
SQL Approach (3 tables):
students: student_id, name courses: course_id, title enrollments: student_id, course_id -- junction table
Cassandra Approach (2+ tables based on queries):
- Query 1: "Get courses for student X"
- Query 2: "Get students in course Y"
CREATE TABLE courses_by_student (
student_id UUID,
course_id UUID,
-- Denormalized course data
course_title TEXT,
course_instructor TEXT,
enrollment_date TIMESTAMP,
PRIMARY KEY (student_id, course_id)
);
CREATE TABLE students_by_course (
course_id UUID,
student_id UUID,
-- Denormalized student data
student_name TEXT,
student_email TEXT,
enrollment_date TIMESTAMP,
PRIMARY KEY (course_id, student_id)
);
Writing:
BEGIN BATCH INSERT INTO courses_by_student (...) VALUES (...); INSERT INTO students_by_course (...) VALUES (...); APPLY BATCH;
Key Point: Each direction of the relationship gets its own table!
Answer:
Strategies to Reduce Overhead:
- 1. Denormalize Selectively
- Only duplicate data that's actually needed together
- Don't denormalize "just in case"
- Analyze which fields each query uses
- 2. Prefer Immutable Data
- Denormalize data that rarely changes (categories, types)
- Avoid denormalizing frequently updated data (prices, counts)
- 3. Use Batches Efficiently
- Batch writes to multiple tables together
- Reduces network roundtrips
- Ensures atomic application of changes
- 4. Consider Materialized Views
- Let Cassandra maintain simple denormalizations
- Reduces application code complexity
- Automatic consistency management
- 5. Asynchronous Updates
- For non-critical data, update asynchronously
- Use message queues for eventual consistency
- Improves write latency
- 6. TTL-Based Cleanup
- Set TTL on denormalized data that expires
- Automatic deletion reduces maintenance
- 7. Limit Table Count
- Don't create table for every possible query
- Focus on high-frequency queries
- Accept slower performance for rare queries
Golden Rule: Denormalization is a tradeoff. Optimize for your specific use case, not theoretical perfection!
Responsive Ad