Skip to main content

Top-N and Percentile Queries Across Shards

Ranked and quantile questions are the two aggregate shapes that break naive fan-out. Both return plausible numbers when implemented wrongly, which is why they survive review and fail an audit. This guide gives the correct over-fetch rule for top-N, replaces deep pagination with keyset traversal, and merges sketches for percentiles. It extends Cross-Shard Aggregation Patterns inside Cross-Partition Querying & Aggregation Strategies.

Prerequisites

Step 1 β€” Apply the over-fetch rule for top-N

The rule has one line and one reason:

Merging ordered per-shard lists into a global ranking Three shards each return their own ordered list of ten candidates. The coordinator merges the heads of the three lists with a heap, taking the largest remaining value at each step, and stops after the requested number of rows. It never needs to sort all thirty candidates, only to compare the current heads. shard_0 (ordered)c14 Β· 9,120c02 Β· 7,410c31 Β· 6,004… 7 more shard_1 (ordered)c77 Β· 8,880c19 Β· 5,320c50 Β· 4,910… 7 more shard_2 (ordered)c08 Β· 9,004c61 Β· 3,110c44 Β· 2,880… 7 more heap merge, take 3: c14 9,120 Β· c08 9,004 Β· c77 8,880 3 comparisons per output row The merge is cheap; the fetch is not. Each shard had to sort its own candidates to produce an ordered list, which is why the per-shard query should be served by an index on the ranking column rather than by a sort at request time. With a rollup table holding each shard's top 100, this merge answers any global top-N up to 100 without touching the shards at all.
-- each shard, for a global LIMIT 10
SELECT customer_id, sum(amount_cents) AS spend
FROM   orders
WHERE  placed_at >= '2026-08-01' AND placed_at < '2026-09-01'
GROUP  BY customer_id
ORDER  BY spend DESC, customer_id      -- tiebreaker makes the order total
LIMIT  10;                             -- N, never fewer
# coordinator: merge the per-shard lists and cut once
import heapq

def top_n(shard_results, n):
    merged = heapq.merge(*shard_results, key=lambda r: (-r.spend, r.customer_id))
    return list(itertools.islice(merged, n))

The reason to fetch N from every shard is the worst case: all ten global winners could be on one shard. Fetching nine from each is correct almost always and wrong exactly when the data is skewed, which is when someone is looking.

Operational note: The tiebreaker is not cosmetic. Without a total order, two shards can return the same value in different orders and the merge becomes non-deterministic between runs β€” which looks like data instability to whoever is reading the dashboard.

DBA tip: Push the GROUP BY down. Sending raw order rows to the coordinator to aggregate there moves orders of magnitude more data for the same answer.

Why deep pages cost so much more in a fan-out With an offset of m and a limit of N, each shard must return m plus N rows. For page one of a twenty-row list across sixteen shards that is 320 rows; for page five hundred it is 160,320 rows, all discarded except twenty. Keyset pagination sends a cursor instead of an offset, so every page costs the same 320 rows regardless of depth. OFFSET pagination across 16 shards, 20 rows per page page 1 page 50 page 500 320 rows fetched, 20 kept 16,320 rows fetched, 20 kept 160,320 rows fetched, 20 kept β€” each shard sorts 10,020 rows and discards 10,000 Keyset pagination β€” same 16 shards page 1 β€” 320 rows page 500 β€” 320 rows, using WHERE (spend, id) < (last_spend, last_id) Keyset pagination is not an optimisation here β€” it is what makes deep pagination possible at all across a fan-out.

Step 2 β€” Replace OFFSET with a cursor

-- each shard: rows strictly after the cursor, in the same total order
SELECT customer_id, spend
FROM   customer_spend_mv
WHERE  (spend, customer_id) < (%(last_spend)s, %(last_customer_id)s)
ORDER  BY spend DESC, customer_id
LIMIT  20;
# the cursor is the last row of the previous page, opaque to the client
def next_page(coordinator, cursor, n=20):
    results = coordinator.fan_out(SQL, cursor=cursor, limit=n)
    page = merge_and_cut(results, n)
    return page, (page[-1].spend, page[-1].customer_id) if page else None

Operational note: Row-value comparison ((a, b) < (x, y)) is a single index-friendly predicate in PostgreSQL, provided an index exists on (spend DESC, customer_id). Splitting it into spend < x OR (spend = x AND id < y) usually loses the index.

DBA tip: Keyset pagination cannot jump to an arbitrary page number. If the product requires page numbers, cap the reachable depth β€” nobody paginates to page 500 by hand, but a crawler will.

Step 3 β€” Merge sketches for percentiles

Averaging per-shard percentiles is wrong; merging digests is right:

-- on each shard: return sketch state, not a number
CREATE EXTENSION IF NOT EXISTS tdigest;

