Partitioning & Sharding

Cross-Partition Operations: Paying for What the Split Took Away

Joins, transactions, aggregations, secondary indexes and unique constraints were all cheap when the data sat on one machine, because one storage engine could see all of it. Across partitions each becomes a distributed protocol with its own latency, its own failure modes and its own consistency story. The lever that matters most is chosen long before any of them: the partition key.

▶ Run the lab

The question this answers

The question

Which operations get harder once the data is no longer co-located, and what does each one cost now?

The guarantee — the property claimed, and its scope

Single-partition operations retain the storage engine’s local guarantees — typically atomic, isolated and single-round-trip. Operations spanning P partitions guarantee nothing beyond per-partition atomicity unless a commit protocol is added; their latency is the maximum over P responses, not the mean; and any global invariant requires either coordination or an explicitly weakened definition.

Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.

What a node knows — observation versus inference

A partition knows its own data completely and everyone else’s not at all. A coordinator knows only what the partitions have told it, and each of those answers was true at a different instant. Without a snapshot mechanism, a cross-partition read returns a composite of several different moments that never simultaneously existed.

A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
scatter-gatherdistributed joinglobal indexaggregationtransactions

The fan-out tax on reads

A query that cannot be answered from one partition must be sent to several and merged. Three costs stack up, and the third is the one that surprises people.

Work multiplies. P partitions each do work; the coordinator merges. For a LIMIT 10 ORDER BY x across 50 partitions, each must return its top 10 — 500 rows fetched to produce 10.

Latency becomes a maximum, not a mean. The query finishes when the slowest partition answers. If each partition independently answers within its p99 latency, the probability that *all* P are fast is 0.99^P. At P = 50 that is 61%; at P = 100 it is 37%. So a 100-way fan-out experiences its per-partition p99 on nearly two thirds of queries — the median of the fan-out is roughly the p99 of a shard. This is Fan Out to 100 and the Component’s Tail Becomes the System’s Median and it is the single most important number in this section.

The result is not a snapshot. Each partition answered at a different moment. A cross-partition COUNT can double-count a row that moved between partitions, or miss one, unless the reads are taken at a common snapshot timestamp — which requires either a global clock (There Is No Global Clock) or an explicit coordination step.

The mitigations are the usual ones and they are all about *avoiding* the fan-out rather than speeding it up: choose the partition key to match the dominant query, maintain a differently-partitioned copy for the other access pattern (Materialized Views: A Read Model That Lags), or, when you must fan out, use hedging (Send a Second Request After p95 and Take Whichever Answers First) and per-partition deadlines (Pass the Remaining Budget Down, Not a Fresh One) so one slow shard cannot hold the whole query.

Partitions queriedP(all within p99)Effective experience
1assumption99%The p99 is the p99
10assumption90%One query in ten hits a slow shard
50assumption61%Two queries in five hit a slow shard
100assumption37%The median query hits a slow shard — the p99 has become the norm
Fan-out width against the probability that every partition responds within its p99

Joins, aggregations and the operations that survive decomposition

Joins. A join is cheap when both sides of the join key live in the same partition — co-partitioning. Choose the same partition key for orders and order_items and the join is local, forever. When they are not co-partitioned there are two strategies, both borrowed from parallel databases: broadcast the small side to every partition holding the large side (cheap when one side genuinely is small), or shuffle both sides by the join key so matching rows meet on the same node (The Shuffle Is the Job). Shuffle is correct and expensive; it moves data proportional to the input size, over the network, per query.

Aggregations decompose exactly when the function is associative and commutative. SUM, COUNT, MIN, MAX and AVG (as a sum/count pair) reduce partially at each partition and merge trivially — the fan-out cost is a handful of numbers per partition regardless of data size. That is why they feel free.

DISTINCT, COUNT(DISTINCT), MEDIAN and percentiles do not decompose. There is no partial result smaller than the data: to know the global distinct count you must, in principle, see every value. The practical resolutions are exact-but-expensive (shuffle by the value being counted so each distinct value lands in one place) or approximate-but-cheap (HyperLogLog for cardinality, t-digest or KLL for quantiles). Sketches are mergeable by construction, which restores the associative-merge property at the cost of a bounded error — this is precisely why they exist and why every large analytics system uses them.

