Skip to content

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

Cassandra CQL Secondary Index Queries

Secondary indexes enable queries on non-primary-key columns without requiring the partition key. This capability comes with significant trade-offs that application developers must understand to avoid performance degradation and operational incidents.


Cassandra's data model is optimized for partition key lookups. Primary key queries contact a deterministic set of replica nodes (typically 3 in a RF=3 cluster). Secondary index queries, lacking partition key constraints, must coordinate across multiple nodes to locate matching data.

Primary Key Query: Client → Coordinator → RF Replicas → Response
(3 nodes)
Secondary Index Query: Client → Coordinator → Token Range Coverage → Response
(potentially all nodes)
Query TypeNodes ContactedLatency ProfileScalability
Partition keyRF replicasPredictable, lowConstant with cluster growth
Partition key + indexRF replicasPredictableConstant
Index only (global)All token rangesVariable, higherDegrades with cluster size

Secondary indexes are appropriate when:

  • Queries frequently include the partition key alongside the indexed predicate
  • The indexed column has low-to-medium cardinality (tens to thousands of unique values)
  • Query result sets are small (typically < 10,000 rows)
  • The workload tolerates higher latency compared to primary key queries

Secondary indexes are inappropriate when:

  • Queries never include the partition key
  • The indexed column has very high cardinality (approaching uniqueness)
  • The indexed column has very low cardinality (2-3 values like boolean)
  • Large result sets are expected
  • Low, predictable latency is required

  • Queries with partition key constraint contact only replicas for that partition
  • Results are returned in clustering order within each partition
  • Index queries respect the specified consistency level
  • Results include matching rows visible to the consistency level (subject to index build state and replica synchronization)
  • Multiple indexed predicates (AND) are evaluated correctly

What Secondary Index Queries Do NOT Guarantee

Section titled “What Secondary Index Queries Do NOT Guarantee”

Undefined Behavior

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

  • Global result ordering: Without partition key, results across partitions have undefined order
  • Latency bounds: Index queries without partition key have unbounded latency proportional to cluster size
  • Complete results during topology changes: Adding/removing nodes may cause temporary result inconsistencies
  • Memory usage: Large result sets may exhaust coordinator memory
  • Timeout behavior: Partial results may be returned on timeout (implementation-dependent)
  • Performance consistency: Query times vary significantly based on data distribution and cluster load
Query PatternExecution ModelNode Contact
WHERE pk = ? AND indexed_col = ?Single partition scanRF replicas
WHERE indexed_col = ?Scatter-gatherAll nodes (token range coverage)
WHERE indexed_col = ? LIMIT nScatter-gather with limitAll nodes, stops when limit reached
WHERE indexed_col > ? AND indexed_col < ?Range scan (SAI/SASI)All nodes
Consistency LevelBehavior on Index Query
ONE/LOCAL_ONEEach node returns from local index; coordinator merges
QUORUM/LOCAL_QUORUMEach shard uses QUORUM; coordinator merges
ALLAll replicas for each shard must respond
ScenarioCompleteness
All nodes healthyComplete results (within consistency level)
Some nodes downResults from available nodes only
Index building on some nodesPartial results from those nodes
Coordinator timeoutPartial results possible
Failure ModeOutcomeClient Action
Some nodes unavailablePartial results (if CL can be met)Retry or accept partial data
Coordinator timeoutPartial results or timeout exceptionAdd partition key or reduce scope
Index not foundQuery failsCreate index or use ALLOW FILTERING
Memory exhaustionQuery failsAdd LIMIT or partition constraints
VersionBehavior
AllLegacy secondary indexes (2i)
3.4+SASI indexes (experimental, not recommended for production)
4.0+SAI available (experimental)
5.0+SAI production-ready and recommended for new deployments

Cassandra provides three secondary index implementations, each with different query capabilities:

CapabilityLegacy 2iSASISAI
AvailabilityAll versions3.4+ (experimental)5.0+
Production statusSupportedNot recommendedRecommended
Equality (=)YesYesYes
Range (<, >, <=, >=)NoYesYes
LIKE prefix ('foo%')NoYesYes
LIKE suffix ('%foo')NoCONTAINS modeYes
LIKE contains ('%foo%')NoCONTAINS modeYes
IN clauseYesYesYes
Multiple predicates (AND)Scatter-gatherSingle-passSingle-pass
Collection indexingYesLimitedYes
Vector search (ANN)NoNoYes

Legacy indexes support only equality predicates:

