A ClickHouse dashboard query joins a fact table of billions of rows to a dim_product table of a few million. On every run, the hash JOIN reads the dimension and builds a hash table from it on each server, and under load some queries fail with MEMORY_LIMIT_EXCEEDED. A dictionary keeps that dimension in a key-value structure that loads once, refreshes in the background, and is read with dictGet or a direct JOIN. This guide covers CREATE DICTIONARY, sources, layouts, LIFETIME, the lookup functions, memory in system.dictionaries, and the failure modes.
- Use a dictionary for a dimension with a unique key that fits in RAM on every server, where the fact row already carries the key.
- Start with
hashed_arrayforUInt64keys andcomplex_key_hashedfor string or composite keys. Keepcachelayouts out of insert paths. - Set
LIFETIME(MIN x MAX y)so servers reload at random times, and addinvalidate_queryorupdate_fieldfor large sources. - Monitor
error_countandlast_successful_update_timeinsystem.dictionaries: a failed reload keeps serving the old copy with no query error. - Dictionary memory is charged to the dictionary, not the query. You trade per-query peaks for a resident cost on every server.
Scope and review date: November 2026. This post covers self-managed ClickHouse 26.8 LTS and 26.10 (the newest release in the changelog on 9 November 2026), with notes on ClickHouse Cloud and on JOIN changes since 24.12. Syntax, defaults, and version notes were checked against the ClickHouse documentation and changelog on 9 November 2026. The commands were not run in a lab, so test them on staging first.
When does a ClickHouse dictionary beat a JOIN?
A dictionary is an in-memory key-to-attributes map that ClickHouse loads from a source and keeps on each server. The dictionary best-practice page says to use one in place of a JOIN against a small dimension table when the fact row already has the key. A JOIN builds a hash table from the right-hand side first; dictGet runs inline, only on rows that survived filtering.
A dictionary loses when WHERE filters on a looked-up value across most rows, such as dictGet('dict', 'elevation', id) > 1800. A regular column can use PREWHERE and indexes; dictGet runs row by row.
-- The shape that suits a dictionary: one key, many fact rows, few attributes
SELECT
dictGetOrDefault('analytics.product_dict', 'category', product_id, 'unknown') AS category,
count() AS orders
FROM analytics.orders
WHERE order_date >= today() - 7
GROUP BY category
ORDER BY orders DESC;Interpretation: the date filter runs first, then each surviving row does one lookup. No hash table is built.
In the ClickHouse dictionary guide, a Stack Overflow query went from 1.283 s and 3.18 GiB peak memory with a JOIN to 0.551 s and 552.26 MiB with dictGet. The dictionary itself held 4.00 GiB and took 36 s to load on a 64 GB node, so the memory moved rather than disappeared.
How do you create a dictionary with CREATE DICTIONARY?
Use the DDL form. ClickHouse recommends it over XML files, and ClickHouse Cloud only supports DDL. The statement names the attributes, the PRIMARY KEY, a SOURCE, a LAYOUT, and a LIFETIME.
CREATE DICTIONARY analytics.product_dict ON CLUSTER main
(
product_id UInt64,
category String DEFAULT 'unknown',
brand String DEFAULT '',
supplier_id UInt64 DEFAULT 0
)
PRIMARY KEY product_id
SOURCE(CLICKHOUSE(
USER 'dict_reader' PASSWORD '********'
DB 'analytics' TABLE 'dim_product'
))
LAYOUT(HASHED_ARRAY())
LIFETIME(MIN 300 MAX 360)
COMMENT 'Product dimension for order dashboards';Interpretation: without HOST, the source is the local server. DEFAULT is the value returned for a missing key. Self-managed clusters need ON CLUSTER because each server holds its own copy; ClickHouse Cloud replicates it for you.
Give the source a dedicated user with SELECT on the table. In ClickHouse Cloud, a non-default creator must set both USER and PASSWORD, or CREATE DICTIONARY fails with BAD_ARGUMENTS.
From source table to dictGet
The dictionary loads once, serves lookups from RAM, and reloads in the background
Lazy by default: with dictionaries_lazy_load at its default of 1, step 1 happens on first use, not at CREATE. The first query pays for the load, and a load failure surfaces in that query.
Sources: MySQL, PostgreSQL, HTTP, and files
The sources page lists files, HTTP(S), ODBC, MongoDB, Redis, Cassandra, and more. The two most common database sources outside ClickHouse:
-- PostgreSQL, reloaded only when the invalidate query's result changes
SOURCE(POSTGRESQL(
host 'pg-replica.internal' port 5432
user 'ch_dict' password '********'
db 'shop' table 'products'
invalidate_query 'SELECT max(updated_at) FROM products'
))
-- MySQL with two replicas tried in priority order
SOURCE(MYSQL(
port 3306 user 'ch_dict' password '********'
replica(host 'mysql-a' priority 1)
replica(host 'mysql-b' priority 2)
db 'shop' table 'products'
))Interpretation: point database sources at a replica, because every server in the cluster runs this query on every reload. SOURCE(HTTP(url '...' format 'TabSeparated')) is checked against remote_url_allow_hosts. A SOURCE(FILE(...)) created by DDL must sit under user_files.
With a query, MySQL, PostgreSQL, and ODBC sources take columns by position: keys, then range columns, then attributes in declared order. Any other order loads wrong values or fails to convert. The ClickHouse source matches by name.
Which dictionary layout should you choose?
The layouts reference names flat, hashed, and complex_key_hashed as the fastest and advises against cache layouts. Layouts without complex_key take a UInt64 key.
| Layout | Key | When it fits | Watch for |
|---|---|---|---|
flat | UInt64 | Dense integer keys in small to medium tables | Largest key capped by max_array_size, default 500,000; memory sized to the largest key |
hashed_array | UInt64 | The recommended default for most cases | bytes_allocated under-reported before 26.9 |
hashed | UInt64 | Very few attributes | More memory than hashed_array as attributes grow |
sparse_hashed | UInt64 | One attribute, memory tight | More CPU per lookup |
complex_key_hashed | Any, composite | String or multi-column keys | Same tradeoffs as hashed |
range_hashed | UInt64 plus a range | Prices or contracts valid between two dates | Cannot back a direct JOIN |
cache | UInt64 | Data too large for RAM, hit rate at least 99% | Cold after restart; misses query the source |
direct | UInt64 | Data that changes too often to hold | Every lookup queries the source |
ip_trie | One String CIDR prefix | IP to ASN or country | Must fit in RAM |
-- range_hashed: the DDL adds LAYOUT(RANGE_HASHED(range_lookup_strategy 'max'))
-- and RANGE(MIN start_date MAX end_date); the lookup takes a fourth argument
SELECT dictGet('analytics.discount_dict', 'amount', toUInt64(123), toDate('2026-10-15'));Interpretation: the fourth argument picks the range. With overlapping ranges, max returns the match with the latest range_min. The default strategy, min, returns the earliest. No match returns the attribute's default.
For ip_trie, the key is one String column of CIDR prefixes, and dictGet takes an IPv4 or IPv6 value. When prefixes overlap, the most specific one wins.
How do LIFETIME and refresh work?
LIFETIME is the update interval in seconds, or the cell expiry for cache layouts. Per the LIFETIME page, updates do not block queries except on first load, a successful update replaces the old version atomically, and a failed one is logged while queries keep the old version.
LIFETIME(MIN 300 MAX 360)picks a random time in the window on each server, spreading load on the source.LIFETIME(0)turns off timed reloads.- File sources reload when the file's modification time changes; other sources reload in full each time unless
invalidate_querysays nothing changed. update_fieldfetches only changed rows forflat,hashed,hashed_array, andcomplex_key_hashed, andupdate_lagwidens the window.
-- Incremental refresh: ClickHouse adds WHERE updated_at >= (last update - 15 s)
SOURCE(CLICKHOUSE(
USER 'dict_reader' PASSWORD '********'
DB 'analytics' TABLE 'dim_product'
update_field 'updated_at' update_lag 15
))
-- Force a reload now, on every server
SYSTEM RELOAD DICTIONARY ON CLUSTER main analytics.product_dict;Interpretation: an incremental refresh selects only rows whose update_field moved, so it cannot see deleted rows. If deletes matter, schedule SYSTEM RELOAD DICTIONARY, which the SYSTEM reference says reloads it completely.
SYSTEM RELOAD DICTIONARY returns Ok. whatever the result of the update. Read status and last_exception in system.dictionaries afterwards. Do not treat the statement's success as proof of a reload.
How do you look up values with dictGet and dictGetOrDefault?
The dictionary functions take the dictionary name, one attribute or a tuple of attributes, and the key. A simple-key dictionary expects a UInt64 key. A composite key is a tuple, and a single-column composite key can be passed bare. For DDL dictionaries, qualify the name with the database, or the current database is used.
SELECT
order_id,
dictGet('analytics.product_dict', 'category', toUInt64(product_id)) AS category,
dictGetOrDefault('analytics.product_dict', 'brand', toUInt64(product_id), 'n/a') AS brand,
dictGetOrNull('analytics.product_dict', 'supplier_id', toUInt64(product_id)) AS supplier,
dictHas('analytics.product_dict', toUInt64(product_id)) AS known_product
FROM analytics.orders
WHERE order_date = today()
LIMIT 10;Interpretation: for a missing key, dictGet returns the attribute's DEFAULT, dictGetOrDefault returns your fourth argument, and dictGetOrNull returns NULL. dictHas returns 1 or 0. Use it to count orphan keys rather than reading defaults as real values.
Passing a tuple of attribute names, such as ('category', 'brand'), returns both in one lookup. Two tips from the layouts page: call the function after GROUP BY where you can, and mark an attribute INJECTIVE when different keys always map to different values. ClickHouse can then move the lookup out of GROUP BY automatically.
Can a JOIN use a dictionary directly?
Yes. A DDL dictionary can be named in a JOIN like a table, and ClickHouse can run it with the direct algorithm: no build phase, one lookup per left-hand row, memory charged to the dictionary, no spill. The JOIN algorithms page lists the conditions. The JOIN is INNER ALL or LEFT with ALL, ANY, SEMI, or ANTI. ON is exactly one equality on the dictionary's single-column key, with a matching type. The dictionary has no range key.
Since 26.8 the default join_algorithm is direct,parallel_hash,hash,ie_join, so a qualifying JOIN uses direct with no setting change. Anything else falls through to the next algorithm.
SELECT o.order_id, p.category
FROM analytics.orders AS o
LEFT ANY JOIN analytics.product_dict AS p ON o.product_id = p.product_id
WHERE o.order_date = today()
SETTINGS join_algorithm = 'direct';Interpretation: pinning direct while testing makes a non-qualifying JOIN fail instead of falling back to hash. In ClickHouse's direct join walkthrough, EXPLAIN PLAN shows a FilledJoin step with no read of the dimension underneath. From 26.10, system.query_log also records used_join_algorithms.
Hash JOIN, direct JOIN, and dictGet side by side
Switch tabs to see where the work and the memory go for the same dimension lookup
Hash JOIN
| Aspect | Hash JOIN on dim_product |
|---|---|
| Right side at query time | Dimension read on every query |
| Build phase | Per query, per server |
| Memory charged to | The query, under max_memory_usage |
| Filters into the dimension | Pushed down where possible, since 24.12 |
| JOIN types | All, including RIGHT, FULL, and ASOF |
| Freshness | Current table data |
| Spill to disk | Via grace_hash past the spill threshold |
Direct JOIN
| Aspect | JOIN on product_dict, direct algorithm |
|---|---|
| Right side at query time | Lookups in the loaded dictionary |
| Build phase | None |
| Memory charged to | The dictionary, in bytes_allocated |
| Filters into the dimension | Not pushed into a filled right side |
| JOIN types | INNER ALL and LEFT; one key |
| Freshness | Last successful dictionary load |
| Spill to disk | Never; must fit in RAM |
dictGet
| Aspect | dictGet in the SELECT list |
|---|---|
| Right side at query time | One lookup per evaluated row |
| Build phase | None |
| Memory charged to | The dictionary, in bytes_allocated |
| Filters into the dimension | Not needed; lookups run after WHERE |
| JOIN types | Not a JOIN; missing keys return a default |
| Freshness | Last successful dictionary load |
| Spill to disk | Never; must fit in RAM |
Semantics differ: an INNER JOIN drops fact rows with no match; dictGet keeps them with a default. Add dictHas to WHERE for inner behavior.
How much memory does a dictionary use?
Dictionaries store data uncompressed, so bytes_allocated can far exceed the table's size on disk, on every server. Measure the uncompressed columns first, then read the real figure from system.dictionaries.
SELECT
formatReadableSize(sum(data_compressed_bytes)) AS on_disk,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed
FROM system.columns
WHERE database = 'analytics' AND table = 'dim_product'
AND name IN ('product_id', 'category', 'brand', 'supplier_id');Interpretation: the uncompressed figure is a floor before hash table overhead. Multiply by the server count for the cluster cost.
Reading dictionary health in system.dictionaries
Illustrative output: the columns are real, the values are examples
Read errs with status: supplier_dict is still serving a copy from hours ago while its reloads fail. status alone would not show that; error_count, last_successful_update_time, and last_exception do.
Three memory traps come from the docs and changelog:
- Sparse
flatkeys. The array is sized to the largest key, and a key abovemax_array_sizefails the load. - Under-reported
hashed_array. The 26.9 changelog fixedbytes_allocatedforHASHED_ARRAYdictionaries, which came out roughly 2 to 5 times lower than the memory held. On older releases, check the server's resident memory before trusting the column. - Reload headroom. The old version serves until the new one replaces it, so budget for two copies during a reload. That is our reading of the atomic swap, not a documented figure; measure it on staging.
What breaks when the source fails or the data grows?
The source is down
If the first load fails, the query that triggered it gets the exception, and so does every later query until a load succeeds. If a reload fails after a successful load, queries keep reading the old copy and only the log, error_count, and last_exception show it. For MySQL, fail_on_connection_loss defaults to false, so ClickHouse retries at least three times before reporting an error, and the docs note that retrying increases response times.
-- Alert query: dictionaries failing, or not refreshed for 30 minutes
SELECT database, name, status, error_count, last_exception,
last_successful_update_time
FROM clusterAllReplicas('main', system.dictionaries)
WHERE status IN ('FAILED', 'FAILED_AND_RELOADING')
OR error_count > 0
OR (lifetime_max > 0 AND last_successful_update_time < now() - INTERVAL 30 MINUTE);Interpretation: any row is a dictionary serving stale data or none. The table is local to each server, so the query fans out. Set the threshold to a few multiples of LIFETIME MAX.
Stale data
Upgrades can cause it too. The 26.10 changelog fixed a local CLICKHOUSE source that kept serving stale data with ACCESS_DENIED after its user's roles changed. The same release stops DDL MySQL sources from enabling enable_local_infile, and makes sources that set ssl_ca verify the certificate and host name. Either can turn a working dictionary into a failed load.
Memory blowups
A growing dimension grows the dictionary on every server, outside any per-query limit. cache layouts are no escape for insert paths: a restarted server starts with an empty cache, so inserts that call dictGet depend on the source again. For MATERIALIZED columns and materialized views, use a fully loaded layout, or enrich before insert.
On staging, stop the source during a reload and confirm your alert fires while queries keep reading the old copy.
How do you convert a dimension JOIN to a dictionary?
This runbook replaces a LEFT JOIN dim_product in a dashboard query. Production queries are untouched until step 6.
Runbook: dimension JOIN to dictionary
Eight steps; nothing user-facing changes before step 6
-
Confirm the key is unique
A dictionary keeps one value per key; a JOIN returns every duplicate.
SELECT product_id, count() AS c FROM analytics.dim_product GROUP BY product_id HAVING c > 1 LIMIT 10 -
Size it against RAM on every server
Uncompressed size of the loaded columns, plus reload headroom.
SELECT formatReadableSize( sum(data_uncompressed_bytes)) FROM system.columns WHERE table = 'dim_product' -
Create a read-only source user
The dictionary connects as this user on every reload.
CREATE USER dict_reader IDENTIFIED BY '...'; GRANT SELECT ON analytics.dim_product TO dict_reader; -
Create the dictionary on every server
The DDL from the create section, with ON CLUSTER.
CREATE DICTIONARY analytics.product_dict ON CLUSTER main (...) -
Load it and read the status
RELOAD returns Ok even on failure, so check status and last_exception.
SYSTEM RELOAD DICTIONARY analytics.product_dict; SELECT status, element_count, last_exception FROM system.dictionaries WHERE name = 'product_dict' -
Rewrite the query
Keep the default the LEFT JOIN produced.
dictGetOrDefault('analytics.product_dict', 'category', product_id, '') AS category -
Prove the results match
Fixed date range; EXCEPT in both directions must return 0.
SELECT count() FROM ( (old_query) EXCEPT (new_query)) -
Compare cost in query_log
Tag each run with log_comment and compare.
SELECT log_comment, query_duration_ms, formatReadableSize(memory_usage), read_rows FROM system.query_log WHERE type = 'QueryFinish' AND log_comment IN ('join', 'dict')
Rollback point: before step 6 ships, DROP DICTIONARY undoes everything. After it, rollback means redeploying the old query, so keep it.
What are the alternatives to a dictionary?
A dictionary is one of several ways to avoid a costly JOIN. ClickHouse's JOIN best-practice page still recommends denormalizing for latency-sensitive queries when the joined attributes rarely change.
- The
Jointable engine. A prepared in-RAM hash table, persisted to disk, read withjoinGetor as a JOIN's right side. Its strictness and type must match the JOIN, or, per the docs, ClickHouse may return incorrect data without an exception. It cannot be used inGLOBAL JOIN. - Insert-time enrichment. A
MATERIALIZEDcolumn withdictGetstores the value once, so use it only for attributes that never change. - A better regular JOIN. Since 24.12 the planner can build the smaller side and push filters below the JOIN. Since 25.12, multi-table reordering and statistics are on by default, and
directworks againstMergeTreewhen it is the onlyjoin_algorithm. Statistics went GA in 26.3 and are built on insert from 26.8, and ClickHouse's JOIN guide now says to use 26.8 or later.
CREATE TABLE analytics.product_join (product_id UInt64, category String)
ENGINE = Join(ANY, LEFT, product_id);
INSERT INTO analytics.product_join SELECT product_id, category FROM analytics.dim_product;
SELECT joinGet('analytics.product_join', 'category', toUInt64(42));Interpretation: the Join table is as fresh as your last INSERT. It has no LIFETIME, so you schedule the refresh yourself.
Frequently asked questions
Is dictGet faster than a JOIN in ClickHouse?
Often, for a small dimension with a unique key: there is no per-query build phase, and lookups run only on rows that pass the filter. ClickHouse's own example dropped from 1.283 s to 0.551 s. It loses when you filter on the looked-up value across most rows, because dictGet cannot use indexes.
How much memory does a ClickHouse dictionary use?
Read bytes_allocated in system.dictionaries. Dictionaries hold data uncompressed, so expect more than the source's on-disk size, and every server holds a full copy. Budget for a second copy during reloads. Before 26.9, bytes_allocated under-reported hashed_array dictionaries by roughly 2 to 5 times.
What happens to queries when the dictionary source is down?
If the dictionary loaded before, queries keep using the last good copy, and the failure shows only in the server log, error_count, and last_exception. If it never loaded, every query that uses it fails until a load succeeds. Alert on error_count and on last_successful_update_time, not only on status.
Can I JOIN a dictionary like a table?
Yes. A dictionary created with DDL can be named in a JOIN, and ClickHouse uses the direct algorithm when the JOIN is INNER ALL or a LEFT JOIN, with one equality on the dictionary key. direct is first in the default join_algorithm list. Range-key dictionaries do not qualify and fall back to a hash JOIN.
Key takeaways
- Use a dictionary only for a dimension with a unique key that fits uncompressed in RAM on every server.
- Default to
hashed_arrayorcomplex_key_hashed. Useflatonly for dense integer keys, and keepcachelayouts away from inserts. - Point
LIFETIME(MIN x MAX y)reloads at a replica, and schedule full reloads if you useupdate_fieldand deletes matter. - Monitor
error_count,last_successful_update_time, andbytes_allocatedacross all servers.SYSTEM RELOAD DICTIONARYreturns Ok even when the reload fails. - Prove equal results with
EXCEPTbefore switching. AnINNER JOINanddictGettreat missing keys differently.
Tune ClickHouse JOINs with JusDB
JusDB's ClickHouse performance tuning team can review the JOINs behind your slowest and most memory-hungry queries. We can pick the ones that suit a dictionary, size the layouts against your servers, and rewrite and verify the queries with you.
Bring your top queries from system.query_log, table sizes, version, and cluster layout. Your team approves every production change.