Apache Cassandra Deployment Guide: NoSQL for High Availability and Write-Heavy Workloads

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Quick Start: Cassandra Running in 5 Minutes

If you’ve ever watched a PostgreSQL master choke at 50,000 writes per second, you already understand why Cassandra exists. It was designed specifically for distributed, write-heavy workloads — no single point of failure, no master node, just a ring of peers handling reads and writes equally.

Let’s get a single node running so you can follow along with the deeper sections below.

Install via Docker (fastest path)

# Pull and start a Cassandra node
docker run --name cassandra-dev \
  -p 9042:9042 \
  -d cassandra:5.0

# Wait ~30 seconds for startup, then connect
docker exec -it cassandra-dev cqlsh

Once inside cqlsh, create a keyspace and a test table:

-- Create keyspace with replication factor 1 (single node)
CREATE KEYSPACE IF NOT EXISTS app_dev
  WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};

USE app_dev;

CREATE TABLE events (
  user_id UUID,
  event_time TIMESTAMP,
  event_type TEXT,
  payload TEXT,
  PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

-- Insert a row
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'login', '{"ip": "1.2.3.4"}');

That’s the core loop: keyspace → table → write. Now let’s understand why it scales.

Deep Dive: How Cassandra Actually Works

The Ring Architecture

Every node in a Cassandra cluster owns a token range — a slice of a giant hash ring. When you write a row, Cassandra hashes the partition key and routes that write to whichever node owns that token range. No leader election, no failover delay. If one node goes down, the replicas covering its range keep serving traffic without missing a beat.

Compare that to PostgreSQL streaming replication or MongoDB replica sets — both still depend on a primary for writes. Lose the primary and you’re waiting for election. With Cassandra, every node is equal.

Replication Factor and Consistency Levels

Production clusters run at least 3 nodes with a replication factor of 3. Every row lives on 3 different machines. The consistency level then controls how many of those replicas must acknowledge a read or write before Cassandra returns success.

-- Write with QUORUM consistency (majority of replicas must confirm)
CONSISTENCY QUORUM;
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'purchase', '{"amount": 99}');

-- For maximum write throughput, use ONE (fire and replicate async)
CONSISTENCY ONE;
INSERT INTO events (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'pageview', '{"url": "/pricing"}');

The rule I apply on every project: writes at ONE or ANY for maximum throughput, reads at LOCAL_QUORUM to avoid serving stale data. For analytics and event-logging pipelines, this CAP tradeoff is exactly right.

Data Modeling: Think Queries First

Nearly every team migrating from a relational database gets tripped up here. In Cassandra, you design tables around your access patterns — not normalized entities.

Say you have users generating events and you need two query patterns:

  • Get the latest 100 events for a specific user
  • Get all events of type “purchase” in the last hour

Those are two separate tables in Cassandra:

