Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Cassandra CQL Indexing Reference

Indexes in Cassandra do not work like relational indexes, and that surprises many. A relational index is a global structure—query it, and all matching rows are returned. A Cassandra secondary index is local to each node—query it without a partition key, and Cassandra has to ask every node in the cluster. That is an all-node scatter query, and it does not scale.

This makes secondary indexes useful only in specific situations: queries that always include the partition key, or low-cardinality columns when combined with partition key constraints. For high-cardinality lookups like email addresses, a separate denormalized table is usually more appropriate.

SAI (Storage-Attached Indexes), available experimentally in 4.0 and production-ready in 5.0, handle more use cases efficiently. Materialized views offer another option—automated denormalization at the cost of write amplification.

This guide covers when each approach makes sense.


  • Index updates are applied synchronously as part of the write path
  • Indexes are local to each node (each node indexes only its own data)
  • Queries with partition key plus indexed column contact only partition replicas
  • Index build is asynchronous; queries may return partial results during build
  • DROP INDEX removes the index immediately from schema

Undefined Behavior

The following behaviors are undefined and must not be relied upon:

  • Global ordering: Index queries without partition key return results in undefined order
  • Latency bounds: Scatter-gather queries have unbounded latency proportional to cluster size
  • Memory usage: Large result sets from index queries may exhaust coordinator memory
  • Build time: Index build duration depends on data volume; no progress or completion guarantee
  • Result completeness during build: Queries may miss data on nodes still building the index
  • Performance consistency: Query performance varies significantly based on data distribution
Index TypeQuery TypesCardinalityProduction Status
Legacy (2i)Equality onlyLowSupported
SASIEquality, range, LIKEMediumDeprecated
SAIEquality, range, LIKE, ANNAnyRecommended (5.0+)
Query PatternNodes ContactedPerformance
pk = ? AND indexed = ?RF replicasFast
indexed = ?All nodesSlow (scatter-gather)
indexed = ? LIMIT nAll nodes (early termination)Variable
indexed > ? AND indexed < ?All nodes (SAI/SASI only)Variable
ScenarioRecommended IndexAlternative
Low cardinality + partition keyLegacy 2i or SAINone needed
High cardinality lookupSAIDenormalized table
Text search (prefix/suffix)SAIApplication-side filtering
Vector similaritySAI with ANNExternal vector database
Frequent alternative access patternMaterialized viewApplication-managed table
Failure ModeOutcomeClient Action
Index build incompletePartial resultsWait for build or query specific partitions
Node unavailableResults from available nodes onlyRetry or accept partial results
Query timeoutPartial results or exceptionAdd partition key or reduce scope
Index not foundQuery failsCreate index or use ALLOW FILTERING
VersionBehavior
AllLegacy secondary indexes (2i)
3.4+SASI indexes (experimental)
4.0+SASI officially not recommended for production
5.0+SAI (Storage-Attached Indexes) as recommended default (CEP-7)

TypeBest ForLimitations
Secondary IndexLow cardinality, occasional queriesFull cluster scan
SAI (Cassandra 5.0+)General purpose, high cardinalityRequires Cassandra 5.0+
SASI (deprecated)Prefix/suffix searchBeing replaced by SAI
Materialized ViewFrequent alternative queriesWrite amplification

-- Basic secondary index
CREATE INDEX ON users (email);
-- Named index
CREATE INDEX users_email_idx ON users (email);
-- Index on collection values
CREATE INDEX ON users (tags); -- For set/list
-- Index on map keys
CREATE INDEX ON users (KEYS(preferences));
-- Index on map values
CREATE INDEX ON users (VALUES(preferences));
-- Index on map entries
CREATE INDEX ON users (ENTRIES(preferences));
-- Index on full frozen collection
CREATE INDEX ON users (FULL(address));
-- Query by indexed column
SELECT * FROM users WHERE email = 'john@example.com';
-- With collection index
SELECT * FROM users WHERE tags CONTAINS 'premium';
-- With map index
SELECT * FROM users WHERE preferences CONTAINS KEY 'theme';
SELECT * FROM users WHERE preferences CONTAINS 'dark';
SELECT * FROM users WHERE preferences['theme'] = 'dark';

Good use cases:

  • Low cardinality columns (status, country)
  • Infrequent queries
  • Combined with partition key
  • Small result sets expected

Bad use cases:

  • High cardinality columns (user_id, email)
  • Frequently queried columns
  • Large result sets
  • Primary access pattern
-- These queries require ALLOW FILTERING or fail:
-- No partition key, no index
SELECT * FROM users WHERE age > 30; -- Error
-- Range query on indexed column
SELECT * FROM users WHERE age > 30 ALLOW FILTERING; -- Slow
-- Multiple conditions without partition key
SELECT * FROM users WHERE status = 'active' AND country = 'US';
-- Slow even if both indexed

Secondary indexes on collections have special query semantics:

Collection TypeIndex TypeQuery OperatorExample
SET/LISTValuesCONTAINSWHERE tags CONTAINS 'premium'
MAPKEYS()CONTAINS KEYWHERE preferences CONTAINS KEY 'theme'
MAPVALUES()CONTAINSWHERE preferences CONTAINS 'dark'
MAPENTRIES()[] accessorWHERE preferences['theme'] = 'dark'
FROZENFULL()= (full match)WHERE address = {street: '...', city: '...'}

Collection Index Restrictions

  • CONTAINS and CONTAINS KEY only work with indexed collections
  • Cannot combine multiple CONTAINS on same collection in one query
  • Each element in collection is indexed separately (storage overhead)
  • FULL() index requires exact match of entire frozen collection
  • Collection indexes still result in scatter-gather without partition key
-- ERROR: Cannot use multiple CONTAINS on same column
SELECT * FROM users
WHERE tags CONTAINS 'premium' AND tags CONTAINS 'verified';
-- WORKAROUND: Filter in application or use intersection

Avoid Secondary Indexes on High-Cardinality Columns

Secondary indexes on high-cardinality columns (many unique values) cause severe performance problems:

IssueImpact
Index sizeApproaches table size; one entry per unique value
Read amplificationMust check index on every node
HotspotsPopular values create uneven load
Tombstone buildupDeleted values leave tombstones in index
Memory pressureLarge indexes consume heap on each node

Example of problematic indexing:

-- BAD: High cardinality
CREATE INDEX ON users (email); -- Unique per user
CREATE INDEX ON orders (order_id); -- Unique per order
CREATE INDEX ON events (timestamp); -- High cardinality
-- BETTER: Low cardinality
CREATE INDEX ON users (account_type); -- Few values: 'free', 'premium', 'enterprise'
CREATE INDEX ON orders (status); -- Few values: 'pending', 'shipped', 'delivered'

For high-cardinality lookups, use:

  • Denormalized tables with the lookup column as partition key
  • SAI indexes (Cassandra 5.0+) which handle cardinality better
  • Application-side caching for frequently accessed values

SAI is the next-generation indexing in Cassandra 5.0+, offering better performance and more capabilities than secondary indexes.

-- Basic SAI index
CREATE CUSTOM INDEX ON users (email)
USING 'StorageAttachedIndex';
-- Named SAI index
CREATE CUSTOM INDEX users_email_sai ON users (email)
USING 'StorageAttachedIndex';
-- SAI with options
CREATE CUSTOM INDEX ON users (description)
USING 'StorageAttachedIndex'
WITH OPTIONS = {
'case_sensitive': 'false',
'normalize': 'true'
};
-- Numeric index for range queries
CREATE CUSTOM INDEX ON products (price)
USING 'StorageAttachedIndex';
-- Equality queries
SELECT * FROM users WHERE email = 'john@example.com';
-- Range queries (numeric)
SELECT * FROM products WHERE price >= 100 AND price < 500;
-- AND queries with multiple SAI columns
SELECT * FROM products
WHERE category = 'electronics'
AND price < 1000
AND in_stock = true;
-- Combined with partition key (most efficient)
SELECT * FROM orders
WHERE user_id = ?
AND status = 'pending';
FeatureSecondary IndexSAI
High cardinalityPoorGood
Range queriesNoYes
Multiple conditionsPoorBetter
Write overheadLowerHigher
Text searchNoLimited
AvailabilityAll versions5.0+
  • Still scans all nodes for queries without partition key
  • Not a replacement for proper data modeling
  • Higher storage overhead than secondary indexes
  • Text search is basic (no fuzzy matching)

SASI (SSTable Attached Secondary Index) is deprecated but still available. Use SAI instead for new deployments.

-- Prefix search index
CREATE CUSTOM INDEX ON users (username)
USING 'org.apache.cassandra.index.sasi.SASIIndex'
WITH OPTIONS = {
'mode': 'PREFIX',
'analyzer_class': 'org.apache.cassandra.index.sasi.analyzer.StandardAnalyzer',
'case_sensitive': 'false'
};
-- Contains search index
CREATE CUSTOM INDEX ON users (bio)
USING 'org.apache.cassandra.index.sasi.SASIIndex'
WITH OPTIONS = {
'mode': 'CONTAINS',
'analyzer_class': 'org.apache.cassandra.index.sasi.analyzer.StandardAnalyzer',
'case_sensitive': 'false'
};
-- Numeric sparse index
CREATE CUSTOM INDEX ON orders (total)
USING 'org.apache.cassandra.index.sasi.SASIIndex'
WITH OPTIONS = {
'mode': 'SPARSE'
};
-- Prefix search (LIKE 'prefix%')
SELECT * FROM users WHERE username LIKE 'john%';
-- Contains search (LIKE '%substring%')
SELECT * FROM users WHERE bio LIKE '%developer%';
-- Range queries
SELECT * FROM orders WHERE total > 100 AND total < 500;

Materialized views automatically maintain denormalized copies of data with different primary keys.

