QUERY-DRIVEN DESIGN - Model for Your Queries
🎯 Master Cassandra's fundamental design principle with interactive examples, real patterns, and beginner-friendly explanations!
📖 Fundamentals (Start Here!)
Understanding ALL basic concepts before diving into Query-Driven Design! We're starting from absolute zero - no prior knowledge needed.
What is a Query?
Query = Question you ask the database
Think of database like a librarian:
• You ask: "Find me books by Shakespeare"
• Librarian searches and returns books
• That question = Query!
Database examples:
• "Show me user with ID 123"
• "Get all orders from yesterday"
• "Find posts by user Alice"
In code (CQL):
SELECT * FROM users WHERE user_id = 123;
Every time you fetch data = Query!
Data Modeling
Data Modeling = How you organize information
Real-world analogy - Organizing clothes:
Option A: By type
• All shirts together
• All pants together
• All shoes together
Option B: By occasion
• Work outfits (shirt+pants+shoes)
• Gym outfits
• Casual outfits
In database:
Option A = Traditional (normalized)
Option B = Query-driven!
Data modeling = your organization strategy!
Partition Key
Partition Key = Address where data lives
Perfect analogy - Apartment building:
Cassandra cluster = City with many buildings
• Each building = 1 server/node
• Building address = Partition key
• All roommates = Data with same key
Example:
Partition key: user_id
user_id = 123 → Building A (Node 1)
user_id = 456 → Building B (Node 2)
user_id = 789 → Building C (Node 3)
Critical rule:
All data for same partition key
lives on SAME node! ✓
This enables O(1) lookups!
Clustering Key
Clustering Key = Sorting ORDER within partition
Continuing apartment analogy:
• Building address = Partition key
• Apartment numbers = Clustering key
• Numbers sorted: 101, 102, 103...
Example table:
PRIMARY KEY (user_id, timestamp)
• user_id = Partition key (which building)
• timestamp = Clustering key (which apartment)
Data for user_id=123:
• Event at 09:00 (apartment 101)
• Event at 09:15 (apartment 102)
• Event at 09:30 (apartment 103)
Pre-sorted by time! ⚡
Clustering key = automatic sorting!
Normalization vs Denormalization
Two opposite philosophies!
📚 Normalization (Traditional SQL):
• Store each fact ONCE
• Join tables when querying
• No duplication allowed
• Saves storage ✓
• Complex queries ✗
Example:
Table 1: Users (id, name)
Table 2: Orders (id, user_id, total)
Query: JOIN to get user + orders
📦 Denormalization (Cassandra):
• Store data MULTIPLE times
• Each table self-contained
• Duplicates encouraged! ✓
• Uses more storage ✗
• Simple, fast queries ✓
Example:
Table: user_orders (has BOTH user AND order data)
Query: Single table, no JOIN!
Cassandra philosophy:
"Disk is cheap, JOINs are expensive!"
Performance & Complexity
Understanding Big O notation (simplified!)
O(1) - Constant time (BEST!):
• 1 row or 1 billion rows
• Takes SAME time always!
• Example: Hash table lookup
• Cassandra goal: ~1-5ms ✓
O(n) - Linear time (OK):
• More data = more time
• 2x data = 2x time
• Example: Scanning a list
O(n²) - Quadratic time (BAD!):
• 2x data = 4x time
• 10x data = 100x time!
• Example: Nested loops
Cassandra optimization:
Design for O(1) reads!
Know partition key → Direct lookup
Predictable, fast, always! 🚀
Distributed Systems
Distributed = Data spread across many computers
Analogy: Library system
• 1 library (single server) - Simple!
• 100 libraries (distributed) - Complex!
Single server (easy):
• All data in one place
• Can JOIN tables easily
• But: Limited capacity ✗
Distributed (Cassandra):
• Data across 100s of nodes
• Each node has subset
• Can't JOIN across nodes! ✗
• But: Unlimited capacity! ✓
The fundamental trade-off:
Scalability vs Query flexibility
Cassandra chooses scalability!
Replication
Replication = Making copies for safety
Analogy: Important documents
• Original at home
• Copy in safe deposit box
• Scan on cloud backup
• 3 copies! (RF=3)
In Cassandra:
Replication Factor (RF) = 3
Every piece of data → 3 nodes
Example:
user_id=123 stored on:
• Node A (primary)
• Node B (replica 1)
• Node C (replica 2)
Benefits:
• Node fails? 2 backups! ✓
• Read from any copy
• High availability!
Schema
Schema = Blueprint for your data
Analogy: House blueprint
• Defines room layout
• Specifies room sizes
• Must follow plan!
Database schema:
• Defines table structure
• Specifies column types
• Must match plan!
Example schema:
CREATE TABLE users (
user_id UUID PRIMARY KEY,
name TEXT,
email TEXT
);
Schema = contract for data structure!
🎓 Quick Reference Summary
Congratulations! You now understand:
• Query: Question you ask database (SELECT statement)
• Data Modeling: How you organize data in tables
• Partition Key: Address determining data location (which node)
• Clustering Key: Sorting order within partition
• Normalization: Store once, JOIN to query (traditional)
• Denormalization: Duplicate data for fast queries (Cassandra)
• Performance: O(1) = constant time = always fast!
• Distributed: Data across many servers = scalability
• Replication: Multiple copies (RF=3) = safety
• Schema: Blueprint defining table structure
🎯 You're ready to learn Query-Driven Design!
🤔 What is Query-Driven Design?
🏠 The Perfect House Building Analogy
Imagine you're building a house. There are TWO fundamentally different approaches:
❌ APPROACH #1: Build First, Ask Later (WRONG!)
The traditional way:
1. Architect says: "I'll design you a beautiful house!"
2. Builds standard layout:
• 3 bedrooms
• 2 bathrooms
• Living room
• Kitchen
• Garage
3. After 6 months of construction...
4. "By the way, what will you use this house for?"
You answer: "I'm opening a restaurant!"
😱 DISASTER! The house is completely wrong!
Problems discovered:
• Bedrooms? Don't need them! (Restaurant, not residence)
• Kitchen too small! (Need industrial kitchen for 100 meals/day)
• No dining area! (Living room seats 6, need space for 50 tables)
• No storage! (Need walk-in freezer, dry storage, wine cellar)
• No separate entrance! (Need delivery door for suppliers)
• Wrong bathrooms! (Need ADA-compliant customer bathrooms)
• Garage useless! (Need commercial kitchen hood system instead)
Cost to fix:
• Tear down walls: $50,000
• Install commercial kitchen: $200,000
• Reconfigure plumbing: $30,000
• Add ventilation: $40,000
• Total renovation: $320,000!!! 💰💰💰
• Plus 6 more months delay!
🎯 This is EXACTLY what happens with traditional database design!
✅ APPROACH #2: Ask First, Build for Purpose (CORRECT!)
The query-driven way:
1. Architect asks: "What will you use this for?"
2. You answer: "Restaurant for 50 people!"
3. Architect designs SPECIFICALLY for restaurant:
Perfect restaurant design:
• Commercial kitchen:
- Industrial stoves (8 burners) ✓
- Double convection ovens ✓
- Commercial dishwasher ✓
- Prep stations (4 cooks working simultaneously) ✓
- 3-compartment sink (health code requirement) ✓
• Dining area:
- Open floor plan for 50 tables ✓
- Efficient server pathways ✓
- Host station at entrance ✓
- Bar area for waiting guests ✓
• Storage:
- Walk-in freezer (20'x15') ✓
- Walk-in refrigerator (20'x15') ✓
- Dry storage room ✓
- Wine cellar (temperature controlled) ✓
• Infrastructure:
- Delivery entrance (separate from customers) ✓
- Customer restrooms (ADA compliant) ✓
- Staff restroom (separate) ✓
- Office for management ✓
- Heavy-duty ventilation system ✓
- Commercial-grade utilities ✓
Result: Perfect fit from day one!
• No renovations needed
• Opens on schedule
• Efficient operations
• Happy customers & staff!
🎯 This is EXACTLY Query-Driven Design in Cassandra!
In Database Terms:
❌ Traditional Database Approach (SQL):
1. Design "perfect" normalized data model
2. Create tables following normalization rules
3. Eliminate all duplication
4. "This is how databases should be designed!"
5. THEN try to run queries...
Problems discovered:
• Query needs 5 table JOINs! (Too slow!)
• Data scattered across many tables
• Complex queries taking 500ms+
• Can't scale to billions of rows
• Must completely redesign! 😫
✅ Query-Driven Approach (Cassandra):
1. FIRST: List ALL queries your application needs
Example queries:
• "Get user by ID"
• "Get user by email"
• "Get all orders for user"
• "Get recent posts by user"
• "Get posts from last 24 hours"
2. THEN: Design ONE table for EACH query
Example tables:
•
users_by_id (PK: user_id)•
users_by_email (PK: email)•
orders_by_user (PK: user_id, CK: timestamp)•
posts_by_user (PK: user_id, CK: post_time)•
posts_by_time (PK: day_bucket, CK: post_time)3. RESULT: Every single query is FAST! ⚡
• No JOINs needed
• Direct partition lookups
• O(1) performance
• 1-5ms response times
• Scales to trillions of rows! 🚀
💡 The Golden Rule:
"Know your QUERIES first, design your TABLES second!"
Key Insight:
• Traditional: Design for data beauty → hope queries work
• Query-Driven: Design for query needs → guaranteed performance!
The fundamental philosophy:
"We're not storing data for art. We're storing it to be QUERIED. So design for the queries!"
Formal Definition
Query-Driven Design =
A data modeling methodology where table structure is determined by query patterns rather than entity relationships.
In simple terms:
Design tables based on HOW you'll query them, not WHAT entities they represent.
Process:
1. List all queries needed (complete list!)
2. Design one table per query pattern
3. Optimize each table for its query
4. Accept data duplication as strategy
5. Prioritize read speed over storage
Core principle:
"One table per query pattern" 🎯
Core Principle
"One table per query pattern"
Need 3 different queries?
Create 3 different tables!
Real example:
Entity: User
Queries needed:
1. Get user by ID
2. Get user by email
3. Get users by country
Traditional SQL:
1 table: users
Add indexes on email, country
Cassandra:
3 tables:
• users_by_id
• users_by_email
• users_by_country
Same data, 3 tables!
Each optimized for its query ✓
Duplication is not a bug, it's the strategy!
Ultimate Goal
Achieve O(1) read performance for all queries
What's O(1)?
Constant time - always the same speed!
Examples:
• 1 row in table: 2ms
• 1 million rows: 2ms
• 1 billion rows: 2ms
• 1 trillion rows: 2ms ✓
How to achieve:
1. Know partition key in query
2. Direct node lookup (hash function)
3. No table scanning needed
4. Consistent performance!
Compare to O(n):
• 1 million rows: 100ms
• 1 billion rows: 100,000ms! 💥
• Unacceptable!
Query-driven design =
Predictable, fast, always! 🚀
❓ Why is Query-Driven Design Needed?
The Distributed Systems Problem
Cassandra is distributed - data spreads across multiple servers. This creates fundamental limitations:
❌ NO JOINS: Can't join tables across different servers (too slow!)
❌ NO FULL TABLE SCANS: Scanning billions of rows across cluster = disaster
❌ NO ARBITRARY QUERIES: Must know partition key to find data
✅ Solution: Design each table for specific query pattern!
If you need 5 ways to query data, create 5 tables. Each optimized for fast, direct lookup.
🎮 Interactive Query Builder
Select Your Query Pattern:
Select a query pattern above to see the table design!
📐 Common Design Patterns
One Table Per Query
Pattern: Duplicate data for different access patterns
Query 1: users_by_id (PK: user_id)
Query 2: users_by_email (PK: email)
Query 3: users_by_country (PK: country, CK: user_id)
Same user data, 3 different tables!
Time-Series Pattern
Pattern: Partition by user/entity, cluster by time
Table: user_activity
PK: user_id
CK: timestamp DESC
Fast queries: "Get recent activity for user"
Bucketing Pattern
Pattern: Split large partitions into buckets
Table: events_by_day
PK: (user_id, day_bucket)
CK: event_time
Prevents hot partitions!
🏢 Real Company Examples
Netflix: 5 Tables for User Profile
Queries needed:
1. Get profile by user_id
2. Get profile by email
3. Get profiles by country
4. Get viewing history by user
5. Get recommendations by user
Solution: 5 different tables!
Each optimized for its specific query. Result: <5ms reads across 200M+ users.
✅ Best Practices
1. List Queries First
Before any design, write down ALL queries your app needs. Be specific and complete!
2. Know Partition Key
Every query MUST include partition key in WHERE clause. No exceptions!
3. Embrace Duplication
Disk is cheap, reads are precious. Duplicate data freely for query optimization!
Interview Questions & Answers
Complete Answer:
Query-driven design is the fundamental principle of designing Cassandra tables based on the queries you need to run, rather than normalizing data into an ideal logical structure. You list all queries first, then create tables optimized for those specific access patterns.
Why it's critical: Cassandra is a distributed database that cannot efficiently perform JOINs or full table scans across nodes. Each query must know the partition key to directly locate data. This requires designing one table per query pattern, accepting data duplication as a trade-off for consistent O(1) read performance.
Example: If you need to query users by ID, email, and country, you create three tables: users_by_id, users_by_email, and users_by_country. Each duplicates user data but optimizes for its specific access pattern, ensuring all queries remain fast regardless of data volume.
Complete Answer:
Traditional (SQL): Design normalized tables first using entity-relationship modeling. Store each piece of data once. Use JOINs to query across tables. Optimize with indexes. Philosophy: "Design for data integrity, query any way you want."
Cassandra (Query-Driven): List all queries first. Design one table per query pattern. Duplicate data across tables. No JOINs - each table is self-contained. Philosophy: "Know your queries, design for them specifically."
Key differences: (1) Traditional starts with data model, Cassandra starts with queries. (2) Traditional avoids duplication, Cassandra embraces it. (3) Traditional uses JOINs, Cassandra uses denormalization. (4) Traditional optimizes storage, Cassandra optimizes reads.
Complete Answer:
Advantages: Predictable O(1) read performance, horizontal scalability, no complex JOINs, consistent low latency, clear query patterns.
Trade-offs: (1) Data duplication - same data in multiple tables increases storage. (2) Write complexity - updates must propagate to all tables. (3) Upfront planning required - must know all queries before design. (4) Difficult to add new query patterns later without redesign. (5) Application complexity - app must manage multiple tables for same entity.
When acceptable: Read-heavy workloads, predictable access patterns, scale requirements, need for consistent performance. The modern reality: storage is cheap, fast reads are expensive. Query-driven design trades storage for speed.
❓ Why is Query-Driven Design Needed?
Understanding the fundamental limitations that make query-driven design absolutely necessary in Cassandra!
❌ Why JOINs Don't Work in Distributed Systems
The fundamental problem: Data lives on different servers!
Example JOIN query in SQL:
SELECT users.name, orders.total
FROM users
JOIN orders ON users.id = orders.user_id
WHERE users.country = 'USA'
In single server (works!):
1. Scan users table (in RAM/disk)
2. Scan orders table (in RAM/disk)
3. Match rows together (fast - same machine)
4. Return results
Total time: Maybe 50ms ✓
In distributed Cassandra (DISASTER!):
1. users table scattered across 100 nodes
2. orders table also scattered across 100 nodes
3. To JOIN, must:
→ Scan ALL 100 nodes for users ❌ (billions of rows!)
→ Scan ALL 100 nodes for orders ❌ (billions of rows!)
→ Transfer MASSIVE data across network ❌
→ Match rows on coordinator node ❌
4. Total time: Maybe 30 SECONDS or timeout! 💥
Why it's so slow:
• Network transfer: Moving GBs of data between nodes (10-100x slower than disk)
• Full scans: Can't use indexes across nodes
• Coordinator bottleneck: One node processing billions of rows
• Unpredictable: Performance depends on data volume
Solution: Query-Driven Design!
Instead of JOIN, create denormalized table:
CREATE TABLE user_orders_by_country (
country TEXT,
user_id UUID,
user_name TEXT,
order_id UUID,
order_total DECIMAL,
PRIMARY KEY (country, user_id)
);
Query becomes:
SELECT * FROM user_orders_by_country
WHERE country = 'USA';
Result: 2ms! All data on ONE node! ⚡
Key insight:
Can't JOIN? Don't need to! Store joined data together from the start!
❌ Why Full Table Scans Don't Work
The scaling problem: More data = exponentially slower!
Example "bad" query:
SELECT * FROM users WHERE age > 25;
Why this is terrible in Cassandra:
Scenario: 1 billion users across 100 nodes
• Each node: 10 million rows
• No partition key specified! (age is just a column)
• Must scan EVERY node!
What happens:
1. Coordinator sends query to all 100 nodes
2. Each node scans its 10M rows
3. Each node filters by age > 25
4. Each node returns ~7M matching rows
5. Coordinator receives 700M rows total!
6. Coordinator must merge/sort results
Performance disaster:
• Time: 10-60 seconds (or timeout!)
• Network: Transferring GBs of data
• Memory: Coordinator might run out (OOM crash!)
• Cluster load: All nodes working simultaneously
• Other queries: Blocked/slow (resources exhausted)
Query-Driven Solution:
If you need to query by age, create a table for it!
CREATE TABLE users_by_age_range (
age_bucket TEXT, -- '20-29', '30-39', etc
user_id UUID,
age INT,
name TEXT,
PRIMARY KEY (age_bucket, user_id)
);
Query becomes:
SELECT * FROM users_by_age_range
WHERE age_bucket IN ('20-29', '30-39', '40-49', ...);
Result: 5-10ms! Direct partition lookups! ⚡
Rule of thumb:
If your query doesn't have partition key in WHERE clause, it's probably wrong for Cassandra!
✅ The Query-Driven Solution Summary
Because of these limitations, we MUST design differently:
What Cassandra CAN'T do:
• ❌ JOINs across tables/nodes
• ❌ Full table scans
• ❌ Queries without partition key
• ❌ Ad-hoc queries (unpredictable patterns)
• ❌ Complex WHERE clauses (multiple conditions)
What Cassandra CAN do amazingly well:
• ✅ Direct partition key lookups (O(1) - instant!)
• ✅ Range queries within partition (using clustering keys)
• ✅ Massive scale (petabytes, trillions of rows)
• ✅ Predictable performance (always fast)
• ✅ High availability (99.99%+ uptime)
The Query-Driven Design strategy:
1. Accept limitations: No JOINs, no full scans
2. Know your queries: List every query pattern needed
3. Design per query: Create table for each pattern
4. Denormalize freely: Duplicate data for performance
5. Optimize reads: Fast queries > storage efficiency
The payoff:
• Every query: 1-5ms (predictable!)
• Linear scalability: 10 nodes or 1000 nodes, same performance
• High throughput: Millions of queries/second
• Always available: No downtime
Bottom line:
"We can't query like SQL, so we design like Cassandra - for the queries we know we'll run!"
⚙️ How Query-Driven Design Works (Step-by-Step)
Complete walkthrough from requirements to production with real example!
📱 Real-World Example: Building a Social Media App
Let's build a simplified Twitter-like app from scratch using query-driven design!
STEP 1: List ALL Application Features
Our app needs these features:
1. Users can create accounts
2. Users can post messages (tweets)
3. Users can follow other users
4. Users see their timeline (posts from people they follow)
5. Users can view anyone's profile and posts
6. Users can search posts by hashtag
STEP 2: Translate Features into Specific QUERIES
From those features, we derive exact queries:
Q1: User Profile by Username
"Get user profile where username = 'alice'"
Q2: User Profile by User ID
"Get user profile where user_id = '123e4567-e89b-12d3-a456-426614174000'"
Q3: Posts by User (for profile page)
"Get all posts by user_id = 'X' ordered by time (newest first)"
Q4: Timeline for User (home feed)
"Get recent posts from all users that user_id = 'X' follows"
Q5: Posts by Hashtag
"Get all posts with hashtag = '#cassandra' ordered by time"
Q6: Following List
"Get all users that user_id = 'X' is following"
Q7: Followers List
"Get all users following user_id = 'X'"
Notice: 7 queries identified! This means we'll need 7 tables!
STEP 3: Design ONE Table for EACH Query
Now we create tables optimized for each query:
Table 1: users_by_username
CREATE TABLE users_by_username (
username TEXT PRIMARY KEY,
user_id UUID,
email TEXT,
bio TEXT,
created_at TIMESTAMP
);
-- Query: SELECT * FROM users_by_username WHERE username = 'alice';
-- Fast! Partition key = username ✓
Table 2: users_by_id
CREATE TABLE users_by_id (
user_id UUID PRIMARY KEY,
username TEXT,
email TEXT,
bio TEXT,
created_at TIMESTAMP
);
-- Query: SELECT * FROM users_by_id WHERE user_id = ?;
-- Fast! Partition key = user_id ✓
-- NOTE: Same data as Table 1, different key! (Denormalization)
Table 3: posts_by_user
CREATE TABLE posts_by_user (
user_id UUID,
post_time TIMESTAMP,
post_id UUID,
content TEXT,
PRIMARY KEY (user_id, post_time)
) WITH CLUSTERING ORDER BY (post_time DESC);
-- Query: SELECT * FROM posts_by_user WHERE user_id = ?;
-- Fast! Partition key = user_id ✓
-- Returns posts in reverse chronological order automatically!
Table 4: timeline_by_user
CREATE TABLE timeline_by_user (
user_id UUID, -- The viewer
post_time TIMESTAMP,
author_id UUID, -- Who posted it
author_username TEXT,
post_id UUID,
content TEXT,
PRIMARY KEY (user_id, post_time)
) WITH CLUSTERING ORDER BY (post_time DESC);
-- Query: SELECT * FROM timeline_by_user WHERE user_id = ?;
-- Fast! Shows posts from everyone they follow ✓
-- NOTE: When someone posts, write to ALL followers' timelines!
Table 5: posts_by_hashtag
CREATE TABLE posts_by_hashtag (
hashtag TEXT,
post_time TIMESTAMP,
user_id UUID,
username TEXT,
post_id UUID,
content TEXT,
PRIMARY KEY (hashtag, post_time)
) WITH CLUSTERING ORDER BY (post_time DESC);
-- Query: SELECT * FROM posts_by_hashtag WHERE hashtag = '#cassandra';
-- Fast! Partition key = hashtag ✓
Table 6: following_by_user
CREATE TABLE following_by_user (
user_id UUID,
followed_at TIMESTAMP,
followed_user_id UUID,
followed_username TEXT,
PRIMARY KEY (user_id, followed_at)
);
-- Query: SELECT * FROM following_by_user WHERE user_id = ?;
-- Fast! Get everyone this user follows ✓
Table 7: followers_by_user
CREATE TABLE followers_by_user (
user_id UUID, -- The person being followed
followed_at TIMESTAMP,
follower_id UUID, -- The follower
follower_username TEXT,
PRIMARY KEY (user_id, followed_at)
);
-- Query: SELECT * FROM followers_by_user WHERE user_id = ?;
-- Fast! Get everyone following this user ✓
STEP 4: Handle Write Operations (The Tricky Part!)
With query-driven design, writes are more complex because we must update multiple tables!
Example: User creates a new post
Traditional SQL (easy):
INSERT INTO posts (id, user_id, content, created_at)
VALUES (?, ?, 'Hello world!', NOW());
-- Done! Just 1 write
Cassandra Query-Driven (more complex but necessary):
-- 1. Write to posts_by_user
INSERT INTO posts_by_user (user_id, post_time, post_id, content)
VALUES (?, NOW(), ?, 'Hello world!');
-- 2. Write to timeline_by_user for EACH follower!
-- If user has 1000 followers, insert 1000 rows!
FOR EACH follower IN followers:
INSERT INTO timeline_by_user
(user_id, post_time, author_id, author_username, post_id, content)
VALUES (follower.user_id, NOW(), ?, 'alice', ?, 'Hello world!');
-- 3. Extract hashtags and write to posts_by_hashtag
FOR EACH hashtag IN extract_hashtags('Hello world!'):
INSERT INTO posts_by_hashtag
(hashtag, post_time, user_id, username, post_id, content)
VALUES (hashtag, NOW(), ?, 'alice', ?, 'Hello world!');
-- Result: 1 post = 1000+ writes!
-- BUT: Reads are INSTANT! ⚡
The Trade-off:
• Writes: More complex, more operations (but still fast - few ms)
• Reads: Super simple, super fast (1-5ms always!)
Why this is OK:
• Most apps: 90% reads, 10% writes
• Cassandra handles writes extremely well (optimized!)
• User experience: Reads matter more (scrolling feed)
• 1000 writes still completes in ~50ms total ✓
Philosophy:
"Do the work at write time so reads can be instant!"
🎮 Interactive Query Builder Demo
See how different queries require different table designs in real-time!
Select Your Query Pattern:
Click each button to see how the table design changes based on what you need to query!
👆 Select a query pattern above!
Each query pattern will show you the optimized table design with detailed explanations.
📐 Common Design Patterns
Proven patterns used by companies at massive scale - learn from their experience!
One Table Per Query Pattern
The fundamental pattern!
Rule: Each unique query = new table
Example - User entity:
Query 1: users_by_id (PK: user_id)
Query 2: users_by_email (PK: email)
Query 3: users_by_country (PK: country, CK: user_id)
When to use: Always! Core principle.
Benefits:
• Optimized for specific access
• O(1) performance
• No compromises!
Time-Series Pattern
For time-based data
Structure:
• PK: Entity ID (user, sensor, etc)
• CK: Timestamp DESC
Example:
CREATE TABLE user_activity (
user_id UUID,
event_time TIMESTAMP,
event_type TEXT,
PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY
(event_time DESC);
Perfect for: Activity feeds, logs, metrics, sensor data
Time Bucketing Pattern
Prevent hot partitions!
Problem: One partition too large
Solution: Split by time bucket
Example:
CREATE TABLE events_by_day (
day_bucket TEXT, -- '2024-12-29'
event_time TIMESTAMP,
event_data TEXT,
PRIMARY KEY (day_bucket, event_time)
);
Result: Evenly distributed load!
Use when: >100MB per partition
Write-Time Denormalization
Fan-out writes for instant reads
Pattern: Write to multiple tables when data changes
Example - Social media post:
1. User posts → Write to posts_by_user
2. Also write to timeline of 1000 followers
3. Also write to posts_by_hashtag
Trade-off:
• Writes: 1000+ operations
• Reads: 1 simple query ✓
When to use: Read-heavy workloads (90%+ reads)
Wide Row Pattern
Store related data together
Concept: Many clustering keys per partition
Example - Product catalog:
CREATE TABLE products_by_category (
category TEXT,
product_id UUID,
name TEXT,
price DECIMAL,
PRIMARY KEY (category, product_id)
);
One partition = all products in category!
Limit: Keep partition <100MB
Materialized View Pattern
Let Cassandra handle duplication
Base table:
CREATE TABLE users_by_id (...);
Materialized view:
CREATE MATERIALIZED VIEW
users_by_email AS
SELECT * FROM users_by_id
WHERE email IS NOT NULL
PRIMARY KEY (email, user_id);
Automatic sync! But limited flexibility.
Use sparingly - manual tables more control.
⚠️ Anti-Patterns (What NOT to Do!)
Learn from common mistakes that cause performance disasters!
Querying Without Partition Key
NEVER DO THIS:
SELECT * FROM users
WHERE age > 25;
Why it's terrible:
• Full cluster scan!
• All 100 nodes queried
• 10-60 seconds or timeout
• May crash cluster!
Fix: Create users_by_age_range table!
Using ALLOW FILTERING
RED FLAG:
SELECT * FROM users
WHERE country = 'USA'
ALLOW FILTERING;
What it means:
"I know this is slow, do it anyway!"
Reality:
• Scans entire table
• Filters in memory
• Extremely slow
Fix: Redesign table with proper keys!
Ignoring Partition Size
DANGER:
Partition grows to 10GB!
Problems:
• Slow reads (scanning GB)
• Slow writes (large memtable)
• Slow compaction
• Hot partition (one node overloaded)
Rule: Keep <100MB per partition
Fix: Use time bucketing!
Trying to Normalize Data
SQL Thinking:
"Store each fact once, JOIN when needed"
Problem in Cassandra:
• No JOINs across nodes!
• Must fetch from multiple tables
• Multiple round trips
• Slow and complex
Fix: Denormalize! Duplicate freely!
Designing Tables First
BACKWARDS!
1. Design perfect tables
2. Then try to query...
3. Queries don't work!
4. Must redesign everything 😫
CORRECT:
1. List queries FIRST
2. Design tables for queries
3. Queries work perfectly! ✓
Using Secondary Indexes
TEMPTING BUT BAD:
CREATE INDEX ON users(country);
Problems:
• Distributed index (queries all nodes)
• Slow with many matching rows
• Can cause timeouts
Fix: Create dedicated table with country as PK!
🏢 Real Company Examples
How world-class companies use query-driven design at massive scale!
🎬 Netflix: 200M+ Users, 5 Tables for User Profile
Challenge: Serve personalized content to 200M+ subscribers worldwide with <5ms latency.
Query patterns identified:
1. Get profile by user_id (most common - 90% of queries)
2. Get profile by email (login/password reset)
3. Get viewing history by user
4. Get recommendations by user
5. Get active devices by user
Solution: 5 dedicated tables
-- Table 1: Primary access (90% of queries)
CREATE TABLE user_profiles_by_id (
user_id UUID PRIMARY KEY,
email TEXT,
subscription_tier TEXT,
preferences MAP<TEXT, TEXT>
);
-- Table 2: Login flow
CREATE TABLE user_profiles_by_email (
email TEXT PRIMARY KEY,
user_id UUID,
...
);
-- Table 3: Viewing history (time-series)
CREATE TABLE viewing_history_by_user (
user_id UUID,
watched_at TIMESTAMP,
content_id UUID,
watch_duration INT,
PRIMARY KEY (user_id, watched_at)
) WITH CLUSTERING ORDER BY (watched_at DESC);
-- Table 4: Recommendations (pre-computed)
CREATE TABLE recommendations_by_user (
user_id UUID,
recommendation_slot INT,
content_id UUID,
score FLOAT,
PRIMARY KEY (user_id, recommendation_slot)
);
-- Table 5: Device management
CREATE TABLE devices_by_user (
user_id UUID,
device_id TEXT,
device_type TEXT,
last_active TIMESTAMP,
PRIMARY KEY (user_id, device_id)
);
Results:
• Latency: P99 < 5ms across ALL queries ✓
• Scale: 200M+ users, billions of queries/day ✓
• Availability: 99.99%+ uptime ✓
• Cost: Same data duplicated 5 times, but worth it for performance!
Key lesson:
"Don't fight duplication. User experience depends on fast reads, not storage efficiency!"
📱 Instagram: Activity Feed Design
Challenge: Show personalized feed for 2B+ users, each following 200-1000 accounts.
❌ Naive approach (doesn't work):
-- When user opens app:
1. Get list of 500 people they follow
2. Query posts from each person
3. Merge and sort 500 result sets
4. Return top 50
Problem: 500+ queries per page load! 5-10 seconds! 💥
✅ Query-driven solution (Instagram's actual approach):
-- Pre-compute feed at write time!
CREATE TABLE feed_by_user (
user_id UUID,
post_time TIMESTAMP,
post_id UUID,
author_id UUID,
author_username TEXT,
photo_url TEXT,
caption TEXT,
PRIMARY KEY (user_id, post_time)
) WITH CLUSTERING ORDER BY (post_time DESC);
-- When someone posts:
1. Get their 10M followers
2. Write post to EACH follower's feed
3. Total: 10M writes (but async, fast!)
-- When user opens app:
SELECT * FROM feed_by_user
WHERE user_id = ?
LIMIT 50;
Result: 1 query, 2ms! ⚡
Results:
• Feed loads in <50ms total (including network!)
• Smooth infinite scroll
• Handles billions of posts/day
• Trade-off: Write amplification (10M writes) but acceptable!
Key lesson:
"Fan-out writes to optimize reads. Users scroll feeds constantly but post occasionally!"
💬 Discord: Message History with Time Bucketing
Challenge: Store trillions of messages across millions of channels with fast retrieval.
❌ First attempt (failed):
CREATE TABLE messages_by_channel (
channel_id UUID,
message_time TIMESTAMP,
message_id UUID,
content TEXT,
PRIMARY KEY (channel_id, message_time)
);
Problem: Popular channels = HOT PARTITIONS!
One partition = 50GB of messages! 💥
Reads slow, writes slow, compaction disaster!
✅ Solution: Time bucketing!
CREATE TABLE messages_by_channel_day (
channel_id UUID,
day_bucket TEXT, -- '2024-12-29'
message_time TIMESTAMP,
message_id UUID,
author_id UUID,
content TEXT,
PRIMARY KEY ((channel_id, day_bucket), message_time)
) WITH CLUSTERING ORDER BY (message_time DESC);
-- Query recent messages:
SELECT * FROM messages_by_channel_day
WHERE channel_id = ? AND day_bucket = '2024-12-29'
LIMIT 50;
-- For scrolling back, query previous day buckets:
WHERE channel_id = ? AND day_bucket = '2024-12-28'
...
Results:
• Partition size: <10MB (down from 50GB!)
• Read latency: 2-5ms consistent
• No more hot partitions
• Scales to trillions of messages ✓
Key lesson:
"When partitions grow large, add bucketing to your partition key. Split the load!"
✅ Best Practices & Guidelines
Production-tested advice for successful Cassandra data modeling!
1. Start with Application Workflow
Before ANY design work:
1. Map complete user journey
2. List every screen/feature
3. Write exact query for each
4. Include frequency estimates
Example:
• Login page → Query: user by email (1K/sec)
• Profile page → Query: user by ID (10K/sec)
• Feed page → Query: timeline (50K/sec)
Frequency guides optimization priorities!
2. Choose Partition Keys Carefully
Critical decisions!
Goals:
• Even data distribution
• Predictable partition size
• Matches query patterns
Good partition keys:
✓ user_id (millions of users)
✓ sensor_id (thousands of sensors)
✓ (channel_id, day_bucket)
Bad partition keys:
✗ country (only ~200 values!)
✗ status (active/inactive)
✗ timestamp (unbounded growth)
3. Monitor Partition Sizes
Set alerts and limits!
Recommended limits:
• Warning: 50MB per partition
• Critical: 100MB per partition
• Max: Never exceed 1GB!
Check with:
nodetool cfstats tablename
If too large: Add bucketing!
4. Embrace Duplication
It's not a bug, it's a feature!
Duplicate when:
• Need different access patterns
• Optimizing for read speed
• Avoiding JOINs
Storage is cheap:
• 1TB disk: $20/month
• 5x duplication: $100/month
• Fast queries: Priceless! ✓
Don't optimize for storage, optimize for performance!
5. Plan for Updates
Denormalization = write complexity!
When user changes email:
1. Update users_by_id
2. Delete old users_by_email row
3. Insert new users_by_email row
4. Update denormalized email in ALL tables!
Use batch statements:
BEGIN BATCH
UPDATE ...
DELETE ...
INSERT ...
APPLY BATCH;
Maintains consistency!
6. Test at Scale
Design looks good on paper...
Must test with:
• Realistic data volumes
• Actual query patterns
• Production-like load
Load testing tools:
• cassandra-stress
• nosqlbench
• Custom scripts
Discover issues before production!
7. Iterate Based on Metrics
Monitor everything!
Key metrics:
• Query latency (P50, P99, P999)
• Partition sizes
• Tombstone warnings
• Read/write ratios
Be ready to redesign:
• Usage patterns change
• New features added
• Scale increases
Design evolves with app!
8. Document Your Decisions
Future you will thank you!
Document for each table:
• What query it serves
• Why keys chosen
• Expected query volume
• Update patterns
Example:
-- timeline_by_user
-- Query: User's home feed
-- Volume: 50K QPS peak
-- Updated: On follow + on post
-- PK: user_id (even dist)
-- CK: post_time DESC (newest first)
9. Know When NOT to Use Cassandra
Cassandra isn't always the answer!
Bad fit when:
• Need complex JOINs
• Ad-hoc analytics queries
• Transactions across rows
• Small dataset (<100GB)
• Unpredictable access patterns
Consider instead:
• PostgreSQL (complex queries)
• MongoDB (flexible schema)
• Redis (small, fast data)
⭐ Golden Rules Summary
Remember these core principles:
1. Queries First: Know what you'll ask before designing
2. One Table Per Query: Don't compromise - create dedicated tables
3. Denormalize Freely: Duplicate data without guilt
4. Know Your Partition Key: Every query must include it
5. Watch Partition Size: Keep under 100MB
6. Optimize for Reads: Most apps are read-heavy
7. Test at Scale: Don't guess, measure!
8. Monitor & Iterate: Design evolves with usage
When in doubt: Choose performance over storage!
Interview Questions & Answers
Comprehensive answers to common interview questions about query-driven design!
Complete Answer:
Query-driven design is a data modeling methodology where you design your database tables based on the specific queries your application will run, rather than modeling based on entity relationships like in traditional relational databases.
The process: First, you list all the queries your application needs. Then, you create one table for each distinct query pattern, optimizing the partition key and clustering keys for that specific access pattern. This often means storing the same data in multiple tables with different key structures - a practice called denormalization.
Why it's critical in Cassandra: Cassandra is a distributed database designed for massive scale and high availability. To achieve this, it makes fundamental trade-offs that eliminate the ability to perform JOINs across tables and full table scans. When data is distributed across hundreds of nodes, joining tables would require expensive network operations and scanning multiple nodes. Therefore, each query must be able to locate data using a partition key to directly identify which node contains the data.
Example: If you need to query users by ID, email, and country, you create three separate tables: users_by_id (PK: user_id), users_by_email (PK: email), and users_by_country (PK: country, CK: user_id). Each table duplicates user data but is optimized for its specific query pattern, ensuring O(1) lookup performance.
The alternative doesn't work: If you tried to use traditional normalized design with one users table and query by email without it being the partition key, Cassandra would have to scan all nodes in the cluster - potentially taking 10-60 seconds instead of 1-5 milliseconds.
Complete Answer:
Traditional Relational Design (SQL): You start by modeling entities and their relationships using normalization principles. The goal is to eliminate data duplication and maintain data integrity through foreign keys. You create one table per entity (users, orders, products) and use JOINs to combine data at query time. Indexes are added later to optimize frequently used queries. The philosophy is "design for data integrity, query any way you want."
Query-Driven Design (Cassandra): You start by listing all queries your application will perform. Then you design one table for each query pattern, duplicating data freely across tables. Each table is self-contained with all the data needed for its query - no JOINs required. Partition keys are chosen to ensure even data distribution and fast lookups. The philosophy is "know your queries upfront, design specifically for them."
Key differences:
- Starting point: Traditional starts with entities and relationships; Cassandra starts with queries
- Data duplication: Traditional avoids it religiously; Cassandra embraces it as a strategy
- Query execution: Traditional uses JOINs; Cassandra uses denormalization
- Optimization: Traditional optimizes storage; Cassandra optimizes read performance
- Flexibility: Traditional allows ad-hoc queries; Cassandra requires predefined query patterns
- Write complexity: Traditional has simple writes; Cassandra has complex writes (updating multiple tables)
Example scenario - User and Orders:
Traditional SQL: Create users table and orders table with foreign key. Query with JOIN: SELECT * FROM users JOIN orders ON users.id = orders.user_id WHERE users.id = ?
Cassandra: Create orders_by_user table that includes both user data and order data denormalized together. Query: SELECT * FROM orders_by_user WHERE user_id = ? - one table, one query, no JOIN, instant results.
Complete Answer:
Advantages:
- Predictable O(1) performance: Every query knows the partition key and goes directly to one node
- Linear scalability: Add more nodes, get proportionally more capacity
- No complex JOINs: Queries are simple and fast
- Consistent low latency: 1-5ms reads regardless of data volume
- High availability: No single point of failure with replication
Trade-offs and disadvantages:
- Data duplication: Same data stored in multiple tables increases storage costs. However, storage is relatively cheap compared to compute resources.
- Write complexity: Updating one piece of data may require updates to 5-10 tables. Must ensure consistency across updates.
- Upfront planning required: Must know all query patterns before designing schema. Hard to add new query patterns later without schema redesign.
- Application complexity: Application logic must manage writes to multiple tables and handle consistency
- No flexibility: Can't run ad-hoc queries or analytics without pre-designed tables
When query-driven design is appropriate:
- Read-heavy workloads: 80-90%+ reads vs writes (social media feeds, content platforms)
- Predictable access patterns: You know exactly how data will be queried
- Scale requirements: Need to handle billions of rows, petabytes of data
- Low latency critical: Must have consistent <10ms response times
- High availability needed: Can't afford downtime (99.99%+ uptime requirements)
When it's NOT appropriate:
- Need for complex JOINs across many tables
- Ad-hoc analytics and business intelligence queries
- ACID transactions across multiple rows
- Small datasets (<100GB) where a single server suffices
- Unpredictable or constantly changing access patterns
Real-world example: Instagram uses query-driven design for user feeds because they have 90% reads (scrolling) vs 10% writes (posting), predictable query patterns (get feed for user), and need consistent low latency for 2 billion users. They accept the trade-off of writing each post to millions of followers' feeds to ensure instant feed loading.
Complete Answer (Step-by-Step):
Step 1: List Application Features
- Users can post tweets
- Users can follow other users
- Users see timeline (tweets from people they follow)
- Users can view any profile and their tweets
- Users can search tweets by hashtag
- Users can like tweets
Step 2: Translate to Specific Queries
- Q1: Get user profile by username (login)
- Q2: Get user profile by user_id
- Q3: Get tweets by user (profile page)
- Q4: Get timeline for user (home feed)
- Q5: Get tweets by hashtag
- Q6: Get users that I follow
- Q7: Get my followers
- Q8: Get likes for a tweet
- Q9: Get tweets I've liked
Step 3: Design Tables
Table 1: users_by_username for login
PRIMARY KEY (username) Stores: user_id, email, bio, created_at Query: SELECT * FROM users_by_username WHERE username = ?
Table 2: tweets_by_user for profile pages
PRIMARY KEY (user_id, tweet_time) Clustering: tweet_time DESC (newest first) Stores: tweet_id, content, reply_count, like_count Query: SELECT * FROM tweets_by_user WHERE user_id = ?
Table 3: timeline_by_user for home feed (most important!)
PRIMARY KEY (user_id, tweet_time) Clustering: tweet_time DESC Stores: author_id, author_username, tweet_id, content Query: SELECT * FROM timeline_by_user WHERE user_id = ? Write pattern: When user tweets, write to ALL followers' timelines
Table 4: tweets_by_hashtag for hashtag search
PRIMARY KEY (hashtag, tweet_time) Clustering: tweet_time DESC Stores: user_id, username, tweet_id, content Query: SELECT * FROM tweets_by_hashtag WHERE hashtag = '#cassandra'
Step 4: Handle Write Operations
When user posts a tweet:
- Write to tweets_by_user (1 write)
- Get list of followers (read from followers_by_user)
- Write to timeline_by_user for EACH follower (N writes where N = follower count)
- Extract hashtags from tweet content
- Write to tweets_by_hashtag for each hashtag (M writes)
Result: 1 tweet = potentially thousands of writes, but timeline reads are instant!
Key Design Decisions:
- Fan-out on write: Write amplification for timeline is acceptable because reads far exceed writes
- Time-series pattern: Use TIMESTAMP as clustering key with DESC ordering for chronological feeds
- Denormalization: Include author info in timeline_by_user to avoid second query
- Partition size: Monitor timeline_by_user - may need time bucketing for celebrity accounts with millions of followers
Responsive Ad