Section 1: Getting Started

Cassandra Query Language (CQL)

Learn the SQL-like language that powers Apache Cassandra databases!

📖 The Spotify Story: How CQL Powers Music at Scale

Context: Spotify has over 550 million users streaming billions of songs daily. They needed a query language that was:

  • Easy to learn for developers coming from SQL backgrounds
  • Powerful enough to handle complex data models
  • Fast enough to serve millions of queries per second

The Challenge: Traditional SQL databases couldn't scale horizontally, but NoSQL databases had complex, unfamiliar query languages that made development slow.

The Solution: Cassandra's CQL provided the perfect balance - it looks and feels like SQL but is designed for distributed systems. Spotify engineers could write familiar queries like:

-- Get user's recent plays (simple and intuitive!)
SELECT track_name, played_at, duration_ms
FROM user_listening_history
WHERE user_id = 'user_12345'
ORDER BY played_at DESC
LIMIT 50;

The Result: Spotify now processes over 1.5 billion reads per day using CQL, with average query latencies under 5ms. Developers love it because it's SQL-like, and operations teams love it because it scales effortlessly.

What is CQL?

Cassandra Query Language (CQL) is the primary interface for interacting with Apache Cassandra. Think of it as "SQL for distributed databases" - it provides a familiar, SQL-like syntax while being specifically designed for Cassandra's distributed architecture.

🎯

SQL-Like Syntax

If you know SQL, you already know most of CQL. Commands like SELECT, INSERT, UPDATE, DELETE work just as you'd expect.

⚡

Optimized for Speed

CQL is designed for linear scalability. As you add more nodes, your query throughput increases proportionally.

🌍

Distributed by Design

Unlike SQL, CQL understands that your data is spread across multiple machines and optimizes queries accordingly.

📊

Schema-Based

CQL requires you to define your data structure upfront, ensuring data consistency and query optimization.

🔄

Flexible Data Types

Support for basic types (int, text, timestamp) plus collections (list, set, map), counters, and user-defined types.

🛡️

ACID Guarantees

CQL provides row-level atomicity and isolation, ensuring your writes are consistent and reliable.

CQL vs SQL: Key Differences

While CQL looks like SQL, it's important to understand the differences to use it effectively:

Feature SQL (Traditional RDBMS) CQL (Cassandra)
JOINs ✅ Supports complex joins across tables ❌ No joins - denormalize your data
Subqueries ✅ Full support for nested queries ❌ Not supported - query directly
Aggregations ✅ GROUP BY, complex aggregations ⚠️ Limited - SUM, COUNT, AVG only within partition
WHERE Clause ✅ Filter on any column ⚠️ Must filter on partition key, then clustering columns
Transactions ✅ Multi-row ACID transactions ⚠️ Row-level atomicity only (use BATCH for limited multi-row)
Indexes ✅ Secondary indexes on any column ⚠️ Secondary indexes available but use carefully
Scalability ⚠️ Vertical scaling (bigger machines) ✅ Horizontal scaling (add more nodes)
Performance ⚠️ Degrades with data volume ✅ Linear performance regardless of size
Key Insight

CQL is not a drop-in replacement for SQL. It sacrifices flexibility (no joins, limited WHERE clauses) for massive scalability and consistent performance. This trade-off is intentional and makes Cassandra perfect for write-heavy, high-throughput applications.

How CQL Queries Work

Understanding how a CQL query travels through a Cassandra cluster is crucial for writing efficient queries. Here's the complete journey:

CQL Query Flow: From Client to Data 💻 Client App User Query SELECT * FROM users Coordinator Node 1 🔄 Token Ring (Consistent Hashing) Node 2 ✓ Has Data Node 3 ✓ Has Data Node 4 ✗ No Data Fetch from replicas 📦 Result Set (merged) ✓ Query Flow Steps 1. Client Query Send CQL to any node 2. Coordinator Parse & route query 3. Token Lookup Hash partition key Find replica nodes 4. Parallel Fetch Query all replicas Wait for QUORUM 5. Data Merge Compare timestamps Resolve conflicts 6. Return Results Send to client Latency: 1-5ms ⚡ Performance Stats Typical Latency: 1-5ms Throughput: 100k+ ops/sec Replication: RF = 3 Consistency: QUORUM
Important Concepts
  • Coordinator Node: Any node can act as coordinator - no single point of failure
  • Token Ring: Data is distributed based on hash of partition key
  • Replication Factor: Data is stored on multiple nodes for fault tolerance
  • Consistency Level: Controls how many replicas must respond (ONE, QUORUM, ALL)

Interactive Console: Try CQL Commands

Type commands below and press Enter to execute. Try the example commands or explore on your own!

