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.