Chapter 18 · Secondary Indexing with Storage-Attached Indexing (SAI)

Choose SAI vs Denormalized Tables vs External Search Based on Workload and Scale

Choose among SAI, denormalized Cassandra query tables, and external search from workload scale, relevance features, consistency, security, operations, and cost.

Intermediate → Advanced105–145 minutesArchitecture decision labApache Cassandra 5.0.9 · SAI · cqlsh/nodetool · Java Driver 4.19.3 optional · RF=3 · UCSLast reviewed: September 2026

Learning outcomes

AtlasMart now has three candidate architectures for product discovery: a query-specific Cassandra table, SAI on the source table, or an external search engine. The wrong decision is to choose by fashion—“indexes are easier,” “denormalize everything,” or “search engine for all filtering.” This lesson makes the choice from workload invariants, scale, consistency, relevance features, operational ownership, and measured cost.

01

Choose SAI, a denormalized Cassandra table, or external search from explicit query/SLO/relevance requirements.

02

Distinguish distributed filtering from deterministic partition reads and relevance-ranked text search.

03

Design migration, shadow-read, rollback, and reconciliation paths for each option.

04

Include security/tenant filtering, failure behavior, repair/streaming, and cost in the decision—not only query syntax.

05

Produce a production acceptance matrix that bridges naturally to Chapter 19 vector search.

Chapter 18 lab baseline

The mandatory labs continue the disposable AtlasMart course cluster: Apache Cassandra 5.0.9 in the pinned cassandra:5.0.9 image, Java 17 inside the image, cluster atlasmart-course, Docker network atlasmart-cassandra, nodes atlasmart-cass-1..3, datacenter dc1, racks rack1..rack3, 16 virtual nodes per node, NetworkTopologyStrategy with replication factor (RF) 3, and consistency level (CL) LOCAL_QUORUM unless an experiment explicitly changes it. New tables use UnifiedCompactionStrategy (UCS), gc_grace_seconds = 864000, and no default Time To Live (TTL). Authentication, client Transport Layer Security (TLS), internode TLS, and remote Java Management Extensions (JMX) are disabled only inside this isolated local learning network. Apache Cassandra Java Driver 4.19.3 is optional for latency/routing exercises; all mandatory SAI evidence is available with cqlsh, virtual tables, tracing, nodetool, and read-only filesystem inventory. Storage-Attached Indexing (SAI) is a Cassandra 5.0 feature; do not assume the same syntax or operational behavior on Cassandra 4.x, commercial distributions, or Cassandra-compatible services.

Execution and safety note

Run commands only against the disposable Apache Cassandra course lab or another explicitly approved non-production environment. Confirm node, keyspace, table, container, volume, path, and datacenter targets before destructive, failure-injection, cleanup, repair, restore, security, or topology operations. Capture current state and expected rollback/recovery evidence first; output and timings can differ by host, operating system, Java runtime, Docker/runtime, driver, and Cassandra configuration.

Core terms for this chapter

Apache Cassandra is a peer-to-peer distributed database. CQL is the Cassandra Query Language. A partition key determines a partition and is hashed into a token; token ownership and the keyspace replication strategy determine the replicas storing that partition. A request's coordinator is the Cassandra node that accepts that client request and coordinates work with replicas. A primary-key access path identifies partitions directly from the primary key and is Cassandra's most efficient lookup path. A secondary index provides an additional way to find rows by non-primary-key values. Storage-Attached Indexing (SAI) is Cassandra 5.0's storage-integrated secondary-index capability: it indexes Memtables in memory and attaches on-disk index components to SSTables. An SSTable is an immutable on-disk Cassandra storage file set; a Memtable is the mutable in-memory write buffer before flush. Selectivity describes how narrowly a predicate reduces candidate rows; a predicate matching nearly every row is low-selectivity. Fanout is the amount of token-range/replica work a distributed query must touch. Build state records whether an index is still being constructed and whether it is queryable. Streaming moves SSTable components between nodes during operations such as bootstrap, repair, rebuild, or decommission. SAI is a filtering engine, not a general relational join engine or full-text relevance/search platform.

1. Start from the business invariant, not the technology

Suppose AtlasMart has three workloads. The storefront category page needs predictable single-digit-millisecond service at high QPS and knows the category/bucket in advance. Support staff need selective combinations of brand, price, rating, and status with modest QPS and bounded LIMIT. Public product search needs typo tolerance, stemming, relevance ranking, facets, synonym handling, highlighted matches, and perhaps cross-domain documents. These are different products, so one storage path should not be forced to serve all three.

Requirement Denormalized query table SAI External search
Known high-QPS partition route Excellent Usually unnecessary fanout Possible but operationally excessive
Selective multi-column filtering Requires more projection tables Strong candidate Also possible
Numeric ranges + equality Model-specific Native SAI strength Common
Fuzzy/relevance/full text Poor fit Not a complete search engine Primary use case
Write consistency with Cassandra row Application maintains projection SAI synchronous on each replica write path Usually async ingestion/refresh semantics
Operational stack Cassandra only Cassandra only + index state additional cluster/pipeline/security/backup stack
Failure/recovery Cassandra repair + app reconciliation Cassandra + SAI build/stream state Cassandra plus search ingest/reindex/recovery

2. Compare the same AtlasMart requirement three ways

bash · verify or recreate the disposable three-node lab
# Verify the existing course cluster first.docker exec atlasmart-cass-1 nodetool versiondocker exec atlasmart-cass-1 nodetool statusdocker exec atlasmart-cass-1 java -version# If the shared course cluster does not exist, recreate the same local topology.docker network inspect atlasmart-cassandra >/dev/null 2>&1 || docker network create atlasmart-cassandradocker volume create atlasmart-cass-1-datadocker volume create atlasmart-cass-2-datadocker volume create atlasmart-cass-3-datadocker inspect atlasmart-cass-1 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-1 --hostname atlasmart-cass-1 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack1 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -v atlasmart-cass-1-data:/var/lib/cassandra cassandra:5.0.9# Wait until node 1 is UN before starting peers.docker exec atlasmart-cass-1 nodetool statusdocker inspect atlasmart-cass-2 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-2 --hostname atlasmart-cass-2 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack2 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-2-data:/var/lib/cassandra cassandra:5.0.9docker inspect atlasmart-cass-3 >/dev/null 2>&1 || docker run -d --name atlasmart-cass-3 --hostname atlasmart-cass-3 --network atlasmart-cassandra -e CASSANDRA_CLUSTER_NAME=atlasmart-course -e CASSANDRA_DC=dc1 -e CASSANDRA_RACK=rack3 -e CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch -e CASSANDRA_NUM_TOKENS=16 -e CASSANDRA_SEEDS=atlasmart-cass-1 -v atlasmart-cass-3-data:/var/lib/cassandra cassandra:5.0.9# Continue only when all three nodes are UN in dc1.docker exec atlasmart-cass-1 nodetool statusdocker exec -it atlasmart-cass-1 cqlsh
CQL · create the Chapter 18 base and query-table fixtures
CREATE KEYSPACE IF NOT EXISTS atlasmart_saiWITH replication = {'class':'NetworkTopologyStrategy','dc1':3};CREATE TABLE IF NOT EXISTS atlasmart_sai.product_catalog (    product_id uuid PRIMARY KEY,    category text,    brand text,    status text,    price_cents int,    rating int,    warehouse_region text,    updated_at timestamp) WITH compaction = {'class':'UnifiedCompactionStrategy'};CREATE TABLE IF NOT EXISTS atlasmart_sai.products_by_category_bucket (    category text,    bucket tinyint,    product_id uuid,    brand text,    status text,    price_cents int,    rating int,    PRIMARY KEY ((category,bucket),product_id)) WITH compaction = {'class':'UnifiedCompactionStrategy'};CONSISTENCY LOCAL_QUORUM;
CQL · load a small deterministic AtlasMart catalog
INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001801,'laptop','Northstar','ACTIVE',129900,5,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001802,'laptop','Northstar','ACTIVE',89900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001803,'laptop','Contoso','ACTIVE',149900,5,'us-east',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001804,'monitor','Northstar','ACTIVE',39900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001805,'monitor','Fabrikam','DISCONTINUED',29900,3,'us-east',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001806,'keyboard','Fabrikam','ACTIVE',9900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001807,'keyboard','Northstar','ACTIVE',15900,5,'us-east',toTimestamp(now()));INSERT INTO atlasmart_sai.product_catalog (product_id,category,brand,status,price_cents,rating,warehouse_region,updated_at) VALUES (00000000-0000-0000-0000-000000001808,'mouse','Contoso','ACTIVE',4900,4,'eu-west',toTimestamp(now()));INSERT INTO atlasmart_sai.products_by_category_bucket (category,bucket,product_id,brand,status,price_cents,rating) VALUES ('laptop',0,00000000-0000-0000-0000-000000001801,'Northstar','ACTIVE',129900,5);INSERT INTO atlasmart_sai.products_by_category_bucket (category,bucket,product_id,brand,status,price_cents,rating) VALUES ('laptop',0,00000000-0000-0000-0000-000000001802,'Northstar','ACTIVE',89900,4);INSERT INTO atlasmart_sai.products_by_category_bucket (category,bucket,product_id,brand,status,price_cents,rating) VALUES ('laptop',0,00000000-0000-0000-0000-000000001803,'Contoso','ACTIVE',149900,5);
CQL · option A: deterministic query table
-- Storefront: category+bucket is known before the read.CONSISTENCY LOCAL_QUORUM;SELECT product_id,brand,status,price_cents,ratingFROM atlasmart_sai.products_by_category_bucketWHERE category='laptop' AND bucket=0;
CQL · option B: SAI for selective operational discovery
CREATE INDEX IF NOT EXISTS product_brand_sai ON atlasmart_sai.product_catalog (brand) USING 'sai';CREATE INDEX IF NOT EXISTS product_price_sai ON atlasmart_sai.product_catalog (price_cents) USING 'sai';CREATE INDEX IF NOT EXISTS product_rating_sai ON atlasmart_sai.product_catalog (rating) USING 'sai';TRACING ON;SELECT product_id,category,brand,price_cents,ratingFROM atlasmart_sai.product_catalogWHERE brand='Northstar' AND price_cents < 140000 AND rating >= 4LIMIT 20;TRACING OFF;

Option C—external search—is architectural in the mandatory lab. You do not need a paid service or another multi-gigabyte container stack to learn the decision boundary. Write the required feature contract: tokenizer/language handling, relevance metric, typo/fuzzy semantics, facets/aggregations, authorization filtering, freshness SLA, ingest/replay source, deletion propagation, backup/reindex time, and consistency behavior. If those capabilities are mandatory, SAI's filtering engine should not be sold as a drop-in replacement. A free local OpenSearch/Elasticsearch/Solr experiment can be added later, but it is not required to complete this Cassandra lesson.

3. Wrong comparison: only median latency on eight rows

A technology decision must compare representative data and operating cost. For the query table, count write fanout and reconciliation effort. For SAI, capture index disk bytes, build/queryable state, SSTable indexes hit, trace fanout, compaction/streaming behavior, and write cost. For external search, include ingest delay, duplicate infrastructure, shard/index design, reindex time, authorization model, network egress, backup, and on-call ownership. Median latency on a tiny warm dataset hides all of these.

CQL · evidence snapshot for an SAI decision record
DESCRIBE TABLE atlasmart_sai.product_catalog;SELECT index_name,column_name,cell_count,indexed_sstable_count,is_building,is_queryable,       per_column_disk_size,per_table_disk_sizeFROM system_views.indexesWHERE keyspace_name='atlasmart_sai';SELECT index_name,sstable_name,cell_count,per_column_disk_size,per_table_disk_size,start_token,end_tokenFROM system_views.sstable_indexesWHERE keyspace_name='atlasmart_sai';
bash · Cassandra-side operational evidence
docker exec atlasmart-cass-1 nodetool statusdocker exec atlasmart-cass-1 nodetool tablestats atlasmart_sai.product_catalogdocker exec atlasmart-cass-1 nodetool compactionstatsdocker exec atlasmart-cass-1 nodetool netstats

4. Migration and rollback must be designed before cutover

Change Safe rollout idea Rollback
Add SAI create index; wait is_queryable on required nodes; shadow/read-compare representative queries route traffic back to prior path; drop index only after rollback confidence
Add denormalized table backfill deterministically; dual-write/idempotent event replay; compare projections stop new reads; retain/reconcile projection until deletion is safe
Add external search replay source-of-truth change stream; build index; shadow search; validate freshness/relevance/auth route search back/fail closed; retain replayable source and reindex runbook

When changing SAI options, current documentation generally requires dropping/recreating the index, which means build state becomes part of the deployment. Non-indexed reads continue, but queries requiring that index must wait until it is queryable. This is why index rollout needs an application feature flag and status check rather than a blind schema migration.

Wrong approach: “SAI is globally consistent, so we can remove all application reconciliation.”

SAI is integrated with Cassandra replica storage and regular Cassandra consistency. It does not remove replica outages, hinted handoff/repair, ambiguous client timeouts, application projection divergence, or external side effects. If a business invariant requires exactly-once cross-system behavior, solve that invariant explicitly; do not infer it from an index.

5. Production decision record

Question If yes, lean toward
Does the application know a bounded partition key at request time and need very high predictable QPS? denormalized/query-first Cassandra table
Are filters selective, CQL-supported, bounded by LIMIT, and operationally worth synchronous index cost? SAI
Do users require relevance ranking, fuzzy text, analyzers, facets, complex document search, or independent search scaling? external search
Is vector ANN similarity the main need? Chapter 19: Cassandra 5.0 vector + SAI ANN, then benchmark recall/latency against requirements
Is authorization only enforced after broad retrieval? redesign before choosing any engine; authorization must constrain retrieval safely

Check your understanding

  1. What is the strongest signal for keeping a denormalized table instead of SAI?
  2. What is a strong SAI use case?
  3. Why is SAI not a full replacement for an external search engine?
  4. What must an SAI rollout wait for?
  5. Why does Chapter 19 follow naturally?
Review the answers

1. A known, high-volume, bounded partition route whose predictable locality is itself part of the SLO.

2. Selective CQL-supported filtering across columns where maintaining many projection tables is costly and measured distributed fanout is acceptable.

3. It is primarily a filtering/indexing engine and does not promise the full relevance, fuzzy-text, analyzer, facet, document-search, or independent-scaling feature set.

4. Required indexes must be queryable on the relevant nodes before application traffic depends on them; use shadow traffic and rollback flags.

5. Cassandra 5.0 vector search uses SAI as the ANN index foundation, but adds vector dimensions, similarity metrics, recall/latency evaluation, embedding lifecycle, and authorization-filter concerns.

Production judgment

Adopt SAI only after the primary-key model and business query have been written down. Record expected cardinality/selectivity, result limit, partitions/token ranges touched, replica/DC scope, RF/CL, p50/p95/p99 latency, index build state, SSTables/indexes touched per query, SAI disk bytes, base-table disk bytes, write amplification, compaction strategy/backlog, tombstones/TTL, streaming and repair behavior, node/index failure behavior, driver timeout/retry/idempotency policy, and whether authorization is enforced before or as part of retrieval. SAI adds synchronous write work and on-disk components; it does not make a low-selectivity cluster-wide predicate free. More SAI indexes also mean more components and more write/disk cost, so index count is a workload decision rather than a schema decoration.

For security, remember that index components may contain user-derived values and must be included in the data-at-rest threat model. For managed Cassandra services, verify exact SAI version, allowed index options, query guardrails, observability surfaces, backup/restore behavior, encryption, and billing model instead of assuming open-source defaults. Do not claim SAI provides cross-row relational constraints, global linearizability, arbitrary joins, relevance scoring, fuzzy full-text semantics, or authorization isolation. Chapter 19 extends SAI into vector search. The next lesson begins with the fixed-dimension CQL vector type, embedding provenance, and similarity/schema constraints before any ANN query is trusted.

Summary and next bridge

SAI belongs between query-first Cassandra modeling and full external search—not above both. Use primary-key/query tables for deterministic locality, SAI for measured storage-attached filtering, and a search platform when product requirements exceed SAI's filtering contract. Chapter 19 now builds on SAI for vector approximate-nearest-neighbor search, where index quality must be judged by recall as well as latency.

Authoritative references

These are the version-sensitive source of truth for this lesson. Re-check them when regenerating the course because SAI capabilities, guardrails, virtual tables, and query semantics can evolve.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.