cqlsh:spotify_app - Interactive Mode
✓ Connected to Cassandra cluster
-- Welcome! Try these commands or type your own:
-- 1. CREATE TABLE users (user_id uuid PRIMARY KEY, username text);
-- 2. INSERT INTO users (user_id, username) VALUES (uuid(), 'john_doe');
-- 3. SELECT * FROM users;
-- Type 'help' for more commands
cqlsh:spotify_app>
Quick Commands to Try

Essential CQL Syntax

1️⃣ Creating a Keyspace (Database)

A keyspace is like a database in traditional RDBMS. It defines replication strategy and other settings:

-- Create keyspace with simple strategy (for single datacenter)
CREATE KEYSPACE IF NOT EXISTS spotify_app
WITH replication = {
    'class': 'SimpleStrategy',
    'replication_factor': 3
}
AND durable_writes = true;

-- Create keyspace with network topology (for multiple datacenters)
CREATE KEYSPACE IF NOT EXISTS spotify_app_prod
WITH replication = {
    'class': 'NetworkTopologyStrategy',
    'datacenter1': 3,
    'datacenter2': 2
};
Replication Factor Best Practice

Replication Factor = 3 is recommended for production. This provides:

  • Fault tolerance - can lose 1 node without data loss
  • Better read performance - more replicas to serve reads
  • Balance between redundancy and storage cost

2️⃣ Creating Tables

Tables define your data structure. The PRIMARY KEY is crucial - it determines data distribution and query patterns:

-- Simple table with single partition key
CREATE TABLE IF NOT EXISTS users (
    user_id uuid PRIMARY KEY,
    username text,
    email text,
    created_at timestamp,
    last_login timestamp,
    is_premium boolean
);

-- Table with composite partition key
CREATE TABLE IF NOT EXISTS user_playlists (
    user_id uuid,
    playlist_id uuid,
    playlist_name text,
    created_date date,
    song_count int,
    total_duration_ms bigint,
    is_public boolean,
    PRIMARY KEY (user_id, playlist_id)
);

-- Table with partition key + clustering columns
CREATE TABLE IF NOT EXISTS listening_history (
    user_id uuid,
    played_at timestamp,
    track_id uuid,
    track_name text,
    artist_name text,
    album_name text,
    duration_ms int,
    play_source text,
    PRIMARY KEY (user_id, played_at)
) WITH CLUSTERING ORDER BY (played_at DESC);

-- Real example: Handling time-series data
CREATE TABLE IF NOT EXISTS sensor_readings (
    sensor_id text,
    reading_date date,
    reading_time timestamp,
    temperature decimal,
    humidity decimal,
    pressure decimal,
    PRIMARY KEY ((sensor_id, reading_date), reading_time)
) WITH CLUSTERING ORDER BY (reading_time DESC);
Understanding PRIMARY KEY

PRIMARY KEY (partition_key, clustering_columns)

  • Partition Key: Determines which nodes store the data (hashed to token)
  • Clustering Columns: Define sort order within a partition
  • Compound Partition Key: Use ((key1, key2)) to group related data together

Example: In PRIMARY KEY ((sensor_id, reading_date), reading_time):

  • All readings for sensor "S001" on "2024-12-29" are stored together
  • Within that partition, readings are sorted by time (descending)
  • Queries can efficiently fetch all readings for a sensor on a specific day

3️⃣ Inserting Data

INSERT adds new rows. CQL supports both full and partial inserts:

-- Insert complete user record
INSERT INTO users (
    user_id, 
    username, 
    email, 
    created_at, 
    last_login, 
    is_premium
)
VALUES (
    550e8400-e29b-41d4-a716-446655440000,
    'taylor_swift_fan',
    'taylor@example.com',
    '2024-01-15 10:30:00',
    '2024-12-29 08:15:00',
    true
);

-- Insert with TTL (Time To Live) - auto-delete after 30 days
INSERT INTO session_tokens (user_id, token, created_at)
VALUES (
    550e8400-e29b-41d4-a716-446655440000,
    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
    toTimestamp(now())
)
USING TTL 2592000;  -- 30 days in seconds

-- Insert multiple playlist entries
INSERT INTO user_playlists (user_id, playlist_id, playlist_name, created_date, song_count, total_duration_ms, is_public)
VALUES (550e8400-e29b-41d4-a716-446655440000, uuid(), 'Morning Vibes', '2024-12-01', 45, 9234567, true);

INSERT INTO user_playlists (user_id, playlist_id, playlist_name, created_date, song_count, total_duration_ms, is_public)
VALUES (550e8400-e29b-41d4-a716-446655440000, uuid(), 'Workout Mix', '2024-11-15', 32, 7123456, false);

