database.table, two parts, because ClickHouse has no catalog level above
the database. The cost paradigm is deployment-dependent: self-hosted uses
database load in database-seconds; Cloud uses compute time in
compute-seconds with compute-unit-hours alongside. Read-only against data,
enforced in depth.
Authentication: discover, don’t ask
The engine discovers a connection at runtime and never prompts for or persists a password. Discovery order:CLICKHOUSE_URL(orCLICKHOUSE_DSN), the connection URL most stacks already export- the
CLICKHOUSE_*environment (CLICKHOUSE_HOST,_PORT,_USER,_PASSWORD,_DATABASE,_SECURE) - the committed non-secret
clickhouse.host/port/database/userconfig target, with the password still supplied byCLICKHOUSE_PASSWORD - the
hostof atype: clickhousetarget in a discovered dbtprofiles.yml, with{{ env_var(...) }}references expanded
pg_service.conf analogue to pin, which is why this chain is one
shorter than the Postgres one.
Only a coarse method (for example environment:dsn or config_target:password)
is ever surfaced. DSNs, identities, and passwords never cross the envelope; the
sanitizer additionally strips user:pass@host URL fragments. Every discovery
failure names all the fixes.
Splitting dex’s identity from dbt’s. dex reads and dbt writes, and they are
usually different users. The shape that keeps them apart is CLICKHOUSE_URL for
dex (carrying the read-only user) and CLICKHOUSE_PASSWORD for dbt (which is
what the rendered profile’s env_var reference resolves at dbt runtime). This
mirrors Postgres’s DATABASE_URL plus PGPASSWORD.
Config
default. The documented grant
shape is SELECT on the source databases plus SELECT on system.tables,
system.columns and system.databases, with write access only on the dedicated
dev database for the dbt user. scripts/clickhouse_seed.sql is the shared,
parameterized data fixture; scripts/clickhouse_local_users.sql shows the local
dex_ro and dbt_dev grant shape.
Granting system.grants and system.role_grants as well is optional and only
affects the dev-target preflight: without them dex cannot read the dbt user’s
privileges and says so rather than guessing (see dbt builds).
Scoping a command
A ClickHouse scope is a bare database, because there is no catalog above it to qualify with and a dotted entry would name a table:--scope narrows the committed clickhouse.databases allowlist and can never
widen it. Resolution is free (one system.databases read) and happens before
anything is estimated, so a typo is refused for nothing with the databases that
do exist listed.
Identifiers are two parts
app.customers, not something.app.customers. ClickHouse has no catalog level,
and dbt-clickhouse’s schema: is the ClickHouse database (there is no
database: key in its profile at all). dex keeps the engine’s own shape rather
than synthesizing a third component, so every identifier in the inventory, the
cache, and every drift finding is one you can paste into clickhouse-client.
Cost model
Self-hosted ClickHouse bills nothing, but a scan is real load on a server that is usually shared and usually serving something latency-sensitive. So the guarded quantity is database-seconds, gated through the same strict handshake as every metered connector.- Free:
connect test,explore inventory, all schema and size facts (system.tablesandsystem.columns, no scans),EXPLAIN ESTIMATE,maintain schema,maintain volume, every dev-target preflight, anddbt compile-based build pricing. - Metered: profiling, relationship verification,
explore query,explore cluster,maintain grain, the cardinality half ofmaintain semantic, andtransform build.
EXPLAIN ESTIMATE reports the
rows and marks a statement would read after primary-key and partition pruning,
so a filtered probe on a huge table is not quoted as a full scan. It covers the
MergeTree family only, so Log, Memory, Distributed and view relations fall back
to system.tables sizes. The handshake payload carries estimate_basis
(explain_estimate, system_tables, or floor) so you can tell which priced a
given command. A statement answered entirely from part metadata (a bare
count()) legitimately estimates nothing and takes the fallback.
estimate_quality is heuristic, deliberately, even though the row count is
exact: the number you confirm is seconds, and seconds come from a throughput
constant. The exact part rides one field down rather than inflating the label.
The cap is layered, because time alone does not bind.
max_execution_time is checked at block boundaries and a single fast block can
overshoot it, so every billed statement also carries max_bytes_to_read, derived
by inverting the same throughput constant the estimate used. Both overflow modes
are set to throw explicitly, so a server default of break could never turn a
cap into a silently truncated result: a refusal is recoverable, a wrong answer is
not.
max_result_rows is deliberately not set. With throw it refuses the
one-extra-row fetch that detects truncation, and with break it was measured not
to bind at all; a setting that either breaks a legitimate query or does nothing is
worse than no setting. The result is already bounded twice: the query firewall
clamps LIMIT into the statement, and the adapter slices client-side.
Settlement is free and exact. Every response carries the
X-ClickHouse-Summary header, so elapsed nanoseconds, rows read and bytes read
come back with the result. No second query, and no system.query_log poll (which
flushes on a delay). The seconds in the ledger are what the server spent, not
what the client waited, which is a genuine accuracy improvement over the other
db-load connector.
For self-hosted there is no currency translation, because nothing is billed in
one. Rows and bytes actually read are reported as table notes rather than in the
spend summary, so nothing there carries a magnitude in a unit the ceiling is not
in.
Cloud uses the same free estimates, caps, and exact statement-seconds
settlement. Before any billed work, dex reads CGroupMemoryTotal from every
replica through clusterAllReplicas('default', system.asynchronous_metrics) and
cross-checks the number of answers against system.clusters. It derives:
compute_unit_price_usd is present, estimates and settled spend also carry a USD
translation using that supplied rate.
These fields are explicitly approximate, not an invoice. dex attributes the
server-reported time of each statement at the capacity observed for that
command; Cloud bills active capacity per minute and can include wake and idle
time outside the statement.
The dedicated AWS us-east-1 Scale test service is configured with the actual
compute rate of $0.29846 per 8-GiB compute unit-hour. Its $25.30/TB-month storage
rate is documented operational context only: storage persists independently of
a statement, so dex does not mislabel it as query spend or fold it into the
per-command confirmation.
Deployment
clickhouse.deployment declares which ClickHouse this is, and it is a cost
decision rather than a connection one:
self_hosted(default) bills no currency, so dex guards it in database-seconds.cloudis guarded in compute-seconds. Live allocated memory translates the same binding seconds into approximate compute-unit-hours and, when configured, USD.
compute_unit_price_usd is refused under self_hosted, where it would be
accepted and ignored.
Deployment is a declaration, never a sniff. At connect dex additionally
checks the server’s own cloud_mode setting in both directions and refuses when
the declaration does not match reality. A declared Cloud endpoint proceeds only
after that corroboration and the live capacity proof.
For Cloud, connect test adds compute.replica_count, total_memory_gib,
compute_units_per_hour, the metadata source, and approximate: true. Its
budget adds ceiling_compute_unit_hours; confirmation adds
estimated_compute_unit_hours, compute_unit_rate, and optionally
estimated_usd; settlement adds compute_unit_hours_billed and optionally
usd_billed. The binding ceiling and ledger remain seconds.
Read-only, enforced in depth
readonly = 2andallow_ddl = 0sent as settings on every statement, free or billed, so a host that supplies its own client cannot lose them.2rather than1because1also forbids changing settings, which would block dex from sending its own per-statement caps.- SELECT-only generation through one execution door, with SQLGlot-parsed refusal
of DDL, DML,
OPTIMIZE, multi-statement, and scripting in the ClickHouse dialect. - An adapter that issues no mutating statement of any kind.
- A documented least-privilege user (SELECT only on the source databases).
readonly = 2 by definition permits a session to change
its own settings, so dex’s cap is self-imposed exactly as Postgres’s
SET statement_timeout is. The unraisable form is a server-side settings
constraint on the dex user:
readonly = 2 also permits CREATE TEMPORARY TABLE. That is session-local and
cannot touch source data, the SELECT-only guard refuses it as DDL anyway, and a
least-privilege user has no grant for it.
connect test reports session_read_only from the server’s own readonly
setting rather than assuming it.
Profiling behavior
- Nullability comes from the type, not a column flag: ClickHouse has no
is_nullable, soNullable(...)andLowCardinality(...)are unwrapped in either nesting order before any type reasoning happens. - That has a consequence for drift. Because nullability is part of the type
string, a column that starts or stops accepting nulls is reported by
maintain schemaascolumn_retypedrather thannullability_changed, andmaintain reconcileanswers a retype with advice rather than an edit on every connector. So a nullability change here is surfaced with both type spellings named and the declaration is yours to update, where the same warehouse change on BigQuery or Snowflake would arrive as a proposed edit. - Distinct counts come from
uniq(a HyperLogLog sketch) in the same pass as the null counts, and are never a uniqueness verdict on their own. Near-unique columns escalate to an exactuniqExactinside the confirmed budget. Array,Map,Tuple,Nested,JSONand the geo types degrade to a non-null count: distinct counts and extremes are not meaningful on them.ORDER BYis a sort key, not a uniqueness constraint.system.columns.is_in_primary_keyis free and raises the prior on a candidate key, but it never declares one. ClickHouse has no foreign keys at all, so every relationship dex reports here is name-and-shape inference.- Tables on
ReplacingMergeTree,CollapsingMergeTreeand friends carry a note saying so, because rows sharing the sorting key are kept until a background merge collapses them: a duplicate count there describes the stored parts rather than the modeled grain, and querying withFINALshows the collapsed view. max_full_profile_bytesis honored only where the table declared a sampling expression in its MergeTree key, which most do not. Where it cannot be honored it is refused out loud with a note, rather than silently producing a full scan you believed was sampled, or anORDER BY rand()that reads everything and then sorts it.explore clustersamples withORDER BY rand() LIMIT nfor the same reason, and the note says so: it is a full scan bounded by the budget.
dbt builds
The dbt adapter isdbt-clickhouse, which ships with the [clickhouse] extra.
Its schema: is the ClickHouse database and it has no database: key, so
transform init renders schema: <dev_database> and nothing else.
The dev-target preflight asks a privilege question, not an existence one.
dbt-clickhouse issues CREATE DATABASE IF NOT EXISTS in its own create_schema
macro, so a missing dev database is not fatal; the privilege to create it is.
This lands with Postgres rather than with Snowflake and Databricks, where the
missing object is the whole problem.
Where it differs from Postgres is what happens when the answer is unknown.
Postgres will answer a privilege question about any role; ClickHouse shows
another user’s grants only to a caller holding the right access. So the preflight
has three outcomes rather than two:
A refusal names the statement that fixes it:
transform init renders a custom_settings
block whose values are env_var references, and transform build sets those
variables to the confirmed budget, so the ceiling becomes a per-statement
server-side cap:
0, because in ClickHouse 0
means no limit: a zero default would render a profile that silently removes the
backstop the moment anyone runs dbt by hand.
A hand-written profile without those references is perfectly valid and builds
fine; what it cannot do is be capped. dex warns in that case rather than
refusing, and the build result says the run was uncapped rather than claiming a
cap it never injected.
transform build --verify costs only where the warehouse keeps no row count, so a
table’s count is free system.tables metadata; how the counts are priced into the
build’s own estimate is in �2.
transform test --mutate prices its whole batch as one number and confirms it
once, then runs one dbt invocation per mutant. Nothing is materialized: a mutant
builds as an ephemeral model, so the dev namespace holds exactly the relations it
held before. A budget that runs out partway stops the run, and the remaining
mutants are reported not_run rather than the budget being exceeded. Survivors
are not yet compared with the model on the dev data on this connector, so each
reports equivalence.status: not_checked with the reason, and nothing is
priced or run for it.
--verify also reads clickhouse.dev_database for the length of that one
command, because dbt writes the relations it is judging there and that database
is refused as a source everywhere else. The database need not exist yet:
dbt-clickhouse creates it on a first build, and dex lists it after dbt has run.
How that read is bounded is under Read scope in
�2.
The shipped unpivot_json_object macro has a ClickHouse implementation built on
ARRAY JOIN over JSONExtractKeysAndValuesRaw, since ClickHouse has no lateral
join. It is top-level-only and keeps each value as raw JSON text, matching the
other adapters.
Semantic layer
explore semantic query --local does not work on ClickHouse: MetricFlow ships no
ClickHouse renderer. This is declared as an inert capability with a named refusal
listing the connectors that do work, rather than degrading to another dialect’s
renderer, which would produce SQL that runs and returns wrong numbers.
A native Apache Ossie layer behaves differently here than on a three-part
connector, and both differences are worth knowing before writing a document
against ClickHouse. Ossie’s dialect enum has no ClickHouse token, so expressions
are read in the portable ANSI_SQL dialect rather than in something close, which
is the correct answer and not a fallback to guess about. And a dataset source
links to a relation only when this connector accepts the whole source as one
identifier, which here means two parts (database.table) rather than three: a
document written as database.schema.table reads cleanly and links to nothing on
ClickHouse while linking on DuckDB. See
Apache Ossie compatibility.
Testing
Offline, deterministic, and free:tests/fakes/clickhouse.py is a stateful fake
of the client surface, driving tests/adapters/test_connect_clickhouse.py and
the ClickHouse block of the safety spine.
The live suite runs against a container you start yourself, so there is nothing
to provision and nothing to spend. The same script CI runs stands it up locally:
clickhouse-cloud-integration environment is created
by scripts/setup_clickhouse_cloud_ci.sh. Before opening SQL, both CI and the
local runner call the same read-only usage preflight; it refuses once the exact
service reaches 2 compute CHC in the current UTC day. The post-run report marks
unlocked usage records provisional because Cloud metering can lag.
Setup, rotation, local execution, troubleshooting, and bounded teardown are in
scripts/clickhouse_cloud/README.md. To run locally, with the protected
non-secret values and the two database credentials in the environment and the
scoped API key’s two files in DEX_SECRETS_DIR (the README shows how to write
them; the key is never read from the environment):
order_items rows point at products that do not exist (so an orphan
probe that reports zero has lost join_use_nulls), and events.occurred_at has
exactly three consecutive days missing from a 90-day span (so a continuity check
that reports no gap has the window function wrong).
Source: references/clickhouse.md (edit on main). This page is generated; change the source, not the site.