DuckDB diagrams are scoped to the catalog you opened
Foreign keys come from a single bulk read of the constraint catalog, and every catalog statement the DuckDB provider issues is scoped to the current database.
A DuckDB foreign key schema diagram draws real edges. The engine stores declared
constraints in a catalog you can query, duckdb_constraints() publishes both the
constraining columns and the referenced ones as arrays, and the ER diagram
capability for duckdb is full rather than partial. That part is settled.
The part worth reading is the boundary drawn around it. Every catalog statement
the provider issues carries the same WHERE clause, and that one clause decides
which objects appear in the tree, which edges appear in the diagram, and which
perfectly queryable database is missing from both.
Five catalog statements and no per-object sweep
Reading a schema is where a database tool usually gets slow, because the naive shape is a loop: list the tables, then for each table ask for its columns, then ask again for its constraints. A hundred tables is two hundred round trips on top of the first.
getSchema() on DuckDB issues five catalog statements plus one counting
statement, and no per-object sweep. The five are bulk reads over the whole
catalog:
| Statement | Reads | Feeds |
|---|---|---|
duckdb_tables() |
schema, table, estimated_size |
The object tree |
duckdb_views() |
schema, view | The object tree |
duckdb_columns() |
column, type, nullability, default | Tables and views both |
duckdb_constraints() |
primary and foreign keys | The diagram |
duckdb_indexes() |
index name, uniqueness, expressions | The table detail |
duckdb_columns() covers views as well as tables, so one read serves both. No
schema query is issued at all: schema names come from the schema_name column
that every one of the five already carries.
Two shapes in that table are not what they look like.
duckdb_tables().estimated_size is a row count rather than a byte size, and it
is an estimate: after a delete on a 20,000,000-row table it answered 1,076,480
where count(*) answered 1,000,000, and a CHECKPOINT left it there. The
counting statement is why the sixth read exists - one UNION ALL arm per table,
issued once for the whole catalog. And duckdb_indexes().expressions is declared
VARCHAR and prints as the string "[a, b]", so the statement casts it
::VARCHAR[] to make the engine produce a real list.
Where a DuckDB foreign key schema diagram gets its edges
The constraint read is the one the diagram depends on:
SELECT schema_name, table_name, constraint_type,
constraint_column_names, referenced_table, referenced_column_names
FROM duckdb_constraints()
WHERE database_name = current_database()
AND constraint_type IN ('PRIMARY KEY', 'FOREIGN KEY');
The constraint_type filter is doing real work. duckdb_constraints() publishes
every NOT NULL and every UNIQUE constraint as its own row too, so an
unfiltered read returns rows the diagram has no use for. Filtering by type
is also why this read is not filtered on NOT internal the way the table, view
and column reads are.
Both column lists arrive as VARCHAR[] - ["id"], not a comma-joined string -
so a composite key needs no string parsing. A composite foreign key is one
constraint row spanning several columns, and the provider zips
constraint_column_names against referenced_column_names into the product’s
per-column relationship records. On a PRIMARY KEY row referenced_table is
NULL and referenced_column_names is [], which is how one read serves both
key kinds without a second statement.
This is the same rule the ER diagram feature states everywhere: the
edges are discovered from declared constraints, not inferred. A relationship your
loader enforces in application code and never declares in the schema has nothing
in duckdb_constraints() to find, and no edge is drawn for it. That is worth
knowing on an analytical file, where a table built by a bulk load may carry no
declared constraints at all - a diagram with boxes and no edges there is an
accurate picture of the file, not a broken read.
Why the reads are scoped to the current database
Every one of the five carries database_name = current_database(). The obvious
alternative is worse, and it fails silently.
duckdb_schemas() publishes an internal flag, and the natural filter is
WHERE NOT internal. On DuckDB that flag is TRUE for main even in a user
database. So the obvious filter drops main - the schema nearly every table
lives in - out of the object browser, with no error raised anywhere. The tree
just comes back short, and a user has no way to tell a missing schema from an
empty one.
Scoping by database is the filter that behaves. NOT internal remains correct on
duckdb_tables(), duckdb_views() and duckdb_columns(), where it removes the
system catalog rather than the user’s default schema, and each of those carries
the database scope as well.
There is a second reason to prefer the database scope, and it comes from the
information_schema alternative. information_schema.tables and
information_schema.columns do answer on DuckDB, Postgres-shaped, with a
table_type of BASE TABLE or VIEW. They are deliberately not used: they
carry no internal flag, so they cannot separate the user’s objects from the
system catalog, which is the one distinction the tree needs.
An attached catalog you can query but cannot browse
Here is the cost of that clause, stated plainly.
Attached catalogs are not enumerated in the object browser. Every catalog read is scoped to the current database, so a database attached inside a session is queryable but never appears in the tree.
Run ATTACH '/data/side.duckdb' AS side in the editor and SELECT * FROM side.main.events returns rows. The editor works on it. The diagram does not draw
it, the tree does not list it, and no table inside it is counted. Nothing errors;
the objects are simply not there, because current_database() still answers with
the catalog the connection was opened against and the five reads all agree with
each other about which catalog that is.
That ATTACH is an editor statement. Agent auto mode runs on DuckDB, and it
does not get the same escape hatch: ATTACH is named on the provider’s SQL
denylist, and the agent’s handle is opened with enable_external_access: 'false' on top of access_mode: 'READ_ONLY'.
The workaround is the connection dialog. A DuckDB connection is a server-local
file path - there is no host, no port and no credential, since defaultPort is
null and the filesystem is the access control. Adding a second connection
pointed at /data/side.duckdb gives that file its own tree and its own diagram.
The DuckDB engine page carries the transport details for this.
One deployment note goes with that. A DuckDB file admits exactly one operating
system process: a second read-write process is refused with IO Error: Could not set lock on file ..., and a second READ_ONLY process is refused with the same
error. So close a duckdb CLI session on the file before opening it in Studio,
and never point two Studio replicas at one path.
One filename trap that looks like a product bug
The catalog name is the file stem. warehouse.duckdb is the catalog warehouse,
and current_database() answers warehouse.
Now name the file analytics.duckdb and put a schema named analytics inside
it. Every unqualified reference to that name fails:
Binder Error: Ambiguous reference to catalog or schema "analytics" - use a fully qualified path
Real, reproducible, and not a provider bug - the engine cannot tell which of the two you meant. It is worth knowing what it is when you hit it, because the error arrives in the editor and reads like a tool failure. It is a report about the file name.
Two things follow. Object-browser navigation qualifies schema.table rather than
relying on an unqualified name resolving. And the catalog reads are scoped by
current_database() rather than by a literal catalog name, so the collision that
breaks a hand-written query never reaches the introspection path. The diagram
still draws for a file named this way. The editor is where the ambiguity bites,
and the message names the fix itself - qualify the reference fully, or rename
the file so that catalog and schema no longer share a name.