Skip to main content

Setting Up Citus for Distributed PostgreSQL Queries

This guide walks through installing Citus and building a coordinator-plus-two-workers deployment that executes distributed queries as part of a broader federated query execution strategy within Cross-Partition Querying & Aggregation Strategies.

Unlike the postgres_fdw approach covered in executing federated queries across multiple PostgreSQL instances, Citus is not a bolt-on foreign-table layer: it extends the PostgreSQL planner itself, so the coordinator decomposes one SQL statement into per-shard fragments, runs them in parallel on the workers, and merges partial aggregates natively. The price is homogeneity β€” every node must run the same PostgreSQL major version with the same Citus version loaded.


Citus coordinator and worker topology An application sends SQL to the Citus coordinator, which holds metadata and the distributed planner. The coordinator sends query fragments to worker one holding shards one to sixteen and worker two holding shards seventeen to thirty-two, then merges the partial results. Application plain SQL Coordinator metadata + planner partial-result merge 10.0.1.10:5432 Worker 1 shards 1–16 10.0.1.21:5432 Worker 2 shards 17–32 10.0.1.22:5432 fragments fragments

Prerequisites


Step 1 β€” Install Citus and preload the extension library

Citus hooks into the PostgreSQL planner, so its shared library must be loaded at server start on every node β€” coordinator and workers alike β€” before the extension can be created. Install the package, set shared_preload_libraries, restart, and create the extension in the target database.

# Run on the coordinator AND both workers (Ubuntu, Citus repo configured)
sudo apt-get install -y postgresql-16-citus-13.0

sudo -u postgres psql -c "ALTER SYSTEM SET shared_preload_libraries = 'citus';"
sudo systemctl restart postgresql@16-main

sudo -u postgres createdb orders_db
sudo -u postgres psql -d orders_db -c "CREATE EXTENSION citus;"
sudo -u postgres psql -d orders_db -c "SELECT citus_version();"

Operational note: If shared_preload_libraries already lists other libraries (for example pg_stat_statements), append rather than replace: ALTER SYSTEM SET shared_preload_libraries = 'citus,pg_stat_statements'. Citus must appear in the list before the restart or CREATE EXTENSION citus fails with citus.so not loaded.

DBA tip: Pin the exact Citus package version in your configuration management. All nodes must run the same Citus minor version; a mixed-version deployment fails at metadata sync with obscure unsupported protocol errors rather than a clear version message.

Step 2 β€” Register the coordinator and add two workers

The coordinator learns about workers through metadata functions, not configuration files. First tell the deployment its own coordinator address (required so workers can reach back for metadata sync), then register each worker with citus_add_node.

-- Run on the coordinator, inside orders_db
SELECT citus_set_coordinator_host('10.0.1.10', 5432);

SELECT citus_add_node('10.0.1.21', 5432);
SELECT citus_add_node('10.0.1.22', 5432);

-- Confirm both workers are registered and active
SELECT * FROM citus_get_active_worker_nodes();

Operational note: citus_add_node validates connectivity immediately β€” it opens a connection from the coordinator to the worker as the postgres superuser (or the current user). If it hangs, the problem is almost always pg_hba.conf on the worker or a firewall rule, not Citus itself.

SRE tip: Register workers by DNS name or a stable virtual IP rather than a raw instance IP where possible. Replacing a failed worker then becomes citus_update_node plus a DNS flip instead of a full drain-and-rebalance cycle.

Step 3 β€” Distribute a table with a shard key

create_distributed_table splits a table into citus.shard_count hash shards on the chosen distribution column and spreads them across the registered workers. Set the shard count before distributing β€” it is fixed for the table’s colocation group afterwards.

-- Shard count is read at distribution time; set it first
ALTER SYSTEM SET citus.shard_count = 32;
SELECT pg_reload_conf();