-- Create legacy secondary index
CREATE INDEX users_country_idx ON users (country);
-- Equality query (supported)
SELECT * FROM users WHERE country = 'US';
-- With partition key (optimal)
SELECT * FROM users WHERE user_id = ? AND country = 'US';
-- Range query (NOT supported - will error)
SELECT * FROM users WHERE country > 'A'; -- ERROR
-- Index on SET
CREATE INDEX ON users (tags);
SELECT * FROM users WHERE tags CONTAINS 'premium';
-- Index on MAP keys
CREATE INDEX ON users (KEYS(attributes));
SELECT * FROM users WHERE attributes CONTAINS KEY 'department';
-- Index on MAP values
CREATE INDEX ON users (VALUES(attributes));
SELECT * FROM users WHERE attributes CONTAINS 'engineering';
-- Index on MAP entries
CREATE INDEX ON users (ENTRIES(attributes));
SELECT * FROM users WHERE attributes['role'] = 'admin';

SASI extends query capabilities with range and text search operations:

-- Create SASI index with PREFIX mode (default)
CREATE CUSTOM INDEX ON users (email)
USING 'org.apache.cassandra.index.sasi.SASIIndex'
WITH OPTIONS = {'mode': 'PREFIX'};
-- Prefix matching
SELECT * FROM users WHERE email LIKE 'john%';
-- Create SASI index with CONTAINS mode
CREATE CUSTOM INDEX ON products (description)
USING 'org.apache.cassandra.index.sasi.SASIIndex'
WITH OPTIONS = {
'mode': 'CONTAINS',
'analyzer_class': 'org.apache.cassandra.index.sasi.analyzer.StandardAnalyzer',
'case_sensitive': 'false'
};
-- Substring matching
SELECT * FROM products WHERE description LIKE '%database%';
-- Range queries on numeric SASI index
CREATE CUSTOM INDEX ON products (price)
USING 'org.apache.cassandra.index.sasi.SASIIndex';
SELECT * FROM products WHERE price >= 100 AND price <= 500;

SASI Production Status

SASI is marked as experimental and is not recommended for production use. Known issues include:

  • Unbounded memory consumption during queries
  • No compaction-aware index maintenance
  • Limited testing at scale

Use SAI (Cassandra 5.0+) for production deployments requiring these capabilities.

SAI provides the most comprehensive query capabilities with production-grade reliability:

-- Create SAI index
CREATE CUSTOM INDEX ON users (email)
USING 'StorageAttachedIndex';
-- Case-insensitive text index
CREATE CUSTOM INDEX ON users (username)
USING 'StorageAttachedIndex'
WITH OPTIONS = {'case_sensitive': 'false'};
-- Equality
SELECT * FROM users WHERE email = 'user@example.com';
-- Range queries
SELECT * FROM orders WHERE total >= 100.00 AND total < 1000.00;
-- Text pattern matching
SELECT * FROM users WHERE username LIKE 'john%';
SELECT * FROM users WHERE username LIKE '%smith';
SELECT * FROM users WHERE username LIKE '%admin%';
-- Multiple SAI predicates (efficient single-pass)
SELECT * FROM orders
WHERE status = 'pending'
AND total > 100
AND created_at > '2024-01-01';
-- Index SET/LIST elements
CREATE CUSTOM INDEX ON users (tags) USING 'StorageAttachedIndex';
SELECT * FROM users WHERE tags CONTAINS 'vip';
-- Index MAP entries
CREATE CUSTOM INDEX ON users (ENTRIES(metadata)) USING 'StorageAttachedIndex';
SELECT * FROM users WHERE metadata['tier'] = 'enterprise';
-- Index UDT field (non-frozen UDT)
CREATE CUSTOM INDEX ON customers (address.city) USING 'StorageAttachedIndex';
SELECT * FROM customers WHERE address.city = 'New York';
-- Create vector index
CREATE CUSTOM INDEX ON documents (embedding)
USING 'StorageAttachedIndex'
WITH OPTIONS = {'similarity_function': 'cosine'};
-- Approximate nearest neighbor search
SELECT doc_id, title, similarity_cosine(embedding, ?) AS score
FROM documents
ORDER BY embedding ANN OF ?
LIMIT 10;
-- Combined filtering with vector search
SELECT doc_id, title
FROM documents
WHERE category = 'technical'
ORDER BY embedding ANN OF ?
LIMIT 5;

Understanding how secondary index queries execute is essential for predicting performance.

When the partition key is included, the query contacts only RF replica nodes:

-- Efficient: partition key + index predicate
SELECT * FROM orders
WHERE customer_id = ? -- partition key
AND status = 'pending'; -- indexed column
Partition-Restricted Index QueryPartition-Restricted Index QueryApplicationCoordinatorNode ANode DApplicationApplicationCoordinatorCoordinatorNode A(Replica)Node A(Replica)Node D(Replica)Node D(Replica)QueryRequestRequest1. Local index lookup2. Filter results1. Local index lookup2. Filter resultsResultsResultsResultPartition replicas (RF=3)

