JusDB
Free DB Audit
Analytics & OLAP

ClickHouse Dictionaries: Replacing JOINs with In-Memory Lookups

Replace ClickHouse dimension JOINs with dictionaries: choose a layout, set LIFETIME refresh, use dictGet or a direct JOIN, size memory, and catch failed reloads.

JusDB Team
Published October 24, 2026
Updated November 9, 2026
18 min read

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.

TL;DR
  • 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_array for UInt64 keys and complex_key_hashed for string or composite keys. Keep cache layouts out of insert paths.
  • Set LIFETIME(MIN x MAX y) so servers reload at random times, and add invalidate_query or update_field for large sources.
  • Monitor error_count and last_successful_update_time in system.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.

sql
-- 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.

The vendor's own numbers

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.

sql
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

Dictionary load, lookup, and reload Step 1: ClickHouse reads the source and builds the dictionary in memory on each server. Step 2: queries call dictGet per row and read from memory, not the source. Step 3: every LIFETIME, the dictionary re-reads the source while the old copy keeps serving. Source CH, MySQL, PG, HTTP Dictionary in RAM, per server Query dictGet per row 1 2 3 full load lookup, no source access reload every LIFETIME; old copy serves until swap

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:

sql
-- 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.

Custom query column order

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.

LayoutKeyWhen it fitsWatch for
flatUInt64Dense integer keys in small to medium tablesLargest key capped by max_array_size, default 500,000; memory sized to the largest key
hashed_arrayUInt64The recommended default for most casesbytes_allocated under-reported before 26.9
hashedUInt64Very few attributesMore memory than hashed_array as attributes grow
sparse_hashedUInt64One attribute, memory tightMore CPU per lookup
complex_key_hashedAny, compositeString or multi-column keysSame tradeoffs as hashed
range_hashedUInt64 plus a rangePrices or contracts valid between two datesCannot back a direct JOIN
cacheUInt64Data too large for RAM, hit rate at least 99%Cold after restart; misses query the source
directUInt64Data that changes too often to holdEvery lookup queries the source
ip_trieOne String CIDR prefixIP to ASN or countryMust fit in RAM
sql
-- 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_query says nothing changed.
  • update_field fetches only changed rows for flat, hashed, hashed_array, and complex_key_hashed, and update_lag widens the window.
sql
-- 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.

RELOAD always says Ok

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.

sql
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.

sql
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

AspectHash JOIN on dim_product
Right side at query timeDimension read on every query
Build phasePer query, per server
Memory charged toThe query, under max_memory_usage
Filters into the dimensionPushed down where possible, since 24.12
JOIN typesAll, including RIGHT, FULL, and ASOF
FreshnessCurrent table data
Spill to diskVia grace_hash past the spill threshold

Direct JOIN

AspectJOIN on product_dict, direct algorithm
Right side at query timeLookups in the loaded dictionary
Build phaseNone
Memory charged toThe dictionary, in bytes_allocated
Filters into the dimensionNot pushed into a filled right side
JOIN typesINNER ALL and LEFT; one key
FreshnessLast successful dictionary load
Spill to diskNever; must fit in RAM

dictGet

AspectdictGet in the SELECT list
Right side at query timeOne lookup per evaluated row
Build phaseNone
Memory charged toThe dictionary, in bytes_allocated
Filters into the dimensionNot needed; lookups run after WHERE
JOIN typesNot a JOIN; missing keys return a default
FreshnessLast successful dictionary load
Spill to diskNever; 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.

sql
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

clickhouse-client: ch-01
ch-01 :) SELECT name, status, element_count AS rows, formatReadableSize(bytes_allocated) AS ram, error_count AS errs, last_successful_update_time AS loaded FROM system.dictionaries ORDER BY bytes_allocated DESC;name status rows ram errs loadedproduct_dict LOADED 1840221 212.48 MiB 0 2026-10-23 10:42:17supplier_dict LOADED 48112 6.91 MiB 4 2026-10-23 06:02:51country_dict LOADED 249 41.23 KiB 0 2026-10-23 10:40:03promo_dict FAILED 0 0.00 B 9 1970-01-01 00:00:00 4 rows in set.

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 flat keys. The array is sized to the largest key, and a key above max_array_size fails the load.
  • Under-reported hashed_array. The 26.9 changelog fixed bytes_allocated for HASHED_ARRAY dictionaries, 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.

sql
-- 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.

Rehearse an outage

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

  1. 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
  2. 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'
  3. 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;
  4. Create the dictionary on every server

    The DDL from the create section, with ON CLUSTER.

    CREATE DICTIONARY analytics.product_dict ON CLUSTER main (...)
  5. 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'
  6. Rewrite the query

    Keep the default the LEFT JOIN produced.

    dictGetOrDefault('analytics.product_dict', 'category', product_id, '') AS category
  7. Prove the results match

    Fixed date range; EXCEPT in both directions must return 0.

    SELECT count() FROM ( (old_query) EXCEPT (new_query))
  8. 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 Join table engine. A prepared in-RAM hash table, persisted to disk, read with joinGet or 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 in GLOBAL JOIN.
  • Insert-time enrichment. A MATERIALIZED column with dictGet stores 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 direct works against MergeTree when it is the only join_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.
sql
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_array or complex_key_hashed. Use flat only for dense integer keys, and keep cache layouts away from inserts.
  • Point LIFETIME(MIN x MAX y) reloads at a replica, and schedule full reloads if you use update_field and deletes matter.
  • Monitor error_count, last_successful_update_time, and bytes_allocated across all servers. SYSTEM RELOAD DICTIONARY returns Ok even when the reload fails.
  • Prove equal results with EXCEPT before switching. An INNER JOIN and dictGet treat 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.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

StarRocks Shared-Data vs Shared-Nothing: Choosing a Deployment Mode

Compare StarRocks shared-data and shared-nothing modes: object storage, data cache sizing, compaction, failure handling, migration limits, and when each mode fits.

StarRocks18 minOct 31, 2026
Read

StarRocks Materialized Views and Automatic Query Rewrite

Choose sync or async StarRocks materialized views, set refresh and staleness, accelerate Iceberg and Hive tables, and prove each query rewrite with EXPLAIN.

StarRocks18 minOct 31, 2026
Read

StarRocks 4.1: Adaptive Tablets, Range Distribution, and the Upgrade Trade-Offs

StarRocks 4.1 makes tablet layout adaptive: Range distribution, automatic splitting at 10 GB, and 100 GB tablets. When to enable it, and what it costs.

StarRocks13 minAug 3, 2026
Read