-- Table 1: events by user
CREATE TABLE events_by_user (
  user_id UUID,
  event_time TIMESTAMP,
  event_type TEXT,
  payload TEXT,
  PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

-- Table 2: events by type and hour bucket
CREATE TABLE events_by_type (
  event_type TEXT,
  hour_bucket TEXT,   -- e.g. '2026-05-01-14'
  event_time TIMESTAMP,
  user_id UUID,
  payload TEXT,
  PRIMARY KEY ((event_type, hour_bucket), event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

Yes, you store the same data twice. Expected — disk is cheap, and cross-partition joins simply don’t exist in Cassandra.

Advanced Usage: Production Cluster Setup

Multi-Node Cluster with Docker Compose

Below is a 3-node setup you can spin up locally to test real replication behavior:

# docker-compose.yml
version: '3.8'
services:
  cassandra-1:
    image: cassandra:5.0
    container_name: cassandra-1
    environment:
      - CASSANDRA_CLUSTER_NAME=MyCluster
      - CASSANDRA_DC=datacenter1
      - CASSANDRA_SEEDS=cassandra-1
    ports:
      - "9042:9042"
    volumes:
      - cassandra1_data:/var/lib/cassandra

  cassandra-2:
    image: cassandra:5.0
    container_name: cassandra-2
    environment:
      - CASSANDRA_CLUSTER_NAME=MyCluster
      - CASSANDRA_DC=datacenter1
      - CASSANDRA_SEEDS=cassandra-1
    depends_on:
      - cassandra-1
    volumes:
      - cassandra2_data:/var/lib/cassandra

  cassandra-3:
    image: cassandra:5.0
    container_name: cassandra-3
    environment:
      - CASSANDRA_CLUSTER_NAME=MyCluster
      - CASSANDRA_DC=datacenter1
      - CASSANDRA_SEEDS=cassandra-1
    depends_on:
      - cassandra-2
    volumes:
      - cassandra3_data:/var/lib/cassandra

volumes:
  cassandra1_data:
  cassandra2_data:
  cassandra3_data:
docker compose up -d

# Wait ~90 seconds, then check cluster status
docker exec cassandra-1 nodetool status

A healthy cluster looks like this:

Datacenter: datacenter1
=======================
Status=Up/Down
|/ State=Normal/Leaving/Joining/Moving
--  Address     Load       Tokens  Owns
UN  172.18.0.2  89.42 KiB  16      33.3%
UN  172.18.0.3  84.17 KiB  16      33.4%
UN  172.18.0.4  91.08 KiB  16      33.3%

UN means Up + Normal. All three nodes own roughly equal token ranges — the ring is working correctly.

Connecting from Python

from cassandra.cluster import Cluster
from cassandra.policies import DCAwareRoundRobinPolicy
from cassandra import ConsistencyLevel
from cassandra.query import SimpleStatement
import uuid
from datetime import datetime

# Connect to the cluster (point to any seed node)
cluster = Cluster(
    ['127.0.0.1'],
    port=9042,
    load_balancing_policy=DCAwareRoundRobinPolicy(local_dc='datacenter1')
)
session = cluster.connect('app_dev')

# Prepared statement (always use these — they're pre-compiled)
insert_stmt = session.prepare("""
    INSERT INTO events_by_user (user_id, event_time, event_type, payload)
    VALUES (?, ?, ?, ?)
""")
insert_stmt.consistency_level = ConsistencyLevel.ONE

# Write an event
session.execute(insert_stmt, [
    uuid.uuid4(),
    datetime.utcnow(),
    'purchase',
    '{"amount": 149, "currency": "USD"}'
])

# Read latest events for a user
user_id = uuid.UUID('some-user-uuid-here')
rows = session.execute(
    SimpleStatement(
        "SELECT * FROM events_by_user WHERE user_id = %s LIMIT 10",
        consistency_level=ConsistencyLevel.LOCAL_QUORUM
    ),
    [user_id]
)
for row in rows:
    print(row.event_time, row.event_type)

One thing I always do before bulk importing data into Cassandra: prep the CSV files first. When I need to quickly convert CSV to JSON for data imports, I use toolcraft.app/en/tools/data/csv-to-json — runs entirely in the browser so no data leaves your machine. Handy when you’re dealing with customer data under GDPR and can’t push it through an online API.

Practical Tips from the Field

Compaction Strategy Matters

Cassandra defaults to SizeTieredCompactionStrategy, which handles write-heavy workloads well. But when your data has a clear time dimension — event logs, sensor readings, user activity — switch to TimeWindowCompactionStrategy:

ALTER TABLE events_by_user
  WITH compaction = {
    'class': 'TimeWindowCompactionStrategy',
    'compaction_window_unit': 'HOURS',
    'compaction_window_size': 1
  };

SSTables get grouped by time window, so expiring old data with TTL costs a fraction of what STCS would. On a table with 30-day retention, I’ve seen read latency drop by ~40% after this switch.

Use TTL for Log-Style Data

-- Auto-expire events after 90 days (7776000 seconds)
INSERT INTO events_by_user (user_id, event_time, event_type, payload)
VALUES (uuid(), toTimestamp(now()), 'login', '{"ip": "1.2.3.4"}')
USING TTL 7776000;

nodetool Is Your Best Friend

# Check overall cluster health
nodetool status

# See per-table read/write stats
nodetool tablestats app_dev.events_by_user

# Force compaction on a table (do this during low traffic)
nodetool compact app_dev events_by_user

# Flush memtable to disk (useful before taking a snapshot backup)
nodetool flush app_dev

Avoid These Common Mistakes

  • Large partitions: If one user generates 10 million events, that partition balloons fast. Add time buckets to your partition key (e.g., (user_id, day_bucket)) to keep partition size under 100MB.
  • ALLOW FILTERING: Never use this in production queries. It triggers full cluster scans. Redesign your table instead.
  • Unbounded IN clauses: WHERE user_id IN (1000 UUIDs) kills performance. Batch those with async queries in your application layer.
  • Skipping repair: Run nodetool repair weekly. It reconciles data across replicas that drifted during node outages. Skip it long enough and you will eventually read stale data — guaranteed.

When Cassandra Is the Wrong Choice

High-volume time-series, event streams, IoT sensor data, user activity logs — Cassandra handles all of these without breaking a sweat. The moment you need complex joins, transactions across multiple rows, or ad-hoc analytical queries, reach for something else. PostgreSQL covers the transactional side; ClickHouse handles analytical workloads at scale. Picking the right tool upfront beats a painful data migration six months in.

Share: