Skip to main content

Cross-Partition Querying & Aggregation Strategies

Cross-partition querying is the discipline of decomposing a SQL or NoSQL query into per-shard sub-requests, executing them in parallel, and merging the results into a single coherent response β€” all while preserving correctness, bounding latency, and tolerating partial failures. It is the operational complement to database partitioning fundamentals & architecture: once data is distributed, every query that cannot be satisfied by a single shard must travel this path. The strategies on this page apply across PostgreSQL declarative partitioning, application-managed sharding, and purpose-built distributed engines such as Vitess, Citus, and Trino.

Cross-partition query execution flow A coordinator node receives a client query, fans it out to four partition shards in parallel, and merges the partial results back to the client. Client SQL query Coordinator decompose prune Β· merge Shard 0 local scan + agg Shard 1 local scan + agg Shard 2 local scan + agg Shard 3 local scan + agg partial results β†’ merge merged response

Architectural Drivers

Cross-partition query overhead is unavoidable once a schema exceeds single-node capacity, but its cost varies by several orders of magnitude depending on how execution is structured. The breakpoints that push teams toward a distributed query layer are predictable:

Fan-out turns a shard's p99 into the coordinator's median A single shard answers in eight milliseconds at the median and ninety at the 99th percentile. Because a scatter-gather query waits for the slowest of N shards, the coordinator's median tracks the shard p99 as N grows: 24 milliseconds at four shards, 61 at sixteen, 88 at sixty-four. Hedged requests to a replica cut the sixty-four shard case back to 34 milliseconds. 100 ms75 ms50 ms25 ms0 1 shard4 shards16 shards64 shards 8 ms24 ms61 ms88 ms 34 ms with hedging Nothing about the shards got slower β€” the coordinator simply samples the tail N times per query. Fixing this means reducing N (route by key), removing the wait (pre-aggregate), or racing a second replica after a short delay (hedging). Solid line: coordinator p50 under scatter-gather. Dashed line: the same query with hedged reads at a 20 ms delay.
  • Row count above ~500 million per table on a single PostgreSQL instance, where sequential scans saturate I/O and autovacuum contention grows.
  • Write throughput exceeding ~10 000 rows/s consistently, where a single primary can no longer absorb replication lag without query latency spikes.
  • Multi-tenancy where one tenant’s query scan must never block another tenant’s data, requiring physical data isolation.
  • Reporting workloads on OLTP databases where aggregating months of event data on the primary generates lock contention on live tables.

The decision criterion is straightforward: if the query planner’s EXPLAIN (ANALYZE, BUFFERS) output shows full sequential scans on a table that exceeds available shared_buffers by more than 3Γ—, the query is already a cross-partition candidate even before any sharding is introduced.

-- Diagnose whether a query is already I/O-bound before sharding
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT region, SUM(revenue)
FROM   orders
WHERE  created_at > NOW() - INTERVAL '90 days'
GROUP  BY region;

-- Key output to check:
-- "Buffers: shared hit=... read=..."   <-- high "read" = evicting from cache
-- "Seq Scan on orders" with large rows = partition key missing or partition pruning failed

The partitioning implementation patterns & routing guide covers how to choose a partition key before you reach this point β€” the key choice upstream directly determines which queries cross partition boundaries.

Execution Strategy Taxonomy

Cross-partition queries are not monolithic. Four distinct execution models exist, each with different latency, consistency, and infrastructure implications.

Four ways to answer a question that spans shards Scatter-gather runs the query on every shard and merges, giving exact results at the cost of the slowest shard. Pre-aggregation reads a rollup table maintained per shard, which is fast but stale by the refresh interval. A federated engine pushes filters down and joins remotely, trading latency for SQL expressiveness. Approximate sketches such as HyperLogLog answer distinct counts in milliseconds with bounded error. Strategy What the coordinator does Accuracy p95 latency Scatter-gather Pre-aggregated rollups Federated engine Approximate sketches fan out, wait for all, merge and re-sort bounded by the slowest shard read one small table per shard, or one global rollup, then sum push filters and partial aggregates down; join what cannot be pushed merge HLL / t-digest state, no row data leaves the shard exact stale by refresh exact Β±2% typical 180–900 ms 4–20 ms 300 ms–5 s 6–15 ms Most production systems run three of these at once β€” the mistake is using scatter-gather for a dashboard that reloads every 15 seconds.
Strategy Latency profile Consistency Best fit
Scatter-gather Low (parallel fan-out, bounded timeout) Read-committed per shard OLTP aggregations, dashboard queries
Map-reduce High (multi-stage shuffle) Eventual Batch analytics, ETL pipelines
Push-down + local agg Lowest (no coordinator for final agg) Read-committed COUNT, SUM, MIN, MAX without cross-shard JOIN
Broadcast join Medium (small table replicated to all workers) Read-committed Star-schema lookups, dimension table joins