Performance characteristics:

  • Latency comparable to primary key queries
  • Bounded by RF, not cluster size
  • Predictable resource consumption

Without partition key, the coordinator must contact nodes across all token ranges:

-- Expensive: index predicate only
SELECT * FROM orders WHERE status = 'pending';
Global Index Query (No Partition Key)Global Index Query (No Partition Key)ApplicationCoordinatorNode ANode BNode C...ApplicationApplicationCoordinatorCoordinatorNode ANode ANode BNode BNode CNode C......QueryRequestRequestRequestRequestLocal resultsLocal resultsLocal resultsLocal resultsMerged resultAll token ranges contacted

Performance characteristics:

  • Latency increases with cluster size
  • Bounded by slowest responding node (tail latency)
  • Resource consumption proportional to cluster size
  • With SAI: Adaptive execution may reduce nodes contacted for LIMIT queries

Query PatternExpected LatencyVariability
Partition key only1-5 msLow
Partition key + index2-10 msLow
Global index (small result)10-100 msMedium
Global index (large result)100 ms - secondsHigh
Global index + poor selectivitySeconds - timeoutVery high
FactorImpactMitigation
Cluster sizeGlobal queries scale linearlyInclude partition key when possible
Index selectivityLow selectivity = more data scannedChoose columns with medium cardinality
Result set sizeLarge results consume memory and networkUse LIMIT clause
Node heterogeneitySlowest node determines latencyMonitor tail latency, maintain consistent hardware
Compaction backlogMore SSTables = slower index lookupsMonitor compaction, tune strategy

Index queries consume resources differently than primary key queries:

ResourcePrimary Key QueryGlobal Index Query
Coordinator memoryMinimalProportional to result aggregation
Network bandwidthRF × result sizeNodes × local results
Disk I/O (per node)Target partition onlyFull index scan
CPUMinimalIndex traversal + filtering

Anti-Pattern 1: Indexing High-Cardinality Columns

Section titled “Anti-Pattern 1: Indexing High-Cardinality Columns”
-- ANTI-PATTERN: Indexing nearly-unique values
CREATE INDEX ON users (user_id); -- Already a primary key
CREATE INDEX ON events (event_id); -- UUID, unique per row
CREATE INDEX ON logs (timestamp); -- Microsecond precision, near-unique

Why it fails:

  • Index becomes as large as the data itself
  • Each index lookup returns very few rows
  • Read amplification: many SSTables consulted for few results

Alternative approaches:

  • Use the column as part of the primary key
  • Create a denormalized lookup table

Anti-Pattern 2: Indexing Very Low-Cardinality Columns for Global Queries

Section titled “Anti-Pattern 2: Indexing Very Low-Cardinality Columns for Global Queries”
-- ANTI-PATTERN: Indexing columns with 2-3 values for global queries
CREATE INDEX ON users (is_active); -- true/false
CREATE INDEX ON orders (status); -- pending/completed/cancelled (if few values)
-- Then querying without partition key:
SELECT * FROM users WHERE is_active = true; -- returns ~50% of all data

Why it fails for global queries:

  • Each index entry points to a large fraction of all rows
  • Queries return excessive data
  • No selectivity benefit

When low-cardinality indexes are acceptable:

  • When queries always include the partition key (limits scan to single partition)
  • When combined with other selective predicates

Alternative approaches:

  • Include in composite partition key if appropriate
  • Create separate tables per status
  • Combine with other predicates that provide selectivity

Anti-Pattern 3: Global Queries Without LIMIT

Section titled “Anti-Pattern 3: Global Queries Without LIMIT”
-- ANTI-PATTERN: Unbounded global query
SELECT * FROM orders WHERE status = 'pending';
-- BETTER: Bounded result set
SELECT * FROM orders WHERE status = 'pending' LIMIT 100;

Why it fails:

  • Coordinator accumulates all results in memory
  • Network transfer of potentially millions of rows
  • Client memory exhaustion
  • Long query times leading to timeouts

Anti-Pattern 4: Using Indexes for Batch Analytics

Section titled “Anti-Pattern 4: Using Indexes for Batch Analytics”
-- ANTI-PATTERN: Full-table scan via index
SELECT COUNT(*) FROM events WHERE level = 'ERROR';
SELECT * FROM logs WHERE timestamp > '2024-01-01' ALLOW FILTERING;

Why it fails:

  • Secondary indexes are designed for OLTP, not analytics
  • Full scans should use Spark/analytics tools
  • Consumes cluster resources affecting production queries

Alternative approaches:

  • Use Apache Spark with Cassandra connector
  • Maintain pre-aggregated summary tables
  • Use dedicated analytics cluster

Anti-Pattern 5: Querying Frequently Updated Indexed Columns

Section titled “Anti-Pattern 5: Querying Frequently Updated Indexed Columns”
-- ANTI-PATTERN: Index on frequently changing column
CREATE INDEX ON sessions (last_activity);
-- Every user action updates this:
UPDATE sessions SET last_activity = now() WHERE session_id = ?;

Why it fails:

  • Each update creates new index entries
  • Old entries become tombstones
  • Tombstone accumulation degrades read performance
  • Compaction overhead increases

Alternative approaches:

  • Store mutable data separately from indexed data
  • Use TTL-based expiration instead of updates
  • Reconsider whether this query pattern is necessary

Anti-Pattern 6: Relying on ALLOW FILTERING

Section titled “Anti-Pattern 6: Relying on ALLOW FILTERING”
-- ANTI-PATTERN: Using ALLOW FILTERING instead of proper indexes
SELECT * FROM users WHERE age > 25 ALLOW FILTERING;
SELECT * FROM events WHERE type = 'click' AND value > 100 ALLOW FILTERING;

Why it fails:

  • Full table scan regardless of selectivity
  • Contacts every node, reads every partition
  • No optimization possible
  • Performance degrades as data grows

Alternative approaches:

  • Create appropriate secondary indexes
  • Redesign data model for query patterns
  • Use denormalized tables

PracticeRationale
Always include LIMITBounds memory consumption and latency
Include partition key when possibleRestricts query to RF nodes
Prefer SAI over 2i/SASIBetter performance, production-ready
Use prepared statementsReduces parsing overhead, enables caching
Avoid SELECT *Retrieve only needed columns
PracticeRationale
Index medium-cardinality columnsBest selectivity/overhead ratio
Create indexes during low-traffic periodsIndex building consumes resources
Monitor index size relative to dataDetect cardinality issues early
Test with production-like data volumesPerformance varies with scale
-- Pattern: Partition-first query design
-- Store order status per customer for efficient queries
CREATE TABLE orders_by_customer_status (
customer_id UUID,
status TEXT,
order_date TIMESTAMP,
order_id UUID,
total DECIMAL,
PRIMARY KEY ((customer_id, status), order_date, order_id)
) WITH CLUSTERING ORDER BY (order_date DESC);
-- Efficient: queries specific customer's pending orders
SELECT * FROM orders_by_customer_status
WHERE customer_id = ? AND status = 'pending'
LIMIT 50;
-- Pattern: Secondary index for cross-partition queries
CREATE TABLE orders (
order_id UUID PRIMARY KEY,
customer_id UUID,
status TEXT,
total DECIMAL,
created_at TIMESTAMP
);
CREATE CUSTOM INDEX orders_status_idx ON orders (status)
USING 'StorageAttachedIndex';
CREATE CUSTOM INDEX orders_total_idx ON orders (total)
USING 'StorageAttachedIndex';
-- Acceptable: global query with LIMIT and high selectivity
SELECT * FROM orders
WHERE status = 'fraud_review'
AND total > 10000
LIMIT 100;

# Per-table read latency (includes index queries)
org.apache.cassandra.metrics:type=Table,keyspace=*,scope=*,name=ReadLatency
# SAI-specific metrics
org.apache.cassandra.metrics:type=StorageAttachedIndex,name=*
# Index condition processing time
org.apache.cassandra.metrics:type=Table,keyspace=*,scope=*,name=IndexSummaryOffHeapMemoryUsed
-- Identify slow queries in system log
-- Look for: "Slow query" messages with index predicates
-- Check index size and status
SELECT * FROM system_schema.indexes WHERE keyspace_name = 'my_keyspace';
-- Examine table statistics including index info
-- Use: nodetool tablestats keyspace.table
SymptomPossible CauseInvestigation
High P99 read latencyGlobal index queriesCheck for missing partition keys in queries
Timeouts on index queriesLarge result setsAdd LIMIT, check selectivity
Memory pressureResult accumulationMonitor coordinator heap, add LIMIT
Increasing read latencyIndex size growthCheck cardinality, consider redesign

Use this framework to determine whether a secondary index is appropriate:

Secondary Index Decision FrameworkSecondary Index Decision FrameworkSecondary index appropriateYesQuery always includes partition key?NoCreate denormalized lookup tableToo highColumn cardinality medium (100-10000)?YesDesign partition key to include this columnToo lowcardinality too low?OKReconsider data model or use analytics toolsNoResult sets typically < 1000 rows?YesCreate denormalized lookup tableNo - frequent updatesColumn rarely updated?YesLatency tolerance > 10ms acceptable?YesNo - low latency requiredEvaluate with production-like testingDesign partition key to include this column