Skip to content

← All posts

ClickHouse diagrams show structure without relations

An empty edge list here is the engine answer, not an unfinished read: no engine, no table setting and no DDL declares a foreign key anywhere.

Open the ER diagram on a ClickHouse connection and you get boxes with no lines between them. Before deciding the read went wrong, it is worth knowing that a ClickHouse foreign key is not a thing that exists to be read. No engine, no table setting and no DDL statement declares one. The catalog the diagram reads has no constraint table in it, so there is nothing to look up and nothing that a different query would have found.

What a discovered diagram needs from an engine

The diagram in LibreDB Studio is discovered rather than drawn. getSchema() returns a foreignKeys list per table, and each entry becomes one edge, laid out hierarchically by ELK.js. That list is the entire input. The ER diagram feature publishes the consequence beside the claim: a relationship your application enforces in code but never declares in the schema has nothing to discover, and will not appear.

On an engine with a constraint catalog, reading that list is a lookup. Someone wrote REFERENCES, the server created an object, and the object is still there to be selected. The edge on the canvas has the same standing as the column list next to it - it is a fact about the database, not a reading of it.

ClickHouse introspection here is three parallel reads of the system catalogs: system.tables for name, total_rows, total_bytes, sorting_key and primary_key; system.columns for name, type, is_in_primary_key and the default kind and expression, ordered by declaration position; and system.data_skipping_indices for the nearest thing this engine has to a secondary index object. There is no fourth read, because there is no fourth catalog. Foreign keys are always [].

A references clause that parses and enforces nothing

The confusing part is that the word is accepted. REFERENCES in a column definition parses on ClickHouse, and the server enforces nothing by it.

Nothing about the clause is recorded anywhere a client can read back. system.* holds no constraint catalog, so it leaves no trace after the statement returns. A tool that trusted the DDL text it was handed would report a relationship the server has no opinion about.

This is the difference between a syntax that is tolerated and a feature that exists. The provider’s own capability record settles it rather than leaving it to the parser: declaresForeignKeys is false in getCapabilities(), beside supportsInlineRowEdit: false and supportsCreateTable: false.

Declared absence versus an empty read

An empty list on its own is ambiguous. It can mean the read found nothing, the read was refused, or the concept does not exist here. Three situations, one value, and nothing downstream can tell them apart.

The refusal case is not hypothetical on this engine. A permission denial arrives as HTTP 500 with exception code 497 ACCESS_DENIED, not 403, so every error path in this provider is classified by the numeric code rather than by status or message text. system.data_skipping_indices needs its own grant and answers 500 / 497 without it, while system.tables and system.columns answer 200 pre-filtered to what the connected user may see. A restricted user therefore gets a full table-and-column tree with an empty index list - a genuinely degraded read, and one the interface has to distinguish from an impossible one.

declaresForeignKeys: false is the sentence the empty array cannot say. It is set once, at the provider level, and it does not depend on the grant the current user happens to hold.

The limit, stated plainly: this provider declares no foreign keys and the relation list is always empty, because this engine has no foreign-key concept anywhere, so the diagram shows structure without discovered relations. No permission fixes it, no DDL adds it, and no reorganisation of your tables turns the edges on. It is the published boundary for this engine on the engine matrix, written next to its transport and default port rather than discovered at runtime.

What the structure map is still worth

The panel is not empty, and what it does hold is specific to how this engine stores data:

Panel content Source
Tables, row counts, on-disk size system.tables - total_rows, total_bytes
Columns, in declaration order system.columns, ordered by position
Column types, verbatim the declared string, wrappers intact
Primary key system.tables.primary_key, is_in_primary_key
Sorting key a second entry, only when it differs from the primary key
Data-skipping indexes system.data_skipping_indices
Foreign keys always []

Three of those rows carry more than they look like they do.

Types are the declared strings with nothing normalised - Nullable(String), LowCardinality(String), Array(UInt8), Map(String,String), Enum8('x'=1,'y'=2), Decimal(10,3), DateTime64(3). The wrapper is the part that says nullable, low-cardinality, parameterised or enumerated, so collapsing it onto a generic family would throw away the information. Nullability is derived by testing for the Nullable(...) wrapper rather than searching for the substring, because LowCardinality(Nullable(String)) is a nullable column while Array(Nullable(String)) is not.

Row counts are the server’s own bookkeeping from system.tables, not a COUNT(*). For a view and for every non-MergeTree engine those columns really are null, and null is reported as unknown rather than coerced to zero - a table shown as “0 rows” when the server never said so is a number the explorer would have invented.

The key entries describe the read path, which is what a ClickHouse table is organised around. The primary index is a real sparse index over the sort order, and ORDER BY may extend PRIMARY KEY with trailing columns that shape the on-disk order, so the sorting key is surfaced as its own entry when it adds something the primary key entry does not already say. Note that none of these is unique: three identical values were accepted into a table declared PRIMARY KEY (a), live-verified.

Where the join actually lives on this engine

Not in the schema. It lives in the statement you write, and in the choice you made when you laid the tables out.

Whether a table carries its dimensions inline, and which columns the sorting key leads with, are layout choices rather than declared relationships. Neither is recorded as a relationship, because neither is one - they are storage decisions, and the structure map above shows exactly the parts of them the server tracks: the column list, the primary key, and the sorting key when it differs from the primary key.

When you do write a join, the relationship is stated at the moment it is used, by the person using it, and it can be read back from the plan. EXPLAIN json = 1, indexes = 1 is what the Explain action builds for a SELECT, and the plan comes back as a JSON tree that the interface renders node by node:

EXPLAIN json = 1, indexes = 1
SELECT o.id, c.name
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE o.created_at >= now() - INTERVAL 7 DAY

Two things to keep straight about reading that plan. Indexes entries appear on ReadFromMergeTree nodes and can report up to four kinds - Min-Max, Partition, PrimaryKey and each data-skipping index - each with its initial and selected part and granule counts, which is where you see whether the sorting key did any work for the predicate. And this plan was never executed: ClickHouse’s EXPLAIN does not run the statement, so there is no analyze mode to ask for and the estimate is the only plan available. Nothing on that tree was timed.