Top-N is the interesting middle case: it decomposes, but only if each partition returns N candidates, which is exact for ORDER BY on a stored column and *not* exact when the ranking depends on a global quantity, such as a score normalised across the whole dataset.

  • Associative + commutative → partial aggregate per partition, merge at the coordinator. Cheap and exact.
  • Not decomposable → shuffle for exactness, or a mergeable sketch for a bounded approximation.
  • Top-N → each partition returns N; correct only if the ordering key is local.
  • Any aggregate over a moving dataset → needs a snapshot, or the number is a composite of several instants.

Secondary indexes: the same trade in different clothes

You partition users by user_id and now need to look one up by email. The index has to live somewhere, and there are exactly two choices.

Local (per-partition) index. Each partition indexes its own rows. Writes are cheap and atomic — the index entry is written in the same partition, in the same transaction, as the row. Reads by the indexed field must ask *every* partition, because the email could be anywhere. This is the fan-out tax on every lookup.

Global (term-partitioned) index. The index is itself partitioned, by the indexed value. A lookup by email hits exactly one partition. But now writing a user touches two partitions — the row’s and the index term’s — so the write is a distributed operation with all that implies: it can partially fail, leaving an index entry with no row or a row with no index entry.

Almost every system resolves the write side by making the global index asynchronous: the write commits locally and the index is updated shortly after. That is a real weakening — there is a window in which a lookup by email does not find a user who exists, and after a delete, one that does not. It is usually the right trade, and it must be a decision rather than a surprise.

The framing worth keeping: a global secondary index is a materialized view partitioned differently from its source, and every property of Materialized Views: A Read Model That Lags — asynchronous update, bounded staleness, the need for reconciliation — applies to it.

AspectLocal (document-partitioned)Global (term-partitioned)
Write costprotocolOne partition, atomic with the rowTwo partitions — needs a commit protocol or asynchrony
Read costprotocolFan-out to every partitionOne partition
ConsistencytypicalIndex and row always agreeIndex lags the row, or the write becomes distributed
Failure modetypicalSlow reads at high partition countsIndex entries without rows, or rows without entries
Good fortypicalFiltering within a known partition; low partition countsPoint lookups by an alternate key across the whole dataset
Local versus global secondary index

Transactions and uniqueness: where it stops being a performance question

Everything above trades latency and money. Two things trade correctness, and they need naming separately.

Atomicity across partitions requires an agreement protocol. Two-Phase Commit: Buying Atomicity With a Promise gives it to you and gives you its failure modes in the same box: a coordinator crash between prepare and commit leaves participants holding locks with no authority to release them (The Blocking Window: When 2PC Stops and Waits), which is an availability outage on those rows for as long as the coordinator is gone. Sagas: Trading Isolation for Availability avoid the blocking by giving up isolation: the steps commit independently and a failure is handled by compensation, which is a new action rather than an undo (A Refund Is Not a Rollback). The third option is the one to reach for first — choose the partition key so the transaction fits inside one partition. Entity groups, aggregate roots, and "partition by the thing transactions are scoped to" are all the same idea, and it is the cheapest correctness technique available in this domain.

Uniqueness across partitions is subtler and catches people out. If users are partitioned by user_id and emails must be unique, the uniqueness check cannot be done in the user’s partition — the conflicting email lives elsewhere. A unique index is therefore *a differently-partitioned structure*, and creating a user atomically with its email claim is inherently a two-partition operation. The standard resolution is a reservation protocol: claim the email in the email-partitioned store first with a conditional write, then create the user, then confirm — with a sweeper for abandoned claims. See Distributed Uniqueness: One Name, Many Shards for the full treatment; the point here is that it is a *partitioning* consequence, not a database feature you lost.

And when the operation spans services rather than partitions of one store, it is the same problem with worse tooling — Atomicity Stops at the Process Boundary.