INSERT INTO user_playlists (user_id, playlist_id, playlist_name, created_date, song_count, total_duration_ms, is_public)
VALUES (550e8400-e29b-41d4-a716-446655440000, uuid(), 'Chill Evening', '2024-10-20', 28, 6789123, true);

-- Insert listening history
INSERT INTO listening_history (user_id, played_at, track_id, track_name, artist_name, album_name, duration_ms, play_source)
VALUES (
    550e8400-e29b-41d4-a716-446655440000,
    '2024-12-29 14:30:00',
    uuid(),
    'Anti-Hero',
    'Taylor Swift',
    'Midnights',
    200000,
    'discover_weekly'
);

INSERT INTO listening_history (user_id, played_at, track_id, track_name, artist_name, album_name, duration_ms, play_source)
VALUES (
    550e8400-e29b-41d4-a716-446655440000,
    '2024-12-29 14:26:30',
    uuid(),
    'Lavender Haze',
    'Taylor Swift',
    'Midnights',
    202000,
    'playlist'
);

INSERT INTO listening_history (user_id, played_at, track_id, track_name, artist_name, album_name, duration_ms, play_source)
VALUES (
    550e8400-e29b-41d4-a716-446655440000,
    '2024-12-29 14:23:00',
    uuid(),
    'Karma',
    'Taylor Swift',
    'Midnights',
    204000,
    'search'
);

4️⃣ Querying Data (SELECT)

SELECT retrieves data. Remember: CQL queries must specify the partition key!

-- Get all user info (requires full primary key)
SELECT * FROM users 
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

-- Result:
-- user_id                              | username         | email                 | created_at                  | last_login                  | is_premium
-- --------------------------------------+------------------+-----------------------+-----------------------------+-----------------------------+------------
-- 550e8400-e29b-41d4-a716-446655440000 | taylor_swift_fan | taylor@example.com    | 2024-01-15 10:30:00.000000  | 2024-12-29 08:15:00.000000  | True

-- Get specific columns
SELECT username, email, is_premium 
FROM users 
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

-- Get user's playlists (partition key query)
SELECT playlist_name, song_count, is_public 
FROM user_playlists
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

-- Result:
-- playlist_name  | song_count | is_public
-- ---------------+------------+-----------
-- Morning Vibes  | 45         | True
-- Workout Mix    | 32         | False
-- Chill Evening  | 28         | True

-- Get recent listening history (with ORDER BY on clustering column)
SELECT played_at, track_name, artist_name, duration_ms 
FROM listening_history
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
ORDER BY played_at DESC
LIMIT 10;

-- Result:
-- played_at                   | track_name      | artist_name  | duration_ms
-- ----------------------------+-----------------+--------------+-------------
-- 2024-12-29 14:30:00.000000  | Anti-Hero       | Taylor Swift | 200000
-- 2024-12-29 14:26:30.000000  | Lavender Haze   | Taylor Swift | 202000
-- 2024-12-29 14:23:00.000000  | Karma           | Taylor Swift | 204000

-- Query with time range (using clustering column)
SELECT track_name, artist_name, played_at 
FROM listening_history
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
AND played_at >= '2024-12-29 14:20:00'
AND played_at <= '2024-12-29 14:35:00';

-- Using ALLOW FILTERING (not recommended for production!)
SELECT username, email 
FROM users 
WHERE is_premium = true 
ALLOW FILTERING;
-- ⚠️ This scans all partitions - very slow on large datasets!
ALLOW FILTERING - Use with Caution!

Never use ALLOW FILTERING in production! It forces Cassandra to scan all partitions, which:

  • Defeats the purpose of Cassandra's distributed architecture
  • Creates massive performance bottlenecks
  • Can timeout on large datasets
  • Instead: Design tables for your query patterns

5️⃣ Updating Data

UPDATE modifies existing rows. If the row doesn't exist, it creates it (upsert behavior):

-- Update user's last login
UPDATE users 
SET last_login = toTimestamp(now())
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

-- Update premium status
UPDATE users 
SET is_premium = true
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

-- Update playlist details
UPDATE user_playlists
SET song_count = 46, total_duration_ms = 9434567
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
AND playlist_id = a1b2c3d4-e5f6-7890-abcd-1234567890ab;

-- Conditional update (lightweight transaction - use sparingly!)
UPDATE users 
SET is_premium = true
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
IF is_premium = false;

-- Update with TTL (data expires after specified time)
UPDATE session_tokens USING TTL 3600
SET token = 'new_token_value'
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

6️⃣ Deleting Data

DELETE removes rows or specific columns from rows:

-- Delete entire row
DELETE FROM users 
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

-- Delete specific columns
DELETE email, last_login 
FROM users 
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

