Distribution keys and the design check
The distribution key is the single most important choice in a cluster. It is the column whose value decides which node holds each row. It is not the primary key, though a distributed table's primary key must include it. Everything else in the cluster follows from it:
- Where a row lives. Rows with the same key value always live together on one node.
- Whether a query goes to one node or to all of them.
where customer_id = $1goes to one node. A query that names no key is asked of every node and the answers are merged. - Whether a foreign key can hold. Postgres checks a foreign key on one node, so a row and the row it references have to be on the same node.
- Whether a unique key can hold. Each node checks uniqueness only over the rows it holds, so a primary or unique key must include the distribution key.
- What moves together. When data is split or moved to another node, every table distributed by the same key moves its matching rows in the same step, so related rows are never split apart.
A table with the wrong key, or none, still works. It is just slow, or stuck on the home node, and nothing tells you unless you look. The design check is there to look for you.
A chain of tables
Most schemas have a root: a customer, an account or a tenant. Everything else hangs from it through foreign keys, sometimes many levels deep:
customer ─< customer_package ─< payment ─< payment_line
│
└─< invoice ─< invoice_line
The whole chain stays on one node when every table in it carries the root's key and every foreign key includes it:
create table customer_package (
customer_id bigint not null references customer (id),
id bigint not null,
primary key (customer_id, id)
);
create table payment (
customer_id bigint not null,
id bigint not null,
package_id bigint not null,
primary key (customer_id, id),
foreign key (customer_id, package_id) references customer_package (customer_id, id)
);
Distribute all of them by customer_id (the root by its id) in one keyspace, and one customer's
whole tree sits on one node. Every foreign key is checked there, on delete cascade runs there, a
join down the chain that names the customer runs there, and a move carries the tree together. Twelve
levels work the same as two.
Lepis places each row by its own column only. It never looks up a parent row to find where a child belongs, because that would mean an extra read on every insert and every move. So a table in the middle of the chain without the key column cannot follow, and neither can anything below it, until the column is added:
alter table payment_line add column customer_id bigint;
update payment_line c set customer_id = p.customer_id from payment p where c.payment_id = p.id;
Then put customer_id first in its foreign key, and distribute it like the rest.
Tables that are not part of the tree
- Lookups (statuses, countries, languages, staff) that distributed tables reference by some other column become reference tables: a full copy on every node, so those foreign keys hold wherever the row is. A reference table may reference another reference table, at any depth. It may not reference a distributed table, since its copy is on every node and the rows it points at are on one.
- Two foreign keys to the same distributed table (a seller and a buyer, both users): only one of them can be the distribution key. The other points at rows that may live on another node, and is refused. Either the referenced table is small and read far more than written, and becomes a reference table, or the design picks which owner the rows really belong to.
- Ids from a sequence (
serial,identity) are per node, so two nodes would hand out the same value. Generate ids withuuidv7()or a sequence striped by node. A reference table cannot have one either, since each copy would fill it differently.
Adding the key later
Forgetting a table is not permanent. Until it is distributed, a table stays whole on the home node and works as an ordinary Postgres table. It just does not spread.
When you distribute it later, Lepis converts it while it stays live. It builds the table's chunks beside it, copies the rows in, replays the writes made meanwhile, sends each chunk to the node that owns it, then switches over in a short pause and checks the row counts and checksums on both sides. Reads and writes go on throughout, apart from that brief pause.
What Lepis does not do for you is add the column or change your keys. If the table has no key column, add and fill it first. If its primary key, unique keys or foreign keys leave the key out, distribute refuses it and its error names the fix.
The design check
The design check reads your tables, foreign keys and indexes and says what to change, each finding with its fix. It is deterministic: the same schema gives the same findings, and nothing is guessed or sampled. It reads, and changes nothing.
It runs in four places:
- SnoutData Cloud: the Design check card on the project's Cluster tab.
- Studio: the Design check group in the project's Cluster section. The assistant reads it too.
- The CLI:
snoutdata cluster check [--project REF] [--json], which exits 1 when something must be fixed, so a CI job can gate on it. An agent reads it through thecluster_checktool ofsnoutdata mcp. - Before every change to a table. The plan of
table distribute,table reference,table globalandtable autocarries the check of the tables it touches, so you see it while you can still change your mind. It warns. What the cluster cannot do correctly is refused outright, with its reason.
Before anything is distributed, the check starts from the table table auto would choose as the
tenant (the one whose primary key the most tables reference) and follows the foreign keys from
there, so a schema can be checked while it is being designed.
| Finding | What it means | The fix it gives |
|---|---|---|
| Missing key column | The table references a table placed by a key, and has no column of that key's name, so its rows cannot be kept with the rows they reference. | The alter table … add column and the update that fills it. |
| Foreign key without the key | The table has the key column, but its foreign key leaves it out, so after distribution a row and the row it references could be on different nodes. | The foreign key it needs, key first. |
| Should be a reference table | Distributed tables reference this home-node table by a column other than their key. | Make it a reference table (one click or command), or what stops it, such as a sequence. |
| Blocked | The table belongs with another table's rows but cannot be distributed: no primary key, a unique key without the distribution key, or a sequence. | What to change. |
| Can follow / big on home | The table can be distributed with the rows it belongs to. Over 1 GB it is a warning, since it is all on the home node. | The distribute operation, ready to review and run. |
| Foreign key not indexed | No index begins with the foreign key's columns, so every join on it reads the whole table, and every delete in the parent scans it. Postgres does not create one for you. | The create index. |
| Key not indexed | No index begins with the distribution key, so a query for one key finds its rows by reading the table on its node. | The create index. |
| Reference table large | A reference table over 1 GB costs its size on every node, and every write to it is made on every node. | Keep reference tables to lookups. |
| No primary key | The table can never be distributed, copied or moved. | Add one. |
SQL the check gives is yours to run, through the router as usual (an index created on a distributed table reaches every chunk of it). An operation it gives opens the same review as any other: the plan first, then Run.