Secondary index service

The base table is filed by id; a secondary index is a second filing cabinet keyed by some other attribute, so a query doesn't have to read every row to find its matches.

The idea

A primary store is keyed by its primary key: the row id. Looking up order 7 is instant. But "find all orders with status = shipped" asks about a non-key attribute, and the only honest answer is to read every row and check. That full scan gets slower as the table grows.

A secondary index is a separate mapping from attribute value → list of primary keys. Maintain it on every write, and a query becomes one fast lookup instead of a scan. The two operations that matter: the index is updated on write, and looked up on read. It can even live as its own service kept in sync with the base store, which is where consistency lag sneaks in.

Base table · key = id 5 Acme cables · shipped 7 Bolt kit · pending 9 Desk lamp · pending 11 Cam mount · shipped 12 USB hub · cancelled Index · key = status pending [ 7, 9 ] shipped [ 5, 11 ] cancelled [ 12 ]
A query by status would have to scan every base row, unless an index already knows the answer.

How it works

Every write touches two things: the base row and the index buckets for the attribute. On an update you must remove the id from the old value's bucket and add it to the new one: forgetting the removal leaves a stale entry. A read then skips the scan: look up the bucket, get the ids. A non-covering index stops there and fetches each row from the base table; a covering index also stores the needed columns, so it answers alone.

def write_status(id, new):           # insert / update
    old = base[id].status
    base[id].status = new            # 1. update base row
    if old is not None:
        index[old].remove(id)        # 2a. drop OLD bucket entry
    index[new].add(id)               # 2b. add to NEW bucket
    # covering index also stores the columns the query needs:
    # index[new][id] = {status:new, name:base[id].name}

def read_by_status(value):           # query on a non-key attribute
    ids = index.get(value, [])       # one lookup, no scan
    if COVERING:
        return ids                   # index already has the answer
    return [base[i] for i in ids]    # non-covering: N extra fetches

The index can live in the same store (maintained synchronously on the write path) or in a separate service fed by change events (maintained asynchronously). Async keeps writes fast but lets the index trail the base table for a moment: reads can briefly miss a just-written row.

Trade-offs

ChoiceBuys youCosts you
Add a secondary indexReads on that attribute become a lookup, not a scanEvery write must maintain it; extra storage
Covering indexRead answers from the index alone: no base-table hopMore storage; more to keep in sync per write
Non-covering indexSmaller, just value → idsOne base-table fetch per matched id (read amplification)
Async maintenance (index service)Writes stay fast; index scales independentlyConsistency lag: the index can be momentarily stale

Watch out for

Worked example

Order 7 ships. The write sets base[7].status = shipped, then maintains the index: remove 7 from index["pending"] (now [9]) and add it to index["shipped"] (now [5, 7, 11]). Later a query asks for status = shipped. With a non-covering index the lookup returns [5, 7, 11] and then fetches all three base rows: one lookup plus three reads. With a covering index those columns are already stored, so the same query answers from the index alone, never touching the base table. Both beat the alternative: a full scan of all five rows.

Check yourself

A query returns 500 ids from a non-covering index but is still slow. What's the most likely reason?

Coach note: the index lookup itself is cheap. The cost is the one base-table read per matched id that a non-covering index forces. Take another pass if it feels slippery; this is the read-amplification idea.