-- Delete from composite key table
DELETE FROM user_playlists
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
AND playlist_id = a1b2c3d4-e5f6-7890-abcd-1234567890ab;

-- Delete with timestamp (removes data written before specific time)
DELETE FROM listening_history
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
AND played_at < '2024-01-01 00:00:00';

-- Conditional delete (lightweight transaction)
DELETE FROM users 
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
IF is_premium = false;
Tombstones Warning

Deletes in Cassandra create tombstones (markers for deleted data) rather than immediately removing data. Too many tombstones can degrade performance:

  • Why? Distributed systems need tombstones to propagate deletes to all replicas
  • Solution: Use TTL for automatic expiration instead of manual deletes
  • Compaction: Old tombstones are removed during compaction (after gc_grace_seconds)

Advanced CQL Features

🔢 Collections (List, Set, Map)

-- Create table with collections
CREATE TABLE user_preferences (
    user_id uuid PRIMARY KEY,
    favorite_genres set,           -- SET: unique, unordered
    recent_searches list,          -- LIST: ordered, allows duplicates
    device_settings map      -- MAP: key-value pairs
);

-- Insert with collections
INSERT INTO user_preferences (user_id, favorite_genres, recent_searches, device_settings)
VALUES (
    550e8400-e29b-41d4-a716-446655440000,
    {'pop', 'rock', 'indie'},
    ['taylor swift', 'the weeknd', 'billie eilish'],
    {'theme': 'dark', 'quality': 'high', 'autoplay': 'true'}
);

-- Update collections
UPDATE user_preferences 
SET favorite_genres = favorite_genres + {'jazz'}  -- Add to set
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

UPDATE user_preferences 
SET recent_searches = ['ed sheeran'] + recent_searches  -- Prepend to list
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

UPDATE user_preferences 
SET device_settings['volume'] = '85'  -- Update map value
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000;

🔢 Counter Columns

-- Counter table for track plays
CREATE TABLE track_stats (
    track_id uuid PRIMARY KEY,
    play_count counter,
    skip_count counter,
    save_count counter
);

-- Increment counters
UPDATE track_stats 
SET play_count = play_count + 1
WHERE track_id = a1b2c3d4-e5f6-7890-abcd-1234567890ab;

UPDATE track_stats 
SET skip_count = skip_count + 1
WHERE track_id = a1b2c3d4-e5f6-7890-abcd-1234567890ab;

-- Read counter values
SELECT track_id, play_count, skip_count, save_count 
FROM track_stats 
WHERE track_id = a1b2c3d4-e5f6-7890-abcd-1234567890ab;

-- Result:
-- track_id                             | play_count | skip_count | save_count
-- -------------------------------------+------------+------------+------------
-- a1b2c3d4-e5f6-7890-abcd-1234567890ab | 1547       | 23         | 412

📦 Batch Operations

-- LOGGED batch (atomic within single partition)
BEGIN BATCH
    INSERT INTO users (user_id, username, email, created_at, is_premium)
    VALUES (uuid(), 'new_user', 'new@example.com', toTimestamp(now()), false);
    
    INSERT INTO user_activity (user_id, activity_type, activity_time)
    VALUES (uuid(), 'signup', toTimestamp(now()));
    
    UPDATE user_stats SET total_users = total_users + 1
    WHERE stat_id = 'global';
APPLY BATCH;

-- UNLOGGED batch (better performance, no atomicity guarantee)
BEGIN UNLOGGED BATCH
    INSERT INTO listening_history (user_id, played_at, track_id, track_name)
    VALUES (uuid(), toTimestamp(now()), uuid(), 'Song 1');
    
    INSERT INTO listening_history (user_id, played_at, track_id, track_name)
    VALUES (uuid(), toTimestamp(now()), uuid(), 'Song 2');
    
    INSERT INTO listening_history (user_id, played_at, track_id, track_name)
    VALUES (uuid(), toTimestamp(now()), uuid(), 'Song 3');
APPLY BATCH;
Batch Operations Best Practices
  • Don't use for bulk loading: Batches are not for performance, they're for atomicity
  • Keep batches small: Max 5-10 statements per batch
  • Same partition key: Batches work best when all operations are in the same partition
  • Use UNLOGGED when possible: Better performance if atomicity isn't critical

🔐 Secondary Indexes

-- Create secondary index (use carefully!)
CREATE INDEX ON users (email);
CREATE INDEX ON users (is_premium);

-- Now you can query by indexed column
SELECT username, email FROM users WHERE email = 'taylor@example.com';
SELECT username, last_login FROM users WHERE is_premium = true;

-- Drop index
DROP INDEX users_email_idx;
Secondary Index Warning

Secondary indexes have significant limitations:

  • Poor performance on high-cardinality columns (many unique values)
  • Queries still hit all nodes in the cluster
  • Best for low-cardinality columns (few unique values)
  • Better alternative: Create a dedicated table with the query pattern as primary key

CQL Best Practices

✅

Model by Query Pattern

Design tables for your queries, not your data structure. If you need different queries, create multiple tables with denormalized data.

⚡

Avoid ALLOW FILTERING

ALLOW FILTERING is a code smell. If you need it, your data model is wrong. Redesign your tables instead.

🔑

Partition Key Strategy

Choose partition keys carefully. They should distribute data evenly and match your most common query patterns.

📊

Keep Partitions Small

Aim for <100MB per partition. Large partitions degrade performance. Use composite partition keys or time-based bucketing.

⏱️

Use TTL for Expiration

Set TTL instead of manual deletes. Automatic expiration avoids tombstone accumulation and improves performance.

🎯

Consistency Level Choice

Use QUORUM for balance. ONE for speed, ALL for strong consistency. Choose based on your use case.

❌ Common Anti-Patterns to Avoid

Anti-Pattern Why It's Bad Better Approach
SELECT * FROM large_table; Scans entire cluster, times out Always specify partition key in WHERE
Using secondary indexes on high-cardinality columns Queries still hit all nodes, slow Create dedicated table with target column as partition key
Batching for performance Batches add overhead, slow down writes Use concurrent async writes instead
Storing large blobs (>1MB) in columns Increases latency, network overhead Store in object storage (S3), reference in Cassandra
Reading before writing Doubles latency, unnecessary roundtrip Use upsert behavior (INSERT/UPDATE without checking)
Not setting gc_grace_seconds correctly Tombstones accumulate, queries slow down Set gc_grace_seconds = 10 days (minimum replication delay)

Top 10 CQL Interview Questions

Master these questions to ace your Cassandra interviews:

1 What's the difference between partition key and clustering key?
▼

Partition Key:

  • Determines which nodes store the data (via consistent hashing)
  • Must be included in WHERE clause for all queries
  • All data with same partition key is stored together on same nodes
  • Example: PRIMARY KEY (user_id) - user_id is partition key

Clustering Key:

  • Defines sort order within a partition
  • Optional - used when you need sorted data
  • Can query ranges of clustering keys efficiently
  • Example: PRIMARY KEY (user_id, timestamp) - timestamp is clustering key, data sorted by time
-- Example showing both
CREATE TABLE user_actions (
    user_id uuid,          -- Partition key
    action_time timestamp, -- Clustering key
    action_type text,
    details text,
    PRIMARY KEY (user_id, action_time)
) WITH CLUSTERING ORDER BY (action_time DESC);
2 Why doesn't CQL support JOINs?
▼

Technical Reason: JOINs require data from multiple partitions to be brought together, which is extremely expensive in distributed systems:

  • Data is spread across multiple nodes (potentially in different data centers)
  • Coordinator would need to fetch data from many nodes, wait for responses, then merge
  • Network latency would make queries unacceptably slow
  • Defeats Cassandra's design goal of predictable, fast queries

Solution: Denormalize your data - duplicate information across tables designed for specific query patterns:

-- Instead of JOIN, create multiple tables
-- Table 1: Users by ID
CREATE TABLE users_by_id (
    user_id uuid PRIMARY KEY,
    username text,
    email text
);

-- Table 2: Users by email (denormalized!)
CREATE TABLE users_by_email (
    email text PRIMARY KEY,
    user_id uuid,
    username text
);

-- Query by ID
SELECT * FROM users_by_id WHERE user_id = ?;

-- Query by email
SELECT * FROM users_by_email WHERE email = 'user@example.com';
3 When should you use BATCH and when should you avoid it?
▼

✅ Use BATCH when:

  • Atomicity needed: All operations must succeed or fail together (same partition)
  • Denormalization writes: Updating multiple tables to keep data in sync
  • Small number of operations: 5-10 statements maximum

❌ Avoid BATCH when:

  • Bulk loading: BATCH adds overhead, use concurrent async writes instead
  • Different partitions: Cross-partition batches are slower than individual writes
  • Performance optimization: Batches are NOT for performance (common misconception!)
-- ✅ GOOD: Denormalization (atomicity matters)
BEGIN BATCH
    INSERT INTO users_by_id (user_id, username, email)
    VALUES (uuid(), 'john_doe', 'john@example.com');
    
    INSERT INTO users_by_email (email, user_id, username)
    VALUES ('john@example.com', uuid(), 'john_doe');
APPLY BATCH;

