The cost of ignoring data locality
Sharding is frequently presented as the answer to PostgreSQL scalability, but it only works when you know what it does to your data. Among the most common mistakes is neglecting data locality — a concern that matters in many areas of IT and is decisive once a database is sharded. Citus, one of the more sophisticated sharding solutions available for PostgreSQL, supports both analytics and OLTP workloads and is offered on-premise or through Microsoft Azure.
Setting up a sharded example
The demonstration starts with two simple tables, customers and sales:
|
1 2 3 4 5 6 7 8 9 10 11 |
postgres=# CREATE TABLE t_customer ( id int, name text ); CREATE TABLE postgres=# CREATE TABLE t_sales ( id int, customer_id int, whatever text ); CREATE TABLE |
Analyzing this model typically means joining customers against sales. The tables are then distributed and populated:
|
1 2 3 4 5 6 7 8 9 10 11 |
postgres=# SELECT create_distributed_table('t_customer', 'id'); create_distributed_table -------------------------- (1 row) postgres=# SELECT create_distributed_table('t_sales', 'id'); create_distributed_table -------------------------- (1 row) |
Note that the table is sharded on id, which is not the join criterion.
|
1 2 3 4 5 6 7 8 9 |
postgres=# INSERT INTO t_customer SELECT *, 'dummy' FROM generate_series(1, 1000000); INSERT 0 1000000 postgres=# INSERT INTO t_sales SELECT id, random()*100000, 'dummy' FROM generate_series(1, 1000000) AS id; INSERT 0 1000000 |
What happens when the query runs
A straightforward join-and-count query across the two sharded tables does not execute as written:
|
1 2 3 4 5 |
postgres=# SELECT count(*) FROM t_customer AS c, t_sales AS s WHERE c.id = s.customer_id; ERROR: the query contains a join that requires repartitioning HINT: Set citus.enable_repartition_joins to on to enable repartitioning |
Citus rejects the query by default because the join does not use the sharding criterion. The reasoning is mechanical: when both rows live on the same machine, PostgreSQL can join them locally in RAM, which is efficient. Once the data has to traverse an expensive network stack, the cost grows by orders of magnitude, and on large data sets the approach becomes impractical. Returning an error is preferable to silently steering the user into a serious performance problem.
Forcing the join is possible through a locally set configuration variable, although this is not something to do in production on large data sets:
|
1 2 3 4 5 6 7 8 9 |
postgres=# SET citus.enable_repartition_joins = on; SET postgres=# SELECT count(*) FROM t_customer AS c, t_sales AS s WHERE c.id = s.customer_id; count -------- 999993 (1 row) |
The query returns in reasonable time here only because the test data set is small. On large, non-colocated data sets the query may never finish.
The execution plan shows what is actually happening:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
postgres=# explain analyze SELECT count(*) FROM t_customer AS c, t_sales AS s WHERE c.id = s.customer_id; QUERY PLAN ---------------------------------------------------------------------------------------------------------------------------- Aggregate (cost=250.00..250.02 rows=1 width=8) (actual time=1283.486..1283.486 rows=1 loops=1) -> Custom Scan (Citus Adaptive) (cost=0.00..0.00 rows=100000 width=8) (actual time=1283.478..1283.480 rows=20 loops=1) Task Count: 20 Tuple data received from nodes: 160 bytes Tasks Shown: None, not supported for re-partition queries -> MapMergeJob Map Task Count: 32 Merge Task Count: 20 -> MapMergeJob Map Task Count: 32 Merge Task Count: 20 Planning Time: 10.282 ms Execution Time: 1283.670 ms (13 rows) |
The run takes roughly 1.3 seconds. If the shards sit on separate machines rather than separate ports, that time can easily multiply.
Colocating the tables
The fix is data locality: rows that must be joined belong in the same shard. Data should not be moved between shards, and cross-shard operations should be avoided wherever possible. In practice, both tables have to use the same sharding criterion:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
postgres=# CREATE TABLE t_sales_proper AS SELECT * FROM t_sales; SELECT 1000000 postgres=# SELECT create_distributed_table( 't_sales_proper', 'customer_id', colocate_with => 't_customer' ); NOTICE: Copying data from local table... NOTICE: copying the data has completed DETAIL: The local data in the table is no longer visible, but is still on disk. HINT: To remove the local data, run: SELECT truncate_local_data_after_distributing_table($public.t_sales_proper$) create_distributed_table -------------------------- (1 row) |
Citus warns that a local table with all the data now exists. For safety, create_distributed_table does not remove local data, so the local copy has to be truncated to avoid storing everything twice:
|
1 2 3 4 5 |
postgres=# SELECT truncate_local_data_after_distributing_table($public.t_sales_proper$); truncate_local_data_after_distributing_table ---------------------------------------------- (1 row) |
Keeping data locally is generally a bad idea, since space can run out. Sharding and Citus are about scalability, and centralizing data runs against that goal on both space and other limitations.
With the data properly sharded, the query can be run again:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
postgres=# explain analyze SELECT count(*) FROM t_customer AS c, t_sales_proper AS s WHERE c.id = s.customer_id; QUERY PLAN ----------------------------------------------------------------------------------------------------------------------------------------------------------- Aggregate (cost=250.00..250.02 rows=1 width=8) (actual time=98.915..98.915 rows=1 loops=1) -> Custom Scan (Citus Adaptive) (cost=0.00..0.00 rows=100000 width=8) (actual time=98.907..98.909 rows=32 loops=1) Task Count: 32 Tuple data received from nodes: 256 bytes Tasks Shown: One of 32 -> Task Tuple data received from node: 8 bytes Node: host=localhost port=6003 dbname=postgres -> Aggregate (cost=1851.57..1851.58 rows=1 width=8) (actual time=25.905..25.907 rows=1 loops=1) -> Hash Join (cost=858.76..1773.21 rows=31345 width=0) (actual time=8.929..23.526 rows=31345 loops=1) Hash Cond: (s.customer_id = c.id) -> Seq Scan on t_sales_proper_102142 s (cost=0.00..483.45 rows=31345 width=4) (actual time=0.007..2.387 rows=31345 loops=1) -> Hash (cost=474.45..474.45 rows=30745 width=4) (actual time=8.863..8.863 rows=30745 loops=1) Buckets: 32768 Batches: 1 Memory Usage: 1337kB -> Seq Scan on t_customer_102078 c (cost=0.00..474.45 rows=30745 width=4) (actual time=0.005..3.701 rows=30745 loops=1) Planning Time: 0.138 ms Execution Time: 26.038 ms Planning Time: 2.678 ms Execution Time: 98.964 ms |
The result is a query that is 15 times faster than before — and in real-world cases the gap is often larger still.