Reserving a unique email across two partitions before creating the usertypical
CoordinatorPartition of hash(email)Partition of hash(user_id)claim e if absent: deliveredclaim e if absentclaimed (pending): deliveredclaimed (pending)insert user: deliveredinsert userok: deliveredokconfirm claim: sent, never arrives — dropped in flightconfirm claimdropped — never arrivescreate user with email e (write) at t=0create user with email econditional insert e → pending(user_id) (write) at t=2conditional insert e → pending(user_id)insert user row (write) at t=6insert user rowmark claim confirmed (decide) at t=10mark claim confirmedt=0time →t=10
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswritedecide
The confirm is lost, so the claim stays `pending` forever and the email is neither usable nor released. Nothing errored. This is why the protocol needs a sweeper with a timeout and a rule for what a pending claim means to a competing registration — the reservation is only half the design.

The design rule that makes all of this moot

Every technique above is a way of paying for a boundary you drew. The cheapest technique is to draw the boundary somewhere else.

Enumerate your operations. For each, ask: does it touch one partition or many? Then choose the partition key so that the *frequent* and the *correctness-critical* operations are single-partition, and only the rare, tolerant ones fan out. A system where 99% of operations are single-partition scales almost linearly; a system where 30% are cross-partition does not scale at all, because the cross-partition work grows with both traffic and partition count.

When two access patterns genuinely conflict — orders by customer *and* orders by product — the answer is usually not a cleverer key. It is two differently-partitioned copies of the data, kept in sync asynchronously, each serving one pattern. That is Materialized Views: A Read Model That Lags-shaped thinking, it costs storage and staleness, and it is nearly always cheaper than making every query fan out.

Key points

  • A fan-out to P partitions experiences the slowest of P responses: at P = 100, the median query hits a per-partition p99.
  • Aggregations decompose exactly when the merge is associative and commutative; DISTINCT and percentiles need a shuffle or a mergeable sketch.
  • Joins are local when both sides share a partition key, and require broadcast or shuffle when they do not.
  • A global secondary index is a materialized view partitioned by the indexed term — one-partition reads, two-partition writes, usually resolved by making it asynchronous.
  • Atomicity and uniqueness across partitions cost correctness machinery, not just latency — and both are avoidable by choosing the partition key to contain them.

The chain, answered

Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.

How it works
  • A coordinator receives an operation and determines which partitions it touches.
  • Single-partition: forward it and return the answer. One hop, local guarantees intact.
  • Multi-partition read: send sub-queries with individual deadlines, collect, merge, and decide what to do about partitions that did not answer.
  • Multi-partition aggregate: push a partial aggregate down; merge partials if the function permits, otherwise shuffle or approximate.
  • Multi-partition write requiring atomicity: run a commit protocol, or decompose into independently committed steps with compensations.
  • Global constraint: consult the structure partitioned by the constrained value, reserve, then act, then confirm — with a sweeper for the ambiguous middle.
What can fail at the boundary
  • One partition of a fan-out is slow or unavailable, so the whole query is slow or incomplete.
  • Partitions answer at different times, producing an aggregate over a state that never existed at any instant.
  • A distributed write commits on some partitions and not others.
  • The coordinator crashes mid-protocol, leaving participants in prepared state holding locks.
  • An asynchronous global index falls behind, so lookups miss recent rows and find deleted ones.
  • A reservation is claimed but never confirmed or released, permanently retiring a value.
How it fails — what an operator sees
  • Fan-out tail: end-to-end p99 far worse than any individual partition’s p99, with every per-shard dashboard looking healthy. The operator sees a latency problem with no slow component, which is the defining signature of fan-out.
  • Partial results reported as complete: a fan-out query treats a timed-out partition as empty and returns an answer that is quietly wrong. Counts drift down during incidents and nobody notices because there is no error.
  • Orphaned index entries: an asynchronous global index contains rows that no longer exist. The operator sees lookups returning ids that 404 on fetch, at a rate proportional to delete volume.
  • Stuck reservations: a claim on a unique value is pending forever after a lost confirm. The operator sees users unable to register with an email that appears unused, and no error anywhere in the logs.
  • Locks held by an absent coordinator: rows on several partitions are unreadable or unwritable until an operator intervenes. The operator sees a small, precise set of keys timing out while everything else is fine.
  • Snapshot-free aggregation drift: the same report run twice within a minute returns different totals with no writes in between, because the two runs sampled partitions at different instants.
Where coordination is required
  • None for single-partition operations — the entire reason to choose a key that keeps operations local.
  • A fan-out read needs no agreement, only collection — but it needs a common snapshot if the answer must be internally consistent, and a snapshot is a coordination mechanism.
  • Atomic multi-partition writes need agreement on the commit decision. That is the expensive kind: it blocks, and its availability is the product of the participants’ availabilities.
  • Global uniqueness needs a single serialisation point per constrained value. Notice that the point is *per value*, not global — which is what makes it affordable, and what distinguishes it from a global lock.
  • The design objective throughout is to keep coordination out of the common path and confine it to the rare operations that genuinely need it (Coordination Avoidance: Restructuring the Problem Instead of Paying for It).
What still holds under failure
  • Single-partition operations continue normally for every partition that is up — partitioning preserves this containment even when cross-partition work is failing entirely.
  • A fan-out degrades to a partial answer. Whether that is acceptable is an application decision and must be made explicitly; defaulting to "return what we got" is how silent wrongness enters.
  • An in-doubt two-phase commit blocks a specific set of rows and nothing else — small blast radius, indefinite duration.
  • Asynchronous indexes and views continue serving stale data during an incident, which is usually preferable to failing, provided the staleness is visible.
How it recovers
  • Detect: distinguish "complete answer" from "partial answer" at the API level. A response that cannot say which it was cannot be recovered from.
  • Contain: per-partition deadlines and a policy for missing partitions, decided per query type rather than globally.
  • Recover: re-drive incomplete distributed writes from a durable record of intent; this is what a saga log or a transaction coordinator log is for.
  • Reconcile: sweep for orphaned index entries, pending reservations and in-doubt transactions on a schedule. Every cross-partition mechanism needs a sweeper, and the sweeper is the part that gets forgotten.
  • Verify: run periodic invariant checks that a single node cannot enforce — "every user has exactly one email claim, and every claim points at a user that exists" (Reconciliation Is a Component, Not a Cleanup Script).
How you would know
  • Ratio of single-partition to multi-partition operations, by operation type. It is the single best predictor of whether the system will keep scaling.
  • Fan-out width per query — the number of partitions touched. A distribution with a long tail means some queries have no usable index.
  • Rate of partial results returned, and which partitions were missing.
  • Index lag for every asynchronous global index, in seconds and in rows.
  • Count of in-doubt transactions and pending reservations older than the sweeper threshold. Both should be near zero and neither is monitored by default.
When it helps
  • When the cross-partition operation is rare and the alternative is a data model that fights every common query.
  • Analytics and reporting, where fan-out is inherent and latency expectations are measured in seconds.
  • Global indexes for a genuinely global lookup key — login by email is the canonical case, and fanning out every login is not viable.
  • Distributed transactions for the small number of operations where partial application is unacceptable and compensation is not possible.
When it hurts
  • On the hot path. A fan-out in the request path of every page view puts the p99 of your slowest shard in front of every user.
  • At high partition counts, where the tail-latency arithmetic turns a rare slow shard into the common case.
  • When atomic multi-partition writes become routine — you have rebuilt a single-node database with network latency between its pages and none of its guarantees.
  • When partial results are silently treated as complete, which converts a latency problem into a correctness one.
Simpler alternatives
  • Repartition so the operation is local. The most effective option and the one requiring the most work.
  • Denormalise: store the joined data together at write time. Trades write amplification and staleness for local reads, and is the standard answer in wide-column and document stores.
  • A second, differently-partitioned copy of the data serving the other access pattern (Materialized Views: A Read Model That Lags).
  • Mergeable sketches instead of exact distinct counts and percentiles — bounded error, associative merge, orders of magnitude cheaper.
  • Sagas instead of two-phase commit when the steps can be compensated, trading isolation for availability.
  • Accept eventual consistency for the constraint and reconcile after the fact — viable when duplicates are detectable and cheap to resolve, and not viable for money or identity.

Scatter-gather: the query finishes when the slowest partition answers

Scatter-gather: the query finishes when the slowest partition answers
Fan a query across P partitions and the user waits for the maximum of P draws, not the mean. The per-shard p99 becomes the fan-out's median long before P gets large.
user p50
79 ms
user p99
318 ms
P(all within their p99)
61%
rows fetched for 10
500
383 ms0
user p99 as the fan-out widensuser p50one shard's p99 (90 ms)P = 1 · 2 · 5 · 10 · 20 · 30 · 50 · 75 · 100
CostWhat it is hereWhy it is not a tuning problem
work multiplies50 shards × 10 rows = 500 rows fetched to return 10ORDER BY across partitions needs every partition's top-K before it can pick the global top-K
latency is a maximum79 ms median, 318 ms p99The median of the fan-out approaches the p99 of a shard as P grows
the result is not a snapshot50 answers, 50 different momentsA COUNT can double-count a row that moved partitions, unless the reads share a snapshot timestamp
Fanning out to 50 backends turns a 90 ms per-call p99 into 318 ms for the user: the slowest of 50 is what they wait for, so a rare slow call becomes a common slow request. Hedging after 60 ms cuts that to 112 ms (206 ms saved) and costs 3% extra backend requests. That extra load is the whole trade — and it lands on a backend that is slow, which is the case where independence between the two copies is least true.
Every mitigation for this is about avoiding the fan-out rather than speeding it up: choose the partition key to match the dominant query, keep a second copy partitioned for the other access pattern, and — when you must fan out — hedge and give each shard its own deadline so one slow shard cannot hold the whole query.
assumptionShard latencies are modelled as independent log-normal draws fitted through the p50 and p99 you set. Real shards correlate — a shared switch, a shared storage tier, a deploy — and correlation makes the fan-out tail worse than this, not better.

What people believe, and what is true

Claim

A distributed join is just a join with more machines.

Reality

It is a network data-movement problem. Either one side is broadcast to every partition, or both sides are shuffled by the join key — and the shuffle moves data proportional to the input, per query.

Claim

Adding a global secondary index is free, like adding a local one.

Reality

It makes every write a two-partition operation. Systems hide this by making the index asynchronous, which means the index and the data are allowed to disagree for a while.

Claim

Fan-out is fine because each shard is fast.

Reality

You experience the slowest shard, not the average one. At 100 partitions, the median query encounters a per-shard p99.

Claim

Unique constraints are a database feature, so a distributed database gives them to me.

Reality

Only within a partition, unless the system runs a cross-partition protocol on your behalf and charges you for it. Global uniqueness is a differently-partitioned structure plus a reservation protocol, whoever implements it.

Claim

A cross-partition COUNT is exact.

Reality

Without a common snapshot it is a composite of several instants and can double-count or miss rows that changed during the query.

Go deeper

Only the levels this lesson can honestly fill — a missing level is a claim nobody had.

Overview

Once data is split, joins, transactions, aggregations, indexes and unique constraints all need a distributed mechanism. The cheapest fix is a partition key that keeps the important operations inside one partition.

Practical

List your operations and mark each single- or multi-partition before choosing the key. Push aggregates down when the merge is associative; use sketches when it is not. Give every fan-out a per-partition deadline and make a partial result explicitly distinguishable from a complete one.

Advanced

A global secondary index is a materialized view partitioned by the index term. That reframing tells you everything: the write is a two-partition operation, making it asynchronous buys availability at the cost of a staleness window, and it needs a reconciliation sweep because the two copies will diverge.

Internals

Every cross-partition mechanism has an ambiguous middle state — prepared but not committed, claimed but not confirmed, indexed but not stored — and every one of them needs a sweeper with a timeout and a documented rule for what the middle state means to a concurrent operation. The sweeper is not an optimisation; it is the half of the protocol that makes the other half safe, and it is the half that gets left out.

Apply it

Build it, then break it
  • 🔧 Instrument one endpoint to record how many partitions it touches. Plot the distribution; the tail is where your missing index is.
  • 🔧 Take an existing COUNT(DISTINCT ...) report and re-implement it with a mergeable cardinality sketch. Measure both the cost and the error.
Reason about this
  • A search page fans out to all 64 shards and its p99 is 900ms while every shard reports a 40ms p99. Explain the arithmetic and propose two fixes.
  • After a partial network incident, your global index contains 12,000 entries pointing at rows that do not exist. What produced them, and what should have prevented it?
Interview questions
  • 💬 You shard orders by customer_id. Product managers want "top selling products this week". What are your options and what does each cost?
  • 💬 Explain why a fan-out to 100 shards has a p99 much worse than any individual shard.
  • 💬 Design globally unique emails on a store partitioned by user id. What is the failure mode of your design and what cleans it up?