-- ❌ BAD: Bulk insert (use async writes instead)
BEGIN BATCH
    INSERT INTO events (id, data) VALUES (uuid(), 'data1');
    INSERT INTO events (id, data) VALUES (uuid(), 'data2');
    -- ... 100 more inserts
APPLY BATCH;  -- This is SLOWER than individual writes!

Performance Impact: Logged batches have ~30% overhead. For bulk operations, concurrent individual writes are 2-3x faster.

4 Explain consistency levels in CQL
▼

Consistency level determines how many replica nodes must respond before a read/write is considered successful:

Level Replicas Required Use Case
ONE 1 node Maximum speed, eventual consistency. Good for logs, analytics.
TWO 2 nodes Better durability than ONE, still fast.
THREE 3 nodes Good balance for RF=3 clusters.
QUORUM (RF/2) + 1 ⭐ Recommended default. Balance of consistency and availability.
LOCAL_QUORUM Quorum in local DC Multi-DC setups, avoid cross-DC latency.
ALL All replicas Strong consistency, but slow. Not recommended for writes.
-- Set consistency level in cqlsh
CONSISTENCY QUORUM;

-- Or in application code (Python example)
from cassandra.cluster import Cluster
from cassandra import ConsistencyLevel

cluster = Cluster(['127.0.0.1'])
session = cluster.connect('my_keyspace')

# Set default consistency
session.default_consistency_level = ConsistencyLevel.QUORUM

# Or per query
statement = session.prepare(
    "SELECT * FROM users WHERE user_id = ?"
)
statement.consistency_level = ConsistencyLevel.LOCAL_QUORUM

Strong Consistency Formula: For strong consistency: Write CL + Read CL > RF

  • Example: Write with QUORUM + Read with QUORUM > RF(3) ✅ Strong consistency
  • Example: Write with ONE + Read with ONE ≤ RF(3) ❌ Eventual consistency
5 What are tombstones and why are they important?
▼

What are Tombstones? Markers that indicate deleted data. In distributed systems, deletes can't be immediate because:

  • Data is replicated across multiple nodes
  • Nodes might be temporarily down when delete happens
  • Need to propagate delete information to all replicas

How They Work:

  1. DELETE creates tombstone with timestamp
  2. Tombstone propagates to all replicas
  3. Tombstone persists for gc_grace_seconds (default: 10 days)
  4. After grace period, compaction removes tombstone and deleted data

⚠️ The Problem: Too many tombstones degrade performance:

  • Queries must read through tombstones to find live data
  • Can cause query timeouts if thousands of tombstones exist
  • Warning log: "Read X live rows and Y tombstone cells for query"
-- ❌ BAD: Creates tombstones
DELETE FROM user_events WHERE user_id = ? AND event_time < ?;

-- ✅ GOOD: Use TTL instead (no tombstones!)
INSERT INTO user_events (user_id, event_time, event_type)
VALUES (?, ?, ?)
USING TTL 604800;  -- Auto-expire after 7 days

-- Check tombstone warnings
nodetool tablestats keyspace.table

Best Practices:

  • Use TTL for time-series data instead of DELETE
  • Avoid deleting from tables with heavy reads
  • Set appropriate gc_grace_seconds (default 10 days is usually fine)
  • Monitor tombstone warnings in logs
6 How do you handle time-series data efficiently in CQL?
▼

Challenge: Time-series data can create "hot" partitions if all data for a sensor/user goes to one partition.

✅ Solution: Time Bucketing - Include date in partition key to distribute data:

-- ❌ BAD: All sensor data in one partition (grows forever!)
CREATE TABLE sensor_data_bad (
    sensor_id text,
    timestamp timestamp,
    temperature decimal,
    PRIMARY KEY (sensor_id, timestamp)
) WITH CLUSTERING ORDER BY (timestamp DESC);
-- Problem: Partition for sensor_id='S001' keeps growing, will exceed 100MB

-- ✅ GOOD: Time bucketing with date
CREATE TABLE sensor_data_good (
    sensor_id text,
    bucket_date date,
    timestamp timestamp,
    temperature decimal,
    humidity decimal,
    PRIMARY KEY ((sensor_id, bucket_date), timestamp)
) WITH CLUSTERING ORDER BY (timestamp DESC);

-- Insert with bucketing
INSERT INTO sensor_data_good (sensor_id, bucket_date, timestamp, temperature, humidity)
VALUES ('S001', '2024-12-29', toTimestamp(now()), 22.5, 65.3);

-- Query specific day (fast! Single partition)
SELECT * FROM sensor_data_good
WHERE sensor_id = 'S001'
AND bucket_date = '2024-12-29'
ORDER BY timestamp DESC;

