PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 41: Sharding and distributed PostgreSQL

Distributed PostgreSQL: Citus and co-location

Level 8: Scale and Architecture25 min

Citus turns PostgreSQL into a sharded cluster: distributed tables are split into shards on worker nodes, small tables are replicated to every node, and queries are routed or fanned out by a coordinator. It works best when related tables share the distribution column so their joins stay on one node.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Describe distributed, reference and local tables
  • Explain co-location and why the tenant id goes on every table
  • Know the alternatives: application-level sharding, managed sharded services and distributed SQL databases

conceptThe concept

In Citus (an open-source PostgreSQL extension) a coordinator node holds metadata and plans queries; worker nodes hold shards, which are ordinary PostgreSQL tables.

  • Distributed tables are hashed on a distribution column into shards.
  • Reference tables (small lookup tables such as products or countries) are copied to every node, so joins to them are always local.
  • Co-located tables share the distribution column and shard boundaries, so orders and order_items for customer 42 live on the same node and join locally.

A query with the distribution column in its WHERE clause is routed to one shard. Other queries are split into fragments that run on every shard in parallel and are merged on the coordinator.

Other paths: sharding in the application with a router library, managed sharded PostgreSQL services from cloud providers, and distributed SQL databases that speak the PostgreSQL protocol (YugabyteDB, CockroachDB). The last group is wire-compatible but is not PostgreSQL underneath, so extensions and some behaviours differ.

whyWhy it matters

Citus keeps the PostgreSQL you already know (types, extensions, SQL, tools) while removing the single-server limit. The price is that your schema must be designed around the distribution column.

Use caseReal-world scenario

A multi-tenant analytics product with 4,000 tenants outgrows one server. It distributes every tenant-scoped table by tenant_id, makes plans and features reference tables, and keeps per-tenant dashboards as routed single-shard queries.

Try itExamples

Run and edit these in the interactive lesson.

Which tables carry the would-be distribution column?
SELECT c.relname,
       bool_or(a.attname = 'customer_id') AS has_customer_id
FROM pg_class c
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r'
GROUP BY c.relname
ORDER BY 2, 1;
order_items has no customer_id, so it cannot be co-located with orders by customer without a schema change.
Reshaping for co-location
BEGIN;
CREATE TABLE d_orders AS SELECT * FROM orders;
ALTER TABLE d_orders ADD PRIMARY KEY (customer_id, id);
CREATE TABLE d_order_items AS
SELECT o.customer_id, i.*
FROM order_items i JOIN orders o ON o.id = i.order_id;
ALTER TABLE d_order_items
  ADD PRIMARY KEY (customer_id, order_id, line_no),
  ADD FOREIGN KEY (customer_id, order_id) REFERENCES d_orders (customer_id, id);
SELECT count(*) AS items_for_customer_42 FROM d_order_items WHERE customer_id = 42;
ROLLBACK;
Keys that include customer_id can be enforced shard by shard.
The same schema distributed with Citus
CREATE EXTENSION citus;
SELECT citus_add_node('worker-1', 5432);
SELECT citus_add_node('worker-2', 5432);

SELECT create_distributed_table('orders', 'customer_id');
SELECT create_distributed_table('order_items', 'customer_id', colocate_with => 'orders');
SELECT create_reference_table('products');

-- routed: one shard on one worker
SELECT * FROM orders WHERE customer_id = 42;

-- fanned out to every shard, merged on the coordinator
SELECT status, count(*) FROM orders GROUP BY status;

-- move shards onto a newly added worker
SELECT citus_rebalance_start();

internalsUnder the hood

Foreign keys and unique constraints between distributed tables are only enforceable when they include the distribution column, because each shard can only check its own rows. That is why primary keys become composite, such as (customer_id, id), and why the tenant id is denormalized onto child tables.

Avoid / preferDo this, not that

Avoid
SELECT create_distributed_table('orders', 'customer_id');
SELECT create_distributed_table('order_items', 'order_id');
Prefer
SELECT create_distributed_table('orders', 'customer_id');
SELECT create_distributed_table('order_items', 'customer_id', colocate_with => 'orders');

Different distribution columns put an order and its items on different nodes, so every join becomes a network shuffle and the foreign key cannot be enforced.

Avoid
SELECT create_distributed_table('countries', 'code');
Prefer
SELECT create_reference_table('countries');

Small lookup tables joined from everywhere should be reference tables, copied to every node, so joins to them stay local.

PitfallsCommon mistakes

  • Distributing tables before adding the tenant id to child tables, then discovering joins are not co-located.
  • Running many cross-shard transactions and expecting single-node latency.
  • Treating a wire-compatible distributed SQL database as PostgreSQL and finding missing extensions or different isolation behaviour in production.

LimitsWhen not to use it

Skip distributed PostgreSQL for workloads dominated by cross-entity queries with no natural tenant key, or for data that fits on one server. A read replica or a columnar analytics copy is usually the better answer for heavy reporting.

SecuritySecurity notes

The coordinator connects to workers with its own credentials; protect those connections with TLS and restrict pg_hba.conf on workers to the coordinator and other workers. Row-level security policies must be created consistently on every node.

ChallengeTry it yourself

Find every table that references orders through a foreign key but has no customer_id column. These are the tables you would have to change before co-locating them with orders by customer.

Show one solution
One solution
SELECT k.conrelid::regclass AS child_table, k.conname
FROM pg_constraint k
WHERE k.contype = 'f'
  AND k.confrelid = 'orders'::regclass
  AND NOT EXISTS (
    SELECT 1 FROM pg_attribute a
    WHERE a.attrelid = k.conrelid AND a.attname = 'customer_id' AND NOT a.attisdropped
  );

InterviewQuestions you should be able to answer

What are reference tables and co-location in Citus?
Reference tables are small tables replicated in full to every node, so any shard can join them locally. Co-location means distributed tables share a distribution column and shard layout, so rows with the same key (for example the same tenant) of different tables sit on the same node, which keeps joins, foreign keys and transactions for that key local.
Citus versus a distributed SQL database such as CockroachDB or YugabyteDB?
Citus is PostgreSQL with an extension: the full PostgreSQL feature set and extensions, with sharding designed around a distribution column. Distributed SQL databases shard and replicate automatically with consensus (Raft) and provide serializable or strongly consistent distributed transactions, but implement the PostgreSQL protocol on a different storage engine, so extension support, performance characteristics and some semantics differ.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation