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.
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.
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.
| Choice | Buys you | Costs you |
|---|---|---|
| Add a secondary index | Reads on that attribute become a lookup, not a scan | Every write must maintain it; extra storage |
| Covering index | Read answers from the index alone: no base-table hop | More storage; more to keep in sync per write |
| Non-covering index | Smaller, just value → ids | One base-table fetch per matched id (read amplification) |
| Async maintenance (index service) | Writes stay fast; index scales independently | Consistency lag: the index can be momentarily stale |
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.
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.