SELECT tdigest(response_ms, 100) AS digest
FROM   request_log
WHERE  logged_at >= now() - interval '1 hour';
# coordinator: merge the digests, then ask the merged sketch
from tdigest import TDigest

def p99_across_shards(digests):
    merged = TDigest()
    for d in digests:
        merged = merged + d            # merging is associative and commutative
    return merged.percentile(99)
Three routes to a cross-shard p99 Averaging per-shard p99 values moves 16 numbers and is wrong by an unbounded amount. Merging t-digest sketches moves about 100 kilobytes and lands within a fraction of a percent of the true value. Streaming every raw value to the coordinator is exact and moves 2.4 gigabytes, which is only viable for small windows. Method Data moved Accuracy Use for avg of per-shard p99 merge t-digests stream raw values 16 numbers ~100 KB 2.4 GB wrong, unbounded error Β±0.2% typical at the tail exact nothing dashboards, SLOs, alerts billing, regulatory reports The first row is the one that ships by accident, because it produces a number that looks right and moves almost no data. It is wrong in the direction that matters: with skewed shards, the averaged p99 systematically understates the real tail. Sketch merging is associative, so digests can also be pre-aggregated per partition and merged again later β€” the same state serves per-shard, per-region and global percentiles without recomputation.

Operational note: Store the digest state rather than the percentile in any rollup table. A stored p99 can only answer the question it was computed for; stored digest state answers p50, p95 and p99.9 later, and can be re-merged across arbitrary groupings.

DBA tip: Size the digest compression parameter for tail accuracy, not for size. The default of 100 is accurate to within a fraction of a percent at p99 and costs a few kilobytes β€” increasing it buys precision you will not measure.

Step 4 β€” Bound the fan-out for these shapes specifically

Ranked and quantile queries are the ones most worth pre-aggregating:

-- per-shard hourly rollup: sketch state plus the top slice
CREATE TABLE spend_hourly (
    shard_name  text        NOT NULL,
    bucket_ts   timestamptz NOT NULL,
    top_100     jsonb       NOT NULL,      -- customer_id/spend pairs
    latency_td  tdigest     NOT NULL,
    PRIMARY KEY (shard_name, bucket_ts)
);

The dashboard then merges 16 small rows instead of fanning out to 16 databases, and the fan-out runs once an hour on a schedule rather than on every page load.

Operational note: Keeping the top 100 per shard makes a global top 10 or top 50 exact from the rollup. A global top 200 would not be, so the rollup’s slice size must exceed the largest N the product exposes.

SRE tip: Log the requested N alongside the rollup’s slice size. The day someone adds a β€œtop 500” report, that log line is what prevents a silently wrong answer.

Verification

Test correctness against a known-skewed dataset, which is where the wrong implementations fail:

def test_top_n_with_all_winners_on_one_shard():
    seed_shard("shard_0", [(f"c{i}", 1_000_000 - i) for i in range(10)])   # all winners
    for s in ("shard_1", "shard_2", "shard_3"):
        seed_shard(s, [(f"x{i}", 100 + i) for i in range(50)])
    result = top_n_across_shards(n=10)
    assert [r.customer_id for r in result] == [f"c{i}" for i in range(10)]

def test_percentile_merge_matches_exact_within_tolerance():
    exact = numpy.percentile(all_values, 99)
    merged = p99_across_shards(per_shard_digests)
    assert abs(merged - exact) / exact < 0.005
test_top_n_with_all_winners_on_one_shard PASSED
test_percentile_merge_matches_exact_within_tolerance PASSED  (merged 418.2 vs exact 417.9)

Failure mode table

Failure mode Root cause SRE mitigation
A row missing from a global top-N shards were asked for fewer than N rows, or the order was not total fetch at least N per shard and add a tiebreaker column; assert with a skewed-data test
Percentiles understate the real tail per-shard percentiles were averaged rather than merged merge sketch state; store digests, not computed percentiles, in rollups
Deep pagination times out OFFSET m forced every shard to sort and discard m rows switch to keyset pagination with a row-value cursor; cap reachable depth for crawlers

FAQ

How many rows must each shard return for a correct top-N?

At least N, always. The worst case is that all N global winners live on one shard, so asking any shard for fewer than N can miss one. With an OFFSET of m, each shard must return m plus N rows, which is why deep pagination through a fan-out is expensive and should be replaced with keyset pagination.

Can percentiles be computed by averaging per-shard percentiles?

No, and the error is unbounded. A median of medians is not the median, and weighting by row count does not fix it. Either merge sketch state β€” t-digest or equivalent β€” or stream the raw values to the coordinator, which is only viable for small result sets.

Is an approximate percentile acceptable in production?

For dashboards and SLOs, almost always: a t-digest merge is typically within a fraction of a percent at the tails and costs a few kilobytes per shard. For billing or regulatory reporting, no β€” those need exact computation, which means either a single-shard query or a full value stream.