Scatter-Gather

The coordinator dispatches identical sub-queries to every relevant shard in parallel, collects partial result sets, and performs a final merge. The cross-shard aggregation patterns page documents the merge-phase algorithms in depth, including partial GROUP BY consolidation and ORDER BY … LIMIT pushdown.

-- Coordinator pseudo-logic: dispatch and merge
-- Step 1: Prune shards (only shards covering created_at > 2023-01-01)
-- Step 2: Fan out to pruned shards in parallel
SELECT region, SUM(partial_revenue) AS total_revenue
FROM (
    -- Result from shard_0 (tenant range A-G)
    SELECT region, SUM(revenue) AS partial_revenue FROM shard_0.orders WHERE created_at > '2023-01-01' GROUP BY region
    UNION ALL
    -- Result from shard_1 (tenant range H-N)
    SELECT region, SUM(revenue) AS partial_revenue FROM shard_1.orders WHERE created_at > '2023-01-01' GROUP BY region
    -- ... shards 2..N
) sub
GROUP BY region;

Push-Down Filtering with Local Aggregation

For commutative aggregates (SUM, COUNT, MIN, MAX), the coordinator pushes the aggregation itself to each shard. Only the per-shard aggregate row travels the network rather than raw data.

-- What each shard executes locally (pushed down by coordinator)
SELECT region,
       SUM(revenue)   AS partial_sum,
       COUNT(*)       AS partial_count
FROM   orders
WHERE  created_at > '2023-01-01'
  AND  tenant_id BETWEEN 'A' AND 'G'   -- partition pruning predicate
GROUP  BY region;

-- Coordinator final merge: SUM the partial_sums, SUM the partial_counts
-- Network transfer: O(distinct_regions Γ— shards), not O(matching_rows)

Broadcast Join

When a query joins a large fact table (partitioned) against a small dimension table (non-partitioned), replicating the dimension to every shard worker eliminates cross-shard data movement.

-- Coordinator broadcasts the products table to each shard worker
-- Each shard performs a local hash join β€” no shuffle required
SELECT p.category, SUM(o.revenue)
FROM   orders o
JOIN   products p ON o.product_id = p.id   -- products is small: ~50 000 rows
WHERE  o.created_at > '2023-01-01'
GROUP  BY p.category;
-- Threshold: broadcast is efficient when the inner table fits in worker memory (~256 MB)

Map-Reduce for Batch Analytics

Multi-stage map-reduce is appropriate when aggregation results are themselves too large to hold in coordinator memory, or when intermediate shuffles are needed for operations like distributed JOIN on non-partition-aligned keys.

# Python pseudo-code: map phase on each worker, reduce on coordinator
def map_phase(shard_connection, sql: str) -> list[dict]:
    cur = shard_connection.cursor()
    cur.execute(sql)
    return cur.fetchall()  # partial aggregate rows

def reduce_phase(partials: list[list[dict]]) -> dict:
    totals: dict[str, float] = {}
    for partial in partials:
        for row in partial:
            totals[row["region"]] = totals.get(row["region"], 0.0) + row["partial_sum"]
    return totals

# Orchestrate in parallel
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=len(shards)) as pool:
    partial_results = list(pool.map(lambda s: map_phase(s, PUSH_DOWN_SQL), shards))
final = reduce_phase(partial_results)

Query Routing and Data Distribution

Effective routing minimises the number of shards a query touches. The fewer shards involved, the lower the coordinator overhead and the smaller the blast radius of a shard failure.

Partition Pruning

The query planner eliminates shards that cannot contain matching rows using the partition predicate. Pruning only fires when the WHERE clause contains the partition key with a deterministic literal or range.

-- GOOD: partition key (tenant_id) present β€” planner prunes to one shard
SELECT * FROM orders WHERE tenant_id = 'acme_corp' AND order_id = 9921;

-- BAD: no partition key β€” coordinator must broadcast to all shards
SELECT * FROM orders WHERE created_at BETWEEN '2023-01-01' AND '2023-12-31';

-- BAD: function on partition key defeats pruning
SELECT * FROM orders WHERE LOWER(tenant_id) = 'acme_corp';

Verify pruning is active in PostgreSQL declarative partitioning:

SET enable_partition_pruning = on;
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE tenant_id = 'acme_corp';
-- Look for "Partitions selected: 1 (of N)" in the output

Application-Level Routing