-- Base table
CREATE TABLE users (
user_id UUID PRIMARY KEY,
username TEXT,
email TEXT,
country TEXT,
created_at TIMESTAMP
);
-- Materialized view by email
CREATE MATERIALIZED VIEW users_by_email AS
SELECT * FROM users
WHERE email IS NOT NULL AND user_id IS NOT NULL
PRIMARY KEY (email, user_id);
-- View with filtered data
CREATE MATERIALIZED VIEW active_users_by_country AS
SELECT user_id, username, country, created_at FROM users
WHERE country IS NOT NULL
AND user_id IS NOT NULL
AND status = 'active'
PRIMARY KEY (country, created_at, user_id)
WITH CLUSTERING ORDER BY (created_at DESC);
-- Query the view like a regular table
SELECT * FROM users_by_email WHERE email = 'john@example.com';
SELECT * FROM active_users_by_country
WHERE country = 'US'
ORDER BY created_at DESC
LIMIT 100;

Requirements:

  • All base table primary key columns must be in the view's primary key
  • One new column can be added to the partition key
  • All non-null filters must use IS NOT NULL
  • View primary key must include base table primary key
-- Base table
CREATE TABLE orders (
order_id UUID,
user_id UUID,
status TEXT,
amount DECIMAL,
created_at TIMESTAMP,
PRIMARY KEY ((order_id))
);
-- Valid view: adds user_id to partition key
CREATE MATERIALIZED VIEW orders_by_user AS
SELECT * FROM orders
WHERE user_id IS NOT NULL AND order_id IS NOT NULL
PRIMARY KEY ((user_id), created_at, order_id)
WITH CLUSTERING ORDER BY (created_at DESC);
-- Invalid: order_id not in primary key
-- CREATE MATERIALIZED VIEW invalid_view AS
-- SELECT * FROM orders
-- WHERE user_id IS NOT NULL
-- PRIMARY KEY ((user_id), created_at); -- Missing order_id!
LimitationImpact
Write amplificationEvery write to base table writes to all views
Eventual consistencyViews may lag behind base table
Limited transformationsCannot use functions or aggregates
Repair complexityViews need repair too
Schema changesCannot alter view; must drop and recreate

Good use cases:

  • Small number of views per table
  • Low write throughput
  • Need guaranteed consistency with base table
  • Simple alternative access patterns

Avoid when:

  • High write throughput
  • Many views needed
  • Complex transformations required
  • Critical latency requirements

-- List all indexes
SELECT * FROM system_schema.indexes;
-- Indexes for specific keyspace
SELECT * FROM system_schema.indexes WHERE keyspace_name = 'my_keyspace';
-- Indexes for specific table
SELECT * FROM system_schema.indexes
WHERE keyspace_name = 'my_keyspace' AND table_name = 'users';
-- Drop by name
DROP INDEX IF EXISTS users_email_idx;
-- Drop by keyspace.name
DROP INDEX my_keyspace.users_email_idx;
Terminal window
# Rebuild all indexes on a table
nodetool rebuild_index my_keyspace users
# Rebuild specific index
nodetool rebuild_index my_keyspace users users_email_idx
-- List views
SELECT * FROM system_schema.views WHERE keyspace_name = 'my_keyspace';
-- Drop view
DROP MATERIALIZED VIEW IF EXISTS users_by_email;
-- Cannot alter views; must drop and recreate

DO:
✓ Use for low-cardinality columns
✓ Combine with partition key when possible
✓ Use for occasional queries
✓ Index columns queried with equality
DON'T:
✗ Index high-cardinality columns (use SAI instead)
✗ Rely on indexes for primary access patterns
✗ Use for range queries (use SAI instead)
✗ Create many indexes on single table
DO:
✓ Use for high-cardinality columns
✓ Use for range queries
✓ Combine multiple SAI indexes in queries
✓ Use with partition key for best performance
DON'T:
✗ Use as replacement for good data model
✗ Query without partition key for large datasets
✗ Expect full-text search capabilities
DO:
✓ Limit to 2-3 views per table
✓ Use for guaranteed consistency needs
✓ Use for simple access pattern changes
✓ Include all base table PK columns
DON'T:
✗ Create many views per table
✗ Use with high write throughput
✗ Use for complex data transformations
✗ Forget to repair views

Need to query by non-PK column?
├── Is this a frequent/primary access pattern?
│ └── Yes: Create a new table (denormalization)
├── Need guaranteed consistency with base table?
│ └── Yes: Consider Materialized View
├── Running Cassandra 5.0+?
│ ├── Yes: Use SAI
│ │ ├── High cardinality: SAI
│ │ ├── Range queries: SAI
│ │ └── Multiple conditions: SAI
│ │
│ └── No: Secondary Index or SASI
│ ├── Low cardinality: Secondary Index
│ ├── Prefix/contains search: SASI
│ └── High cardinality: Consider data model change
└── Is performance acceptable?
├── Yes: Keep current solution
└── No: Redesign data model