CREATE TABLE orders (
  order_id    BIGINT GENERATED ALWAYS AS IDENTITY,
  tenant_id   INT          NOT NULL,
  total       NUMERIC(12,2) NOT NULL,
  currency    CHAR(3)      NOT NULL,
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, order_id)   -- must include the distribution column
);

SELECT create_distributed_table('orders', 'tenant_id');

Operational note: Every unique index and primary key on a distributed table must include the distribution column, because uniqueness is only enforceable within a single shard. That is why the primary key above is (tenant_id, order_id) rather than order_id alone.

DBA tip: Choose a shard count that is a comfortable multiple of your target worker count β€” 32 shards over 2 workers gives 16 per node and leaves headroom to grow to 4, 8, or 16 workers by rebalancing alone. Too few shards caps future parallelism; a few hundred is the practical ceiling before per-shard overhead bites.

Joins between distributed tables are only pushed down to workers when both tables share the same distribution column and colocation group β€” otherwise Citus must repartition data over the network at query time. Distribute child tables with colocate_with, and turn small lookup tables into reference tables that are replicated in full to every worker.

CREATE TABLE order_items (
  item_id    BIGINT GENERATED ALWAYS AS IDENTITY,
  tenant_id  INT           NOT NULL,
  order_id   BIGINT        NOT NULL,
  sku        TEXT          NOT NULL,
  price      NUMERIC(12,2) NOT NULL,
  PRIMARY KEY (tenant_id, item_id)
);

-- Same distribution column, same colocation group as orders
SELECT create_distributed_table('order_items', 'tenant_id',
                                colocate_with => 'orders');

-- Small lookup table: replicate to all workers instead of sharding
CREATE TABLE currencies (
  code CHAR(3) PRIMARY KEY,
  usd_rate NUMERIC(10,6) NOT NULL
);
SELECT create_reference_table('currencies');

Operational note: Foreign keys between distributed tables are only allowed inside a colocation group and must include the distribution column on both sides. A foreign key from any distributed table to a reference table, however, is always valid β€” that is the main reason currencies is a reference table rather than a plain local table on the coordinator.

SRE tip: Reference tables are written through two-phase commit to every worker, so keep them small and low-churn. A β€œlookup” table receiving thousands of updates per second will serialise those writes across the whole deployment.

Step 5 β€” Run a cross-shard aggregation and read the distributed EXPLAIN

A GROUP BY over the whole table fans out to all 32 shards; each worker computes partial aggregates locally and the coordinator merges them. The distributed EXPLAIN shows exactly how the query decomposes β€” check the task count and the per-shard fragment before trusting performance numbers.

EXPLAIN (VERBOSE, COSTS OFF)
SELECT o.currency,
       count(*)                    AS order_count,
       sum(o.total * c.usd_rate)   AS revenue_usd
FROM orders o
JOIN currencies c ON c.code = o.currency
WHERE o.created_at >= now() - interval '30 days'
GROUP BY o.currency;

The plan should open with a Custom Scan (Citus Adaptive) node reporting Task Count: 32, and each task’s fragment must contain the WHERE filter and a partial GROUP BY:

Custom Scan (Citus Adaptive)
  Output: remote_scan.currency, ...
  Task Count: 32
  Tasks Shown: One of 32
  ->  Task
        Query: SELECT o.currency, count(*) ... FROM orders_102008 o
               JOIN currencies_102040 c ON ...
               WHERE o.created_at >= ... GROUP BY o.currency

Operational note: A query that filters on the distribution column (WHERE tenant_id = 42) should show Task Count: 1 β€” single-shard routing. If it shows 32, the filter is not being recognised (commonly a type mismatch, e.g. tenant_id = '42'::text), and every query is paying full fan-out cost.

SRE tip: Watch citus.max_adaptive_executor_pool_size (default 16) β€” it caps per-query connections from coordinator to each worker. Under high concurrency, total worker connections approximate active queries x pool size, so budget worker max_connections accordingly or front the workers with a pooler.

Step 6 β€” Add a node and rebalance shards