Application-level sharding logic embeds the shard-selection function in the service layer. The routing function maps a partition key to a shard index and opens a targeted connection.

import hashlib

SHARD_COUNT = 16
SHARD_DSN = {
    0:  "postgresql://user:[email protected]/app",
    1:  "postgresql://user:[email protected]/app",
    # ...
    15: "postgresql://user:[email protected]/app",
}

def shard_index(tenant_id: str) -> int:
    return int(hashlib.md5(tenant_id.encode()).hexdigest(), 16) % SHARD_COUNT

def get_connection(tenant_id: str):
    idx = shard_index(tenant_id)
    return connect(SHARD_DSN[idx])

Proxy-Layer Routing

Proxy routing architectures offload shard selection from the application entirely, providing a single connection endpoint and transparent query routing based on rules the proxy evaluates at parse time.

{
  "router_config": {
    "shard_map": "consistent_hash",
    "hash_key": "tenant_id",
    "shard_count": 16,
    "fallback": "broadcast_read",
    "timeout_ms": 2000,
    "retry_policy": "exponential_backoff",
    "max_retries": 3
  }
}

Federated Query Engines

For analytical workloads that span heterogeneous data sources, federated query execution engines (Trino, Citus, DuckDB) accept standard SQL and handle cross-source planning internally, including cost-based join ordering across remote partitions.

Operational Configuration

Production cross-partition deployments require careful tuning of timeouts, concurrency, and connection pool sizes. Under-tuned pools create head-of-line blocking; over-tuned pools exhaust shard memory.

# PgBouncer pool config for a 16-shard deployment
[pgbouncer]
listen_port = 5432
listen_addr = *
auth_type    = md5
pool_mode    = transaction          # transaction-level pooling for short queries
max_client_conn = 2000
default_pool_size = 25             # 25 server connections per database
reserve_pool_size = 5
reserve_pool_timeout = 3.0
server_connect_timeout = 5
query_timeout = 10                 # kill queries exceeding 10 s
client_idle_timeout = 60
log_connections = 0
log_disconnections = 0
# PostgreSQL per-shard statement timeout (set in postgresql.conf or via ALTER SYSTEM)
statement_timeout          = 8000     # ms β€” kills runaway cross-partition aggregations
lock_timeout               = 3000     # ms β€” prevents long-wait lock chains
idle_in_transaction_session_timeout = 15000
max_parallel_workers_per_gather = 4   # allow intra-shard parallelism
work_mem                   = 64MB     # per-sort allocation; raise with caution on many parallel workers
shared_buffers             = 8GB      # 25% of available RAM per shard node
-- Coordinator-level: set a per-session budget before running cross-partition queries
SET statement_timeout = '8s';
SET work_mem = '128MB';   -- coordinator may hold partial result sets in memory

-- Validate connection pool headroom before issuing fan-out
SELECT count(*), state
FROM   pg_stat_activity
WHERE  datname = 'app'
GROUP  BY state;

Monitoring and Observability

Distributed query paths introduce failure modes invisible to single-node monitoring. Instrument at every hop: client, coordinator, and per-shard worker.

# P99 cross-partition query latency β€” alert if > 500 ms
histogram_quantile(0.99,
  sum(rate(cross_partition_query_duration_ms_bucket[5m])) by (le, query_type)
)

# Per-shard result row count β€” detect coordinator skew (one shard returning 10x others)
topk(5, sum(cross_partition_partial_rows_total) by (shard_id))

# Network bytes transferred coordinator ← shards β€” alert if > 100 MB/min
rate(network_bytes_transferred_total[1m]) * 60 > 100e6

# Coordinator connection pool saturation β€” alert if > 80%
(pgbouncer_pools_sv_active / pgbouncer_pools_pool_size) > 0.8
# OpenTelemetry metric definitions
metrics:
  - name: cross_partition_query_duration_ms
    type: histogram
    labels: [partition_count, query_type, coordinator_id, status]
    buckets: [10, 50, 100, 250, 500, 1000, 2500, 5000]
    alert:    "p99 > 500ms for 3 consecutive minutes"

  - name: network_bytes_transferred_total
    type: counter
    labels: [coordinator_node, target_shard, direction]

  - name: partial_result_rows_total
    type: counter
    labels: [shard_id, query_type]

  - name: coordinator_fan_out_shard_count
    type: histogram
    labels: [query_type]
    alert: "mean > 8 shards per query for OLTP workloads"

Catalog queries to detect hot partitions on PostgreSQL:

-- Identify which partitions are handling disproportionate write volume
SELECT
    schemaname,
    relname                              AS partition,
    n_tup_ins + n_tup_upd + n_tup_del   AS write_ops,
    n_live_tup                           AS live_rows,
    pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE relname LIKE 'orders_%'