-- Query multiple days (hits multiple partitions)
SELECT * FROM sensor_data_good
WHERE sensor_id = 'S001'
AND bucket_date IN ('2024-12-28', '2024-12-29', '2024-12-30');

Advanced: Use TTL for automatic cleanup:

-- Auto-expire data after 90 days
INSERT INTO sensor_data_good (sensor_id, bucket_date, timestamp, temperature)
VALUES ('S001', '2024-12-29', toTimestamp(now()), 22.5)
USING TTL 7776000;  -- 90 days in seconds

Partition Size Best Practices:

  • Keep partitions under 100MB (sweet spot: 10-50MB)
  • Use hourly bucketing for high-frequency data (millions of events/day)
  • Use daily bucketing for moderate frequency (thousands of events/day)
  • Use monthly bucketing for low frequency (hundreds of events/day)
7 What's the difference between UPDATE and INSERT in CQL?
▼

Surprising Answer: They're almost the same! Both perform an "upsert" - if row exists, update it; if not, create it.

Feature INSERT UPDATE
If row doesn't exist Creates new row Creates new row
If row exists Overwrites ALL columns Updates ONLY specified columns
Syntax Must specify all columns Can update specific columns
Collections Replaces entire collection Can append/prepend/remove items
IF NOT EXISTS ✅ Supported ❌ Not supported
-- INSERT: Replaces entire row
INSERT INTO users (user_id, username, email, is_premium)
VALUES (uuid(), 'john', 'john@example.com', false);

-- If row exists, overwrites ALL columns (unspecified columns become null!)

-- UPDATE: Partial update
UPDATE users 
SET is_premium = true
WHERE user_id = ?;

-- Only updates is_premium, other columns unchanged

-- INSERT with IF NOT EXISTS (lightweight transaction)
INSERT INTO users (user_id, username, email)
VALUES (?, 'john', 'john@example.com')
IF NOT EXISTS;  -- Only inserts if row doesn't exist

-- UPDATE with collections
UPDATE user_prefs 
SET favorite_genres = favorite_genres + {'rock'}  -- Append to set
WHERE user_id = ?;

Key Insight: Unlike SQL, Cassandra doesn't distinguish between insert and update at the storage level. Both write a new value with a timestamp. The most recent timestamp wins during reads.

8 When should you use collections (list, set, map) and when should you avoid them?
▼

✅ Use Collections When:

  • Small, bounded data: Max 10-100 items per collection
  • Atomic updates: Need to update entire collection at once
  • Simple queries: Retrieve entire collection, no complex filtering
  • Examples: User tags, product attributes, contact emails

❌ Avoid Collections When:

  • Large datasets: Hundreds or thousands of items
  • Individual access: Need to query single items frequently
  • Sorting/filtering: Need to sort or filter collection items
  • Growing forever: Collection size is unbounded
Scenario ❌ Bad (Collection) ✅ Good (Separate Table)
User's 1000 orders orders list<order_id> CREATE TABLE user_orders (user_id, order_id, ...)
Product's 5 tags tags set<text> ✅ Collection is fine here
User's messages (growing) messages list<text> CREATE TABLE user_messages (user_id, message_id, ...)
-- ✅ GOOD: Small bounded collection
CREATE TABLE products (
    product_id uuid PRIMARY KEY,
    name text,
    tags set,           -- 5-10 tags max
    attributes map -- Limited key-value pairs
);

-- ❌ BAD: Large growing collection
CREATE TABLE users_bad (
    user_id uuid PRIMARY KEY,
    orders list  -- Could grow to thousands!
);

-- ✅ GOOD: Separate table for large datasets
CREATE TABLE user_orders (
    user_id uuid,
    order_date date,
    order_id uuid,
    order_total decimal,
    PRIMARY KEY ((user_id, order_date), order_id)
) WITH CLUSTERING ORDER BY (order_id DESC);

Performance Impact: Collections are stored as blobs. Reading a collection with 1000 items means reading 100KB+ of data even if you only need one item!

9 What are lightweight transactions (LWTs) and when should you use them?
▼

What are LWTs? Conditional operations that provide linearizable consistency using Paxos consensus protocol:

-- Insert only if row doesn't exist
INSERT INTO users (user_id, username, email)
VALUES (?, 'unique_user', 'user@example.com')
IF NOT EXISTS;

-- Update only if condition matches
UPDATE accounts 
SET balance = balance - 100
WHERE account_id = ?
IF balance >= 100;

-- Delete only if condition matches
DELETE FROM sessions 
WHERE user_id = ? AND session_id = ?
IF last_activity < '2024-12-01';

⚠️ Performance Cost: LWTs are 5-10x slower than normal operations because they require 4 round trips (Paxos prepare/promise/accept/commit phases)