Scaling out is a two-step operation: register the new worker, then start the rebalancer. The rebalancer moves shards using logical replication so the deployment stays online; progress is observable from a monitoring view.

-- Register the new worker
SELECT citus_add_node('10.0.1.23', 5432);

-- Move shards onto it in the background (non-blocking)
SELECT citus_rebalance_start();

-- Watch progress
SELECT * FROM citus_rebalance_status();

Operational note: citus_rebalance_start() returns immediately and runs in the background; citus_rebalance_status() reports state (running, finished), the current move, and bytes copied. Cancel a misbehaving rebalance with citus_rebalance_stop() β€” completed moves stay in place, in-flight ones roll back.

DBA tip: Each shard move creates a logical replication slot on the source worker. If a move stalls (long-running transaction on the source blocking catch-up), WAL accumulates behind the slot and can fill the disk. Alert on pg_replication_slots.restart_lsn age on workers during any rebalance window.


Verification

Confirm the topology, shard placement, and data distribution before routing production traffic at the coordinator:

-- 1. Distributed tables and their colocation groups
SELECT table_name, citus_table_type, distribution_column, shard_count
FROM citus_tables;

-- 2. Shard placement per worker β€” counts should be balanced
SELECT nodename, count(*) AS shard_count,
       pg_size_pretty(sum(shard_size)) AS total_size
FROM citus_shards
GROUP BY nodename
ORDER BY nodename;

-- 3. End-to-end smoke test: insert routes to one shard, aggregate fans out
INSERT INTO orders (tenant_id, total, currency) VALUES (42, 99.50, 'EUR');
SELECT count(*) FROM orders;

Expected output for check 1: orders and order_items listed as distributed with distribution_column = tenant_id and shard_count = 32; currencies listed as reference. Expected output for check 2 with three workers: roughly 10–11 shards per node with sizes within a few percent of each other. Check 3 must return the inserted row in the count with no error β€” if the INSERT fails with a constraint error, a unique index without the distribution column slipped through Step 3.


Failure mode table

Failure mode Root cause SRE mitigation
create_distributed_table rejected with constraint errors Primary key, unique index, or foreign key does not include the distribution column, or references a non-colocated table Redesign keys as composites including the distribution column; convert small lookup targets to reference tables before distributing
Every query fans out to all shards even with a tenant filter Distribution-column predicate not recognised β€” type mismatch, expression wrapping (lower(tenant_key)), or filter applied on a joined table only Compare Task Count in EXPLAIN output; cast parameters to the column type; filter on the distributed table’s own column
Rebalance stalls and worker disk fills Shard-move logical replication slot blocked by a long-running transaction, WAL retained behind restart_lsn Alert on replication-slot lag during rebalances; terminate or wait out blocking transactions; citus_rebalance_stop() and resume in a quieter window

FAQ

Do I need to change application SQL after distributing tables with Citus?

Mostly no. Queries filtering on the distribution column route to a single shard unchanged, and cross-shard aggregations are planned automatically β€” the coordinator handles the merge step described in cross-shard aggregation patterns. The exceptions are schema-level: unique constraints that omit the distribution column, foreign keys between non-colocated tables, and correlated subqueries that cross colocation groups all need rewriting before or during distribution.

How many shards should citus.shard_count create?

Pick a value several multiples above your current worker count so future workers can receive shards without splitting β€” 32 or 48 works well for two to eight workers. The count is fixed at distribution time for the table’s colocation group, so plan for the node count you expect in two years, not today’s. More shards buy parallelism and rebalancing granularity; hundreds of shards add per-query task overhead and connection pressure.

Can I add a Citus worker later without downtime?

Yes. citus_add_node registers the worker, and citus_rebalance_start() moves shards onto it using logical replication, so reads and writes continue throughout. Only a brief lock is taken at the final cutover of each shard. The operational risk is not downtime but WAL buildup behind stalled replication slots β€” monitor slot lag on source workers for the duration of the rebalance.