ORDER BY write_ops DESC
LIMIT 10;

Debugging and Rebalancing

When a shard becomes a straggler β€” taking 10Γ— longer than its siblings during a scatter-gather β€” the coordinator’s response time degrades to that shard’s latency. The zero-downtime rebalancing workflow below applies to both range and hash-distributed schemas.

Step 1 β€” Identify the straggler shard

-- On the coordinator (Citus example): find query execution time per placement
SELECT
    nodename,
    nodeport,
    avg(execution_duration_ms)  AS avg_ms,
    max(execution_duration_ms)  AS max_ms,
    count(*)                    AS query_count
FROM citus_stat_statements
WHERE query ilike '%orders%'
GROUP BY nodename, nodeport
ORDER BY avg_ms DESC;

Step 2 β€” Check data skew on the straggler

-- Connect directly to the straggler shard and check row distribution
SELECT
    tenant_id,
    COUNT(*)  AS row_count
FROM orders
GROUP BY tenant_id
ORDER BY row_count DESC
LIMIT 20;
-- If a single tenant_id holds >30% of rows, you have a hot-key problem

Step 3 β€” Salt the hot key or split the partition

-- Option A: add a salt suffix to distribute a hot tenant across sub-shards
-- Modify the shard key from tenant_id to (tenant_id, bucket)
ALTER TABLE orders ADD COLUMN bucket SMALLINT
    GENERATED ALWAYS AS (('x' || substr(md5(order_id::text), 1, 4))::bit(16)::int % 8) STORED;

-- New routing function includes bucket
CREATE INDEX ON orders (tenant_id, bucket, created_at);

-- Option B (PostgreSQL declarative): split an oversized range partition online
-- 1. Create two child tables for the narrower ranges
CREATE TABLE orders_2024_q1 PARTITION OF orders_2024
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE orders_2024_q2 PARTITION OF orders_2024
    FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- 2. Detach the old wide partition
ALTER TABLE orders DETACH PARTITION orders_2024;
-- 3. Migrate data (batched to avoid lock contention)
INSERT INTO orders SELECT * FROM orders_2024
    WHERE created_at < '2024-04-01';
INSERT INTO orders SELECT * FROM orders_2024
    WHERE created_at >= '2024-04-01';
-- 4. Drop the old partition after verification
DROP TABLE orders_2024;

Step 4 β€” Verify the rebalance

# Confirm new partition sizes are within 20% of each other
psql -d app -c "
SELECT
    relname,
    pg_size_pretty(pg_relation_size(oid)) AS size,
    pg_relation_size(oid)                 AS bytes
FROM pg_class
WHERE relname LIKE 'orders_%'
ORDER BY bytes DESC;
"

Step 5 β€” Update routing metadata

# Flush coordinator shard map cache (Vitess example)
vtctlclient -server localhost:15999 RebuildKeyspaceGraph ks
# Or for application-level routing: redeploy with updated SHARD_RANGES env var

Cost Model: What a Cross-Partition Query Actually Spends

Every cross-partition query spends four budgets, and the one that runs out first determines what you should optimise. Naming them separately prevents the common mistake of adding a cache in front of a query whose real problem is coordinator memory.

Connection budget. A fan-out to sixteen shards holds sixteen connections for the duration of the slowest shard. With a coordinator pool of 200 connections, thirteen concurrent fan-outs saturate it, and the fourteenth waits β€” including the single-shard queries queued behind it. This is why bounded concurrency belongs in the coordinator rather than in each caller: the limit has to be global to mean anything.

Coordinator memory. Merging results is not free. A top-1000 query across sixteen shards materialises 16,000 rows before discarding 15,000 of them; an unbounded ORDER BY with no LIMIT materialises everything. Sort buffers on the coordinator are the usual first casualty of a fan-out that was tested against three shards and deployed against thirty.

Network bytes. Partial aggregates are small; raw rows are not. The difference between pushing SUM() down and pulling rows up is often two orders of magnitude of traffic β€” and on cloud infrastructure, cross-zone bytes are billed as well as slow.

Tail latency. As shown above, the coordinator observes the slowest of N shards on every request, so p50 at the coordinator tracks p99 at the shard. This budget is spent by the fan-out width itself and cannot be optimised away without reducing N.

A useful discipline is to record all four per query shape in a table that lives beside the code:

Query shape Shards touched Rows returned per shard Coordinator work Budget that binds first
Order detail by id + date 1 1 none none β€” this is the shape to aim for
Tenant dashboard, 24 h 1 ~400 sort 400 rows none
Global top-100 sellers 16 100 merge 1,600, sort, cut coordinator memory
Nightly finance rollup 16 1 partial aggregate sum 16 rows tail latency
Support search by email 16 0–1 trivial connection budget

The last row is the one that surprises teams. A support lookup by email returns almost nothing, so it looks harmless β€” but it holds sixteen connections while doing so, and support tools tend to poll. The fix is not a faster query; it is a secondary lookup table mapping email to shard, so the query becomes single-shard.

Deciding where to spend

Once the budgets are explicit, the strategy choice becomes mechanical. If the binding constraint is tail latency and the answer can be seconds old, pre-aggregate. If it is coordinator memory, push more of the aggregation down or bound the result set with keyset pagination. If it is the connection budget, add a directory that turns fan-out into a routed single-shard query. If it is network bytes, check the plan β€” a filter that failed to push down is nearly always the cause, and it is a one-line fix rather than an architectural one.

What none of these budgets tolerate is an unbounded query shape reaching production untested. A fan-out query that is correct at three shards and catastrophic at thirty passes every functional test you have. Cap the fan-out width in the coordinator, log the shard count per statement, and fail loudly in staging when a query exceeds its declared budget β€” the alternative is discovering the limit during a launch.

Common Mistakes

Mistake Root cause Mitigation
Broadcasting all queries to every shard No partition key in WHERE clause Enforce partition key presence via query linting or a middleware guard; reject queries without it in production
Ignoring partition skew during aggregation Hot-key tenant generates far more rows than average; straggler delays coordinator Monitor per-shard row counts weekly; apply key salting or sub-partition hot tenants
Hardcoding shard topology in application code Topology grows and changes; code becomes a bottleneck Delegate routing to a proxy or service mesh; store shard map in a config store with hot-reload
Missing push-down predicates on aggregates ORM generates SELECT * before grouping at the app layer Audit ORM-generated SQL with EXPLAIN; rewrite aggregations as database-side queries
Unbounded fan-out on metadata queries COUNT(DISTINCT user_id) fans out to all N shards even for a dashboard widget Pre-aggregate into a materialized summary table; use HyperLogLog for approximate counts

FAQ

When should I avoid cross-partition queries entirely?

Avoid them when latency SLAs are strict (sub-5 ms p99) or when the data volume per query exceeds network throughput. Redesign queries to target a single shard via denormalization β€” replicate the necessary columns into the partitioned table β€” or maintain materialized views that pre-aggregate across shards on a schedule. This trades write amplification for read simplicity.

How do I choose between scatter-gather and map-reduce execution?

Scatter-gather is appropriate for low-latency OLTP aggregations where result sets are small and coordinator memory is bounded. A single scatter-gather round-trip with push-down aggregates typically completes in 20–200 ms across 16 shards. Map-reduce is better for large offline analytical workloads where multi-stage shuffling is acceptable and fault tolerance matters more than sub-second response times. If your coordinator would need to hold more than a few gigabytes of intermediate state, use a dedicated analytics engine rather than an OLTP coordinator.

When should I use a dedicated query coordinator versus application-layer fan-out?

Use a coordinator (Citus, Vitess, Trino, PlanetScale) when query complexity is high, partition topology changes frequently, or you need built-in push-down optimisation and query planning. Application-layer fan-out is acceptable for a small, static set of shards (≀ 8) where you control the client, query patterns are narrow, and you want to avoid an extra infrastructure component. Beyond roughly 8 shards, the operational cost of maintaining application-side routing logic outweighs the simplicity benefit.

How do you handle distributed transaction consistency across shards?

Two-phase commit (2PC) provides strict ACID guarantees across shards but introduces coordinator-as-single-point-of-failure risk and 2–5Γ— latency overhead compared to single-node transactions. Saga patterns decompose a distributed transaction into a sequence of local transactions with compensating actions, providing higher availability at the cost of temporary inconsistency. For most multi-tenant SaaS systems, scoping transactions within a single tenant’s shard (and never writing across shard boundaries in a single transaction) eliminates the distributed transaction problem entirely.

What is the performance impact of cross-partition joins?

Expect significant network overhead and memory pressure at the coordinator node. A join between two large partitioned tables with non-aligned partition keys requires a full shuffle: every row from the left table must be co-located with matching rows from the right table at the coordinator, potentially transferring gigabytes of data. Mitigation options in order of preference: (1) use broadcast join if one side fits in memory; (2) physically co-locate related data on the same shard by using the same partition key for both tables; (3) pre-join into a denormalized table maintained by a replication pipeline; (4) tolerate the shuffle overhead by moving the query to a dedicated analytics replica.

Sub-Sections