✅ Use LWTs When:

  • Uniqueness constraints: Ensuring username/email is unique
  • Account balances: Prevent negative balances, overdrafts
  • Seat reservations: Concert tickets, flight bookings (prevent double-booking)
  • Compare-and-swap: Optimistic locking patterns

❌ Avoid LWTs When:

  • High throughput: Operations per second > 1000
  • Eventual consistency acceptable: Most use cases!
  • Idempotent operations: Safe to execute multiple times
-- ✅ GOOD: Preventing duplicate usernames
INSERT INTO users_by_username (username, user_id, email)
VALUES ('unique_user', uuid(), 'user@example.com')
IF NOT EXISTS;

-- Returns: [applied] = true (success) or false (already exists)

-- ✅ GOOD: Preventing overdraft
UPDATE bank_accounts 
SET balance = balance - 500.00
WHERE account_id = ?
IF balance >= 500.00;

-- ❌ BAD: Using LWT unnecessarily
UPDATE user_prefs 
SET theme = 'dark'
WHERE user_id = ?
IF theme = 'light';  -- Wasteful! Just use regular UPDATE

Best Practice: Design your application to avoid LWTs when possible. Most applications don't need linearizable consistency - eventual consistency is sufficient and 10x faster.

10 How do you migrate/alter tables in production?
▼

Schema changes in Cassandra are tricky because the cluster is distributed. Here's how to do it safely:

✅ Safe Operations (Non-Breaking)

-- Add new column (safe - null for existing rows)
ALTER TABLE users ADD phone_number text;

-- Add new column with default value
ALTER TABLE users ADD country text;

-- Drop column (safe but data is marked for deletion)
ALTER TABLE users DROP phone_number;

-- Rename column (metadata change only)
ALTER TABLE users RENAME old_name TO new_name;

❌ Unsafe Operations

These operations are NOT supported or cause problems:

  • Change primary key: Not possible - must create new table
  • Change column type: Not supported
  • Add/remove clustering columns: Not possible

📋 Migration Strategy for Major Changes

When you need to change primary key or column types:

-- Step 1: Create new table with desired schema
CREATE TABLE users_v2 (
    user_id uuid PRIMARY KEY,
    email text,
    username text,
    created_date date,  -- Changed from timestamp
    -- new schema
);

-- Step 2: Dual write (write to both tables)
-- In application code:
await cassandra.execute(insert_users_v1_query, params);
await cassandra.execute(insert_users_v2_query, params);

-- Step 3: Backfill old data (run as background job)
-- Use Spark/Hadoop for large datasets
SELECT * FROM users_v1;  -- Read from old table
INSERT INTO users_v2 ...;  -- Write to new table

-- Step 4: Switch reads to new table
-- Update application to read from users_v2

-- Step 5: Remove dual writes
-- Only write to users_v2 now

-- Step 6: Drop old table (after validating)
DROP TABLE users_v1;

⚠️ Production Checklist:

  • Run ALTER TABLE on one node first, let schema propagate
  • Wait for schema agreement: SELECT * FROM system_schema.columns;
  • Monitor schema version on all nodes: nodetool describecluster
  • For large changes: Use dual-write pattern, never in-place migration
  • Test rollback strategy before deploying
Schema Agreement is Critical

If nodes have different schemas, you'll get errors. Always verify schema propagation:

-- Check schema versions
nodetool describecluster

-- All nodes should show same schema version
-- If not, wait or run: nodetool reloadlocalschema

Summary: CQL Essentials

🎯

Know Your Keys

Partition keys determine data distribution. Clustering keys define sort order. Design PRIMARY KEY for your queries.

📊

Model by Query

Denormalize data. Create multiple tables. Each table should be optimized for specific query patterns.

⚖️

Choose Consistency

Use QUORUM for balance. ONE for speed. ALL for strong consistency. Match consistency to business needs.

⏱️

Use TTL Wisely

Automatic expiration with TTL prevents tombstone accumulation. Perfect for time-series and temporary data.

🚫

Avoid Anti-Patterns

No ALLOW FILTERING. No large collections. No JOINs. No reading before writing. Design around limitations.

📈

Scale Horizontally

CQL's power is linear scalability. More nodes = more throughput. Keep partitions small (<100MB).

You're Ready to Use CQL!

You now understand the fundamentals of Cassandra Query Language. Remember:

  • CQL looks like SQL but is designed for distributed systems
  • Sacrifice flexibility (no JOINs) for massive scalability
  • Design tables for queries, not for data normalization
  • Use the right tools for the right job - CQL excels at high-throughput, always-on workloads

Next Steps: Practice with real data, start small, and iterate on your data models!

Advertisement

Responsive Ad