System designDeep dive10 min
Secondary indexes on sharded data
Sharding by one key makes the queries on that key fast and leaves every other query asking every shard. Secondary indexes are how the other queries find their rows, and on sharded data each kind of index makes a different trade: cheap writes and expensive reads, cheap reads and lagging results, or consistent results and a distributed transaction on every write.
1 · The problem
The shard key serves one access path, and every other query needs an index
A used-car marketplace holds 40 million listings from 60,000 dealers, sharded by dealer ID across 32 shards, about 1.25 million listings each. Dealers manage their stock all day, and every one of those queries carries the dealer ID, so each touches one shard. Buyers don't know or care which dealer has the car:
Query | Has the shard key? | Without an index |
|---|---|---|
A dealer's stock, sorted by price | Yes, dealer ID | One shard |
Toyota RAV4s, cheapest first, 20 a page | No | All 32 shards |
Is this VIN already listed? | No | All 32 shards |
Every VIN must be listed only once | No | Can't be enforced on one shard |
Sharding & partitioning introduces the two basic answers: a local index that each shard keeps for its own rows, or a global index split by the indexed value. This page follows each through its reads, its writes, its consistency and its limits, then adds the two cases they don't cover: indexes that stay on one shard by design, and uniqueness.
2 · Local indexes
A local index keeps writes on one shard and sends every read to all of them
With local indexes, each shard indexes (make, model, price) for its own 1.25 million listings. A new listing updates its row and the index entry on the same shard, in the same local transaction, so the index is never stale. The cost moves to reads. MongoDB's router broadcasts any query without the shard key to every shard, and Vitess does the same unless it has a cross-shard index to consult:
Each shard uses its index well, but all 32 must answer. The router merges 640 rows by price and returns the first 20, and the page waits for the slowest shard.
The router can't know which shard holds the 20 cheapest, so each shard sends its own top 20; MongoDB passes the limit to every shard and applies it again after merging. Skipping pages is worse: the router can't pass a skip down, so for page 50 every shard returns its first 1,000 rows:
Rows sent to the router for one page: 32 shards × (skip + limit). A cursor that starts after the last price seen keeps every page as cheap as the first.
Local indexes suit queries that are rare, or that need every shard anyway, like analytics. For the marketplace's busiest buyer query, every search costs 32 shards' work, and adding shards adds work to each search instead of capacity (Latency covers why waiting on many servers makes the slow tail common).
3 · Index by parent
An index that starts with the shard key never leaves its shard
Not every secondary index needs to be global. The dealer's stock page searches within one dealer: by price, by age in stock, by model. An index whose key starts with the shard key, (dealer_id, price), keeps every entry on the same shard as its rows, so both the write and the read stay local and consistent.
Databases name this pattern differently:
- Spanner calls it an interleaved index. It's the better choice whenever queries filter by the parent's key, because the index entry lands in the same split as the row and writes stay local.
- DynamoDB calls it a local secondary index: same partition key, different sort key, and it allows strongly consistent reads.
Either way, the index answers only questions about one parent, which is exactly what the dealer's pages ask.
Aspect | Index starting with the shard key | Index on another column |
|---|---|---|
Example | (dealer_id, price) | (make, model, price) |
Read | One shard | Every shard if local, one if global |
Write | One shard, same transaction | Local: one shard. Global: two places |
Limit | Only answers per-dealer questions | Pays in reads or in writes |
The catch is size per parent. DynamoDB caps everything that shares a partition key, the items plus their local index entries, at 10 GB on tables with local secondary indexes. A marketplace with one dealer far larger than the rest should check that before relying on it (Hot partitions & skew).
4 · Global indexes
A global index is sharded by the indexed value, so reads ask one place and writes touch two
A global index is a second sharded table: entries keyed by (make, model, price), partitioned by make and model. All RAV4 entries live together, sorted by price, so the buyer's search reads one index partition and stops after 20 entries. The price is on the write side: a new listing goes to its dealer's shard, and its index entry goes to whichever partition owns "Toyota RAV4":
Two writes on two machines for one listing. Whether the second happens later or in the same transaction is the defining choice of a global index.
DynamoDB's global secondary indexes do it later. The table write succeeds first, and DynamoDB updates the index asynchronously, normally within a fraction of a second, so a query on the index can briefly miss a listing the table already has. A dealer who lists a car and searches for it straight away may not see it. Reads that must be current go to the table by its key.
The index is also a separate set of partitions with its own capacity. If a global index can't absorb the write rate, DynamoDB throttles writes to the table itself. And its key decides its hot spots: every RAV4 listing, price change and sale lands on the one partition holding RAV4s. A higher-cardinality key spreads that load, such as make, model and region together (Hot partitions & skew).
5 · Consistent globals
A consistent global index makes every write a distributed transaction
Distributed SQL databases update global indexes in the same transaction as the row, so a query on the index never misses a committed write. The price: the index entry usually lives in a different split from the row, so the write needs a two-phase commit across splits, which adds latency and CPU:
Each global index adds a participant to every write that changes its columns. A listing with three global indexes can involve up to four splits.
So the choice between asynchronous and synchronous global indexes is a choice between a moment of stale search results and slower writes everywhere (Distributed transactions: 2PC & sagas covers the protocol; Distributed SQL covers how the ranges commit). For a marketplace, search results a second old are fine; for "has this coupon code been used?", they are not.
6 · Uniqueness
A unique constraint across shards needs one place that owns each value
Every VIN must appear in at most one active listing. A local unique index can't promise that: two dealers on different shards can each insert the same VIN, and neither shard sees the other. MongoDB states the rule directly: a unique index on a sharded collection works only if it begins with the full shard key, which makes (dealer_id, vin) unique, not vin.
The fix is to give each value a single owner, a record keyed by the value itself, so the claim lands on exactly one shard and that shard can refuse a second one. MongoDB's answer is a separate proxy collection sharded on the unique field. DynamoDB's documented pattern writes an extra item whose key is the value, in the same transaction as the real item, with a condition that it doesn't exist yet:
Claiming a VIN in the same DynamoDB transaction as the listing
TransactWriteItems:
Put pk = "listing#9f3c…" if attribute_not_exists(pk)
dealer = 812, vin = "1HGCM82633A004352", price = 21400
Put pk = "vin#1HGCM82633A004352" if attribute_not_exists(pk)
# a second dealer listing the same VIN:
# TransactionCanceledException: [None, ConditionalCheckFailed]Either both items are written or neither is, so there is never a listing without its claim or a claim without its listing. Selling or deleting the car must remove the claim in the same transaction. Vitess offers the same thing built in, a unique lookup index kept consistent with the table without two-phase commit.
7 · In practice
Choose an index per query, by how often it runs and how fresh it must be
The marketplace ends up with four approaches, each matched to a query. The dealer's stock uses an index that starts with the dealer ID. The model search uses an asynchronous global index, since a second of lag is invisible to buyers. VIN uniqueness uses a claim item written in the same transaction. Free-text search across many filters goes to a search engine fed by a change stream (Search & inverted indexes, Change data capture).
Approach | Reads | Writes | Fresh? |
|---|---|---|---|
Starts with the shard key | One shard | One shard | Always |
Local, other column | Every shard | One shard | Always |
Global, asynchronous | One partition | Two places, the index later | Lags briefly |
Global, synchronous | One partition | Distributed transaction | Always |
- List queries without the shard key
With their rate, page size and how fresh results must be.
- Prefer indexes that start with the shard key
Most per-owner queries never need to leave the shard.
- Global indexes for hot cross-shard reads
Asynchronous if a moment of lag is fine; synchronous if not.
- Check index keys for skew
One popular value can make one index partition hot.
- Give unique values an owner
A claim record keyed by the value, in the same transaction.
- Page with cursors
Skip-based pages multiply rows read by the shard count.
Check yourself
4 questions
Takeaways
Remember this
- Queries without the shard key need a secondary index, and each kind trades read cost, write cost and freshness differently.
- Local indexes keep writes on one shard but send every read to every shard, and skip-based paging multiplies the rows read.
- Indexes that start with the shard key stay on one shard for reads and writes; use them for per-owner queries.
- Global indexes read one partition but write to two places: asynchronously with brief lag, or synchronously with a distributed transaction.
- Uniqueness across shards needs a record keyed by the value itself, written atomically with the row.