ClickHouse 26.8 shipped on 27 August 2026 as the second LTS line of the year, and two days later the 25.8 LTS line dropped out of support. If you run 25.8 in production, you are now on an unsupported build and the upgrade target is ClickHouse 26.8, not 26.3. That is the operational headline. The technical headline is that ClickHouse 26.8 is the release where the aggregation engine, the join planner and the HTTP serving layer all moved at once, which matters if ClickHouse sits behind a dashboard, an API or an alerting pipeline and your p99 is the thing your customers notice.
This is not the changelog retold. ClickHouse counts 98 new features, 128 performance optimisations and 556 bug fixes in ClickHouse 26.8, and most of them will never touch your workload. What follows is the subset that changes how you design schemas, write queries or plan an upgrade, with each claim run on a ClickHouse 26.8.2.7 build so you can see real output rather than release-call slides. Where a feature did not help on my test box, I say so.
ClickHouse 26.8 LTS at a glance
| Item | Detail |
|---|---|
| Version | 26.8 (first patch build 26.8.2.7) |
| Release date | 27 August 2026 |
| Support status | LTS, supported until 27 August 2027 |
| Previous LTS | 26.3 (released 26 March 2026, supported until 26 March 2027) |
| Just expired | 25.8 LTS, support ended 29 August 2026 |
| Breaking changes since 26.3 | 21 in 26.8 itself, 57 across 26.4 to 26.8 |
| x86 requirement | AVX2 mandatory for the default build since 26.6 |
How the ClickHouse 26.8 numbers in this post were produced
Every measurement below comes from a deliberately small box, so treat the absolute numbers as a lower bound and the ratios as the interesting part. Hardware: a 2 vCPU x86-64 VM with AVX2, 7 GiB RAM, local disk, no other load. Software: ClickHouse 26.8.2.7 official static build, default settings apart from what each experiment states, max_threads = 2 everywhere. Protocol: every query ran three times, page cache warm after the first run, and I report the median with min and max. Duration and peak memory are read from system.query_log (query_duration_ms, memory_usage), not from the client stopwatch. Where I quote ClickHouse’s own release-call figures I label them as theirs; they were measured on far larger machines.
The main table is 50 million synthetic click events. Schema and generator in full, so you can rerun everything:
CREATE TABLE lab.events
(
event_time DateTime CODEC(Delta, ZSTD(1)),
event_date Date MATERIALIZED toDate(event_time),
tenant_id UInt32,
user_id UInt64,
session_id String,
url_path LowCardinality(String),
referrer String,
duration_ms UInt32,
bytes UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_time, user_id)
SETTINGS index_granularity = 8192;
INSERT INTO lab.events
SELECT
toDateTime('2026-06-01 00:00:00') + intDiv(number, 60) AS event_time,
intHash32(number) % 5000 AS tenant_id,
intHash64(number) % 20000000 AS user_id,
hex(sipHash64(intDiv(number, 40))) AS session_id,
concat('/p/', toString(intHash32(number * 7) % 1200)) AS url_path,
concat('https://ref', toString(number % 99991), '.example.net') AS referrer,
intHash32(number * 13) % 30000 AS duration_ms,
intHash64(number * 17) % 500000 AS bytes
FROM numbers(50000000);
OPTIMIZE TABLE lab.events FINAL;
-- one active part, 50,000,000 rows, 1.84 GiB on diskThe cardinalities are chosen on purpose: tenant_id has 5,000 distinct values, user_id about 20 million, session_id 1.25 million. Aggregation behaviour in ClickHouse 26.8 depends heavily on which of those you group by, which is the whole point of the first section.
The ClickHouse 26.8 adaptive aggregator: GROUP BY that changes strategy mid-query
ClickHouse has always had two parallel GROUP BY strategies and a heuristic to choose between them. The classic one gives every thread its own hash table and merges them at the end; it wins when the key count is small because the merge is cheap. The two-level one splits each thread’s table into 256 buckets so the final merge can itself run in parallel; it wins when the key count is large. The trouble was that the decision was made from an estimate, and a query over a key whose cardinality varies by tenant, by day or by shard could land on the wrong side of it.
ClickHouse 26.8 introduces enable_adaptive_aggregator, on by default. Each thread aggregates into a local hash table until it hits adaptive_aggregator_freeze_threshold (16,384 keys) or adaptive_aggregator_freeze_threshold_bytes (4 MiB), whichever comes first. At that point the table is frozen, handed off for merging, and the thread starts a fresh one. Low-cardinality keys never reach the threshold and behave exactly as before. High-cardinality keys produce a stream of small, cache-resident tables that are merged and split across threads by key hash instead of one giant merge at the end.
Measured on ClickHouse 26.8 with the 50M-row table, grouping by the 20-million-key column and then by the 5,000-key column, with the new algorithm off and on:
| Query | enable_adaptive_aggregator | Median | Min / max | Peak memory |
|---|---|---|---|---|
| GROUP BY user_id (20M keys) | 0 | 3.68 s | 3.60 / 5.20 | 1.51 GiB |
| GROUP BY user_id (20M keys) | 1 (default) | 1.51 s | 1.44 / 1.66 | 1.42 GiB |
| GROUP BY tenant_id (5K keys) | 0 | 0.28 s | 0.28 / 0.32 | 776 KiB |
| GROUP BY tenant_id (5K keys) | 1 (default) | 0.26 s | 0.23 / 0.30 | 933 KiB |
A 2.4x improvement on the high-cardinality case with only two threads, and no regression on the low-cardinality case, which is exactly the behaviour the design promises. On a 32-core box the merge phase is a larger share of the total and I would expect the gap to widen, but I have not measured that and will not guess at it. What I would flag for production: peak memory barely moved, so this is a CPU and cache-locality win, not a memory win. If your high-cardinality GROUP BY queries are memory-bound you still need max_bytes_before_external_group_by or a schema-side fix.
GROUP BY with a LIMIT no longer builds the whole hash table
The pattern GROUP BY key ORDER BY key LIMIT k is everywhere in dashboards: first 100 accounts, first 20 devices, next page of a keyed list. Until now ClickHouse aggregated every group and threw all but k away. In ClickHouse 26.8 enable_group_by_top_k_optimization (default 1) keeps a bounded heap of k keys inside each aggregation stream. Once a stream has observed group_by_top_k_optimization_observation_rows (65,536) rows it decides whether the heap is actually pruning anything; if it is not, the heap is frozen so that the optimisation costs nothing on shapes it cannot help.
| Query (GROUP BY user_id ORDER BY user_id LIMIT 10) | Median | Peak memory |
|---|---|---|
| enable_group_by_top_k_optimization = 0 | 1.55 s | 1.41 GiB |
| enable_group_by_top_k_optimization = 1 (default) | 0.34 s | 336 KiB |
Same 50 million rows read in both cases; the difference is that the hash table never grows past ten entries per stream. 4.6x faster and roughly 4,000x less memory. ClickHouse’s release call shows the same shape on 500 million rows going from 2.32 s and 19.9 GB to 0.22 s and 2.3 MB, and having reproduced the mechanism I believe the order of magnitude.
One honest caveat. The optimisation applies when the ORDER BY is a prefix of the GROUP BY keys. The far more common dashboard query, GROUP BY user_id ORDER BY count() DESC LIMIT 10, goes through a different path (query_plan_aggregation_bucket_top_k), which only prunes when the plan can prove per-bucket selection is exact. On my table that query ran in 1.27 s with the setting off and 1.33 s with it on, which is noise. You cannot avoid aggregating every group when the sort key is the aggregate itself; the win here is for keyed pagination, not for top-N-by-metric.
The place to see which path you are on in ClickHouse 26.8 is EXPLAIN ANALYZE, which arrived in 26.7 and is the single most useful diagnostic addition of this LTS cycle. It runs the query, discards the rows and annotates the plan with measured time, rows in and out, and observed parallelism at every step:
EXPLAIN ANALYZE
SELECT tenant_id, count() AS hits
FROM lab.events
WHERE event_time >= '2026-06-05' AND url_path = '/p/17'
GROUP BY tenant_id
ORDER BY hits DESC
LIMIT 5;
Query summary:
Time: 334.48 ms (planning 4.22 ms · execution 330.26 ms)
Read: 50.00 million rows, 498.25 MB (151.40 million rows/s., 1.51 GB/s.)
Peak memory: 463.52 KiB
Expression (Project names)
└──Limit (preliminary LIMIT)
└──Sorting (Sorting for ORDER BY)
│ I/O: rows 4.96 thousand → 5 (0.10%)
│ Stage (sorting): time 75.69 us (0.0%) · parallelism 0.99/2
└──Expression ((Before ORDER BY + Projection))
└──Aggregating
│ Keys: tenant_id
│ Aggregates: count()
│ Bucket top-K: 5 descending <-- the 26.8 path, visible in the plan
│ I/O: rows 24.56 thousand → 4.96 thousand (20.20%)
│ Stage (partial aggregation): time 4.01 ms (1.2%) · parallelism 1.01/2
│ Stage (final aggregation): time 506.52 us (0.2%) · parallelism 1.52/2
└──Expression ((WHERE + Change column names to column identifiers))
└──ReadFromMergeTree (lab.events)
Parts: 1 | Granules: 6104
Prewhere filter column: event_time >= '2026-06-05' AND url_path = '/p/17'
... (index sections trimmed)Note the parallelism figure on the Aggregating step: 1.01 out of 2 threads during partial aggregation. That number, per step, is what you used to reconstruct by hand from system.processors_profile_log. Also note the read: the PREWHERE on url_path cut 50 million rows to 24 thousand before aggregation, but all 6,104 granules were still scanned because url_path is not in the sort key. That is a schema finding, and EXPLAIN ANALYZE surfaces it in one call.
Packed string keys: less memory, and on this box, slower
ClickHouse 26.8 also enables enable_packed_string_keys_in_aggregation by default. For a GROUP BY on a single non-nullable String column the hash table is keyed by a 16-byte packed reference: strings up to 11 bytes are stored inline, longer ones are referenced. The intent is a smaller hash-table cell and better cache behaviour. ClickHouse reports 1.60 s to 0.88 s on a 200M-row example. I grouped by session_id, which is a 16-character hex string, so every key takes the long path:
| GROUP BY session_id (1.25M keys, 16 bytes each) | Median | Min / max | Peak memory |
|---|---|---|---|
| enable_packed_string_keys_in_aggregation = 0 | 1.85 s | 1.80 / 2.19 | 930 MiB |
| enable_packed_string_keys_in_aggregation = 1 (default) | 2.66 s | 2.59 / 2.66 | 323 MiB |
Memory dropped by almost two thirds, which is real and useful. Wall time got 44% worse, consistently across the three runs. My reading is that with keys longer than the inline limit and only two threads, the extra indirection costs more than the smaller cell saves; ClickHouse’s example likely used short keys where the inline path dominates. Whether this is a win for you depends on your key lengths, and the cheapest way to find out is SETTINGS enable_packed_string_keys_in_aggregation = 0 on your slowest string GROUP BY, compared through system.query_log. If you are memory-constrained keep it on; if you are latency-constrained with long keys, test before you trust the default.
Joins in ClickHouse 26.8: IEJoin makes interval and range joins usable
A join whose ON clause has only inequality conditions, such as “every trade that falls inside a quote window”, has historically been the query that makes people leave ClickHouse for something else. The hash join has no equality key to hash on, so it degrades to a cross product with a filter: 200,000 by 200,000 rows is 40 billion comparisons. ClickHouse 26.8 adds ie_join to the default join_algorithm list (direct,parallel_hash,hash,ie_join), so the planner picks it automatically when the ON clause is two inequality comparisons and nothing else.
IEJoin is the sort-based inequality join from Khayyat et al. (VLDB 2015). Both sides are sorted on the first inequality column; a permutation array records where each row lands when sorted by the second; a bitmap scan over the permutation then finds, for each left row, the right rows that satisfy both conditions without touching the pairs that cannot. Complexity is dominated by the sorts, not by the product of the two sizes.
CREATE TABLE lab.trades
(
trade_id UInt64,
symbol LowCardinality(String),
ts DateTime64(3),
price Float64
)
ENGINE = MergeTree
ORDER BY (symbol, ts);
CREATE TABLE lab.quote_windows
(
window_id UInt64,
symbol LowCardinality(String),
window_start DateTime64(3),
window_end DateTime64(3)
)
ENGINE = MergeTree
ORDER BY (symbol, window_start);
-- 200,000 rows each; windows are 500 ms wide, trades every 7 ms
EXPLAIN PLAN
SELECT count()
FROM lab.trades AS t
INNER JOIN lab.quote_windows AS q
ON t.ts > q.window_start
AND t.ts < q.window_end;
└──IEJoin
├──Sorting (Sort Left before JOIN)
└──Sorting (Sort Right before JOIN)| Input sizes | join_algorithm | Median | Peak memory |
|---|---|---|---|
| 30K × 30K | hash (forced) | 4.40 s | 4.3 MiB |
| 30K × 30K | ie_join | 0.01 s | 2.8 MiB |
| 200K × 200K | default (planner chose IEJoin) | 0.04 s | 19 MiB |
I did not run the 200K by 200K hash case; extrapolating from the 30K result it would take somewhere over three minutes on this machine and prove nothing new. ClickHouse's own figure for that size is 112 s to 1.06 s on their hardware.
Two production notes. First, the moment you add an equality condition (t.symbol = q.symbol AND t.ts > ... AND t.ts < ...) the planner goes back to the hash join keyed on symbol and evaluates the range as a post-join filter; that ran in 3.8 s here versus 6.3 s with plain hash, which is the parallel hash join doing its job, not IEJoin. Whether the equality-keyed hash or a pure IEJoin over a pre-filtered subquery is faster depends on how selective the equality key is, and you should test both shapes. Second, IEJoin sorts both sides in memory, so it is subject to max_bytes_before_external_sort on large inputs.
Column statistics are now collected on INSERT by default
This one will affect join plans on every ClickHouse 26.8 cluster without anyone opting in. materialize_statistics_on_insert is on, with auto_statistics_types = 'basic, uniq_v2', for tables up to materialize_statistics_on_insert_max_table_size (25 GiB). My events table shows it without any DDL asking for it:
SELECT table, column, statistics
FROM system.columns
WHERE database = 'lab' AND table = 'events' AND statistics != '';
┌─table──┬─column─────┬─statistics────────────────┐
│ events │ event_time │ basic(auto),uniq_v2(auto) │
│ events │ event_date │ basic(auto),uniq_v2(auto) │
│ events │ tenant_id │ basic(auto),uniq_v2(auto) │
│ events │ user_id │ basic(auto),uniq_v2(auto) │
│ events │ session_id │ basic(auto),uniq_v2(auto) │
└────────┴────────────┴───────────────────────────┘The statistics feed join reordering (query_plan_optimize_join_order_algorithm, still greedy by default, with dpsub available since 26.7) and build-side selection. ClickHouse quotes a 29% median improvement across their benchmark suite and 4.5x on TPC-H from this change; I have no multi-table dataset on the test box to confirm the magnitude, but the mechanism is sound and the plan differences are visible in EXPLAIN PLAN once statistics exist. The operational point is the cost: statistics are computed during INSERT and refreshed on merge, so if you have ingestion-bound tables under 25 GiB, watch system.part_log for a change in insert duration after upgrading. The 25 GiB cap means your large fact tables are unaffected unless you enable it for them explicitly.
Two further join changes in ClickHouse 26.8 worth knowing. join_algorithm = 'parallel_full_sorting_merge' shards a merge join by key hash across threads; it is opt-in and the right answer when both sides are too large to hash. And the Cascades cost-based optimiser (enable_cascades_optimizer = 1 with make_distributed_plan = 1) is present but experimental. It is the first time ClickHouse has had a real distributed query planner with cardinality estimates and exchange operators, and it is not something to enable on a production cluster in ClickHouse 26.8; it is something to start testing on a replica so you have an opinion by 27.3.
ClickHouse 26.8 DISTINCT and window functions that respect your partition key
If a table is partitioned by day and the query does DISTINCT event_date, user_id or a window PARTITION BY event_date, user_id, rows from different partitions can never collide, so each partition can be processed by its own thread with its own state and no global merge. ClickHouse 26.8 exploits this through allow_distinct_partitions_independently and allow_window_partitions_independently, both on by default, gated by max_number_of_partitions_for_independent_distinct and its window counterpart. The optimisation only fires when the partition key is derivable from the DISTINCT or PARTITION BY columns, which is one more argument for partitioning by the column your analysts actually slice on.
For this test I copied the 50M rows into a table partitioned by event_date (10 partitions) with ORDER BY (event_date, tenant_id, user_id). The DISTINCT result is almost the full table, since user_id is nearly unique per day, which makes it a worst case for memory:
| Query | Partitions independent | Median | Outcome |
|---|---|---|---|
| SELECT DISTINCT event_date, user_id | 0 | n/a | MEMORY_LIMIT_EXCEEDED at 3.05 GiB, all three runs |
| SELECT DISTINCT event_date, user_id | 1 (default) | 6.06 s (4.82 / 7.05) | completed |
| sum(bytes) OVER (PARTITION BY event_date, user_id ORDER BY event_time) | 0 | 11.05 s | 1.05 GiB |
| sum(bytes) OVER (PARTITION BY event_date, user_id ORDER BY event_time) | 1 (default) | 8.08 s (7.65 / 8.30) | 1.04 GiB |
The DISTINCT case is the more interesting result: the old plan needed one global set of 50 million keys and blew through the server's memory cap on a 7 GiB machine, while the per-partition plan streams each day's keys through a sorted DISTINCT and completes. The window function improvement is a modest 1.37x here because two threads cannot exploit ten independent partitions; ClickHouse quotes 4.8x on their hardware and 34x for a DISTINCT that was not memory-bound to begin with. The lesson for schema design is unchanged from what we tell every client: partition by the column you filter and group by most, not by what makes TTL convenient.
Storage in ClickHouse 26.8: shared part metadata, adaptive codecs, patch parts v2
Three storage-layer changes in ClickHouse 26.8 affect capacity planning rather than query latency.
Shared part metadata. Each active part used to carry its own copy of column descriptions, serialisation info and index metadata. ClickHouse 26.8 interns that metadata and reference-counts it across parts with identical schemas. ClickHouse's example is a table with 231 columns and 3,000 parts going from about 840 MiB to about 240 MiB of resident metadata.
I could not reproduce it meaningfully on a single-part table; the place to see it on your own cluster is server RSS after restart on 26.8 versus 26.3 for a wide table with many parts, and the jemalloc_merge_tree_arenas setting, which now gives MergeTree metadata its own jemalloc arenas so that fragmentation from part churn does not leak into query memory. If you have been sizing memory around the parts count, this changes the arithmetic.
Adaptive codec selection. With the MergeTree setting enable_adaptive_codec_selection = 1 (experimental, off by default), merges try candidate codecs per compressed block for columns declared without an explicit CODEC and keep the smallest. The new table function mergeTreeCodecBlockCounts shows what was chosen:
CREATE TABLE lab.codec_demo
(
ts DateTime,
sensor UInt16,
reading Float64,
note String
)
ENGINE = MergeTree
ORDER BY (sensor, ts)
SETTINGS enable_adaptive_codec_selection = 1;
-- 5,000,000 rows inserted, then OPTIMIZE TABLE ... FINAL
SELECT part_name, column, substream, codec_block_counts
FROM mergeTreeCodecBlockCounts('lab', 'codec_demo');
┌─part_name─┬─column──┬─substream─┬─codec_block_counts─┐
│ all_1_5_1 │ note │ note.size │ {'LZ4':611} │
│ all_1_5_1 │ note │ note │ {'LZ4':306} │
│ all_1_5_1 │ reading │ reading │ {'LZ4':611} │
│ all_1_5_1 │ sensor │ sensor │ {'LZ4':153} │
│ all_1_5_1 │ ts │ ts │ {'T64':306} │ <-- chosen per block, no CODEC clause in the DDL
└───────────┴─────────┴───────────┴────────────────────┘The timestamp column was moved to T64 without anyone specifying it. This is a convenience for tables where nobody got around to codec tuning, and a diagnostic for tables where somebody did: if adaptive selection picks something different from your explicit CODEC on a staging copy, that is evidence worth looking at. Because it runs on merge it costs merge CPU, which is why it is off by default and why I would not turn it on for a table with heavy merge pressure without watching system.merges first.
Patch parts v2. Lightweight UPDATE now writes patch parts in a v2 on-disk format sorted by (sorting_key..., _block_number, _block_offset) with a new merge algorithm. This is a breaking on-disk change for anyone who uses lightweight updates. During a rolling upgrade from 26.3 pin patch_parts_version = 'v1' at the MergeTree level until every replica is on ClickHouse 26.8, then remove the pin. The same rolling-upgrade discipline applies to text_index_serialization_version, whose default moved to v2_with_positions in 26.7.
The serving layer: ClickHouse as the API behind real-time analytics
This is the part of ClickHouse 26.8 that changes architectures rather than plans. Most real-time analytics deployments put a thin API service between ClickHouse and the consumer, whose only job is to turn a URL into a parameterised query and shape the output. ClickHouse 26.8 lets the server do that itself.
CREATE HANDLER: parameterised HTTP endpoints defined in SQL
CREATE HANDLER top_paths URL '/top_paths' AS
SELECT url_path, count() AS hits
FROM lab.events
WHERE tenant_id = {tenant:UInt32}
GROUP BY url_path
ORDER BY hits DESC
LIMIT 3;
SELECT name, url, methods, type FROM system.handlers;
-- top_paths /top_paths ['GET'] query
$ curl -s 'http://127.0.0.1:8123/top_paths?tenant=42'
/p/1125 20
/p/811 17
/p/804 16
$ curl -s 'http://127.0.0.1:8123/top_paths?tenant=42&default_format=JSONCompact'
{
"meta": [ {"name": "url_path", "type": "LowCardinality(String)"}, {"name": "hits", "type": "UInt64"} ],
"data": [ ["/p/1125", 20], ["/p/811", 17], ["/p/804", 16] ],
...Handlers in ClickHouse 26.8 are first-class objects: ALTER HANDLER, DROP HANDLER, listed in system.handlers, and introspectable from inside a query with currentHandler() and currentRequestURL(). Query parameters are typed, so ?tenant=abc is rejected at the parameter layer rather than reaching the SQL. Before this the equivalent was XML http_handlers blocks and a config reload; now it is DDL that can be version-controlled and applied ON CLUSTER.
Alongside this, the HTTP interface gained a REST-style path grammar (/database/table.format?filter=...&order=...&limit=...&page=...) behind the server settings http_allow_database_as_path, http_allow_table_as_file and http_allow_filters_as_path. They are off by default and must be enabled in server configuration, not per request; I confirmed that passing them as URL parameters does nothing, which is the correct security posture. Also new is framing_output_format (EventStream, JSONEachPacketString, JSONEachPacketBase64), which multiplexes data, progress and server logs over a single HTTP response so a browser client can render a progress bar from a server-sent event stream without a second connection.
Queries that outlive the connection
Anyone who has watched a 40-second INSERT SELECT die because a load balancer idle timeout closed the client socket will appreciate run_query_in_background. The server returns an empty result immediately, keeps executing, and the query stays visible in system.processes until it finishes or you KILL QUERY it:
-- client returns immediately
SELECT user_id, count()
FROM lab.events
GROUP BY user_id
FORMAT Null
SETTINGS run_query_in_background = 1, query_id = 'bg_demo_1';
-- 300 ms later, from another session
SELECT query_id, elapsed, is_cancelled, read_rows
FROM system.processes
WHERE query_id = 'bg_demo_1';
┌─query_id──┬──elapsed─┬─is_cancelled─┬─read_rows─┐
│ bg_demo_1 │ 0.383263 │ 0 │ 5101902 │
└───────────┴──────────┴──────────────┴───────────┘
-- and after it finishes
SELECT query_id, type, query_duration_ms, formatReadableSize(memory_usage) AS mem
FROM system.query_log
WHERE query_id = 'bg_demo_1' AND type = 'QueryFinish';
┌─query_id──┬─type────────┬─query_duration_ms─┬─mem──────┐
│ bg_demo_1 │ QueryFinish │ 3500 │ 1.00 GiB │
└───────────┴─────────────┴───────────────────┴──────────┘The natural pairing is a materialised-view backfill or an INSERT INTO ... SELECT into a target table; you get the result by querying the table, not the connection. Always set an explicit query_id so you can find it again. Related: CREATE MATERIALIZED VIEW ... POPULATE is now atomic with respect to concurrent inserts (materialized_views_populate_atomically, on by default) and works with a TO clause, which closes a well-known gap where rows inserted during POPULATE were lost. That alone removes a whole section from our MV deployment runbook.
Credentials with an expiry, and a per-user query log
CREATE USER reporting_bot
IDENTIFIED WITH sha256_password BY '${CH_BOT_PASSWORD}'
VALID FOR INTERVAL 30 DAY;
SELECT name, valid_until, toTypeName(valid_until)
FROM system.users
WHERE name = 'reporting_bot';
┌─name──────────┬─valid_until─────────────┬─toTypeName(valid_until)─┐
│ reporting_bot │ ['2026-10-03 11:22:26'] │ Array(DateTime64(0)) │
└───────────────┴─────────────────────────┴─────────────────────────┘The expiry is computed at creation from the interval, which is what you want for service accounts issued by automation. Note the column type change: system.users.valid_until is now Array(DateTime64(0)), previously Array(DateTime); any audit tooling that compares it to a DateTime needs a cast. system.user_query_log gives each user their own queries without the system.query_log grant, which is the right answer for the recurring "let the analysts see their own slow queries" request in multi-tenant clusters. And default_session_user finally lets you retire the hardcoded default user for anonymous connections per protocol endpoint.
SQL surface: GROUPS frames, pipe syntax, array subscripts
The window-function frame mode GROUPS from SQL:2011 counts peer groups (rows with equal ORDER BY values) instead of physical rows. That is the correct semantics for "average over the last three seconds" when several events share a timestamp, and the difference is easy to see side by side:
SELECT
toStartOfSecond(ts) AS sec,
price,
avg(price) OVER (ORDER BY toStartOfSecond(ts)
GROUPS BETWEEN 2 PRECEDING AND CURRENT ROW) AS avg_last_3_secs,
avg(price) OVER (ORDER BY toStartOfSecond(ts)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS avg_last_3_rows
FROM lab.trades
WHERE symbol = 'SYM7' AND ts < '2026-08-27 09:30:02'
ORDER BY sec, price;
┌─────────────────────sec─┬─price─┬────avg_last_3_secs─┬────avg_last_3_rows─┐
│ 2026-08-27 09:30:00.000 │ 153 │ 160.96666666666667 │ 153 │
│ 2026-08-27 09:30:00.000 │ 154.4 │ 160.96666666666667 │ 160.96666666666667 │
│ 2026-08-27 09:30:00.000 │ 175.5 │ 160.96666666666667 │ 164.25 │
│ 2026-08-27 09:30:01.000 │ 153.9 │ 165.03333333333333 │ 169.1 │
│ 2026-08-27 09:30:01.000 │ 157.4 │ 165.03333333333333 │ 169.26666666666665 │
│ 2026-08-27 09:30:01.000 │ 196 │ 165.03333333333333 │ 175.29999999999998 │
└─────────────────────────┴───────┴────────────────────┴────────────────────┘Every row in the same second gets the same GROUPS average, because the frame is defined in terms of seconds; the ROWS frame gives each row a different value that depends on the arbitrary ordering of ties. If you have ever emulated this with a self-join or a RANGE frame over an integer surrogate, you can delete that code.
Pipe syntax is now in the core parser. It reads top to bottom in execution order, which turns out to matter for generated SQL and for anyone teaching ClickHouse to people who think in dataframes:
FROM lab.events
|> WHERE tenant_id = 42
|> AGGREGATE count() AS hits, uniqExact(user_id) AS users GROUP BY url_path
|> ORDER BY hits DESC
|> LIMIT 3;
┌─url_path─┬─hits─┬─users─┐
│ /p/1125 │ 20 │ 20 │
│ /p/811 │ 17 │ 17 │
│ /p/804 │ 16 │ 16 │
└──────────┴──────┴───────┘
-- multi-index array subscript and the new notHas()
SELECT [10, 20, 30, 40, 50][[1, 3, 5]] AS picked, notHas([1, 2, 3], 4) AS nh;
-- [10,30,50] 1The pipe form returned exactly the rows the HTTP handler above produced for the same tenant, which is the same SELECT written the conventional way. Two more additions for programmatic SQL: parseQueryToJSON() and formatQueryFromJSON() round-trip a query through its AST as JSON, and the experimental dialect = 'clickhouse_json' accepts the AST directly. Query builders and SQL linters no longer need their own ClickHouse grammar.
Keeper in ClickHouse 26.8: coordination state no longer bounded by RAM
ClickHouse Keeper has always held its entire state tree in memory, and on clusters with tens of thousands of replicated tables or aggressive part churn that has been the sizing constraint. ClickHouse 26.8 adds an on-disk storage mode built on a custom LSM tree, enabled with use_lsmt_storage = true and storage_memory_only = false, with the data path set by data_storage_path or a disk via data_storage_disk, including s3_plain. This is experimental in ClickHouse 26.8 and I would not put it under a production quorum yet; it is the direction, and it changes the DR conversation because Keeper state on an object-storage disk is a very different backup story from Keeper snapshots on local NVMe.
Two Keeper changes in ClickHouse 26.8 are production-ready today and worth adopting immediately. Startup reads changelog files concurrently (log_startup_read_max_streams, log_startup_read_buffer_size), which shortens the window during a rolling Keeper restart where the quorum is running on two nodes. And there are new leader-election metrics, zk_leader_uptime, zk_sum_leader_unavailable_time, zk_cnt_election_time and KeeperLastLeaderElectionTime, which give you a direct SLO for coordination availability. Until now the only way to know how long a cluster had spent without a Keeper leader was to grep logs. Put zk_sum_leader_unavailable_time on the same dashboard as replication queue depth and the correlation will explain a class of "inserts stalled for 20 seconds" incidents that were previously filed as network.
Lakehouse and external data in ClickHouse 26.8: the read path keeps widening
The pattern of ClickHouse as the low-latency serving tier over an Iceberg lake continues to firm up. ClickHouse 26.8 adds a working catalog for AWS S3 Tables (managed Iceberg) with INSERT support and SigV4 signing through IAM roles; Snowflake Horizon as an Iceberg catalog endpoint, so you can read and write Snowflake-managed Iceberg tables without spending Snowflake compute credits; and reading of Puffin sidecar files, which means deletion vectors are visible as exact deleted rows per data file rather than as a black box. Delete manifests are now decoded concurrently (iceberg_delete_manifest_decode_concurrency, default 4), and the S3 client caches bucket region per catalog, which removes a round trip that was noticeable on lakes spread across regions.
On the Parquet side, three ClickHouse 26.8 optimisations compound. Lazy materialisation now applies to Parquet from object storage and local files: for ORDER BY ... LIMIT queries only the sort columns are read first and the remaining columns are fetched for the surviving rows. Dictionary-page filter push-down (input_format_parquet_dictionary_filter_push_down) skips row groups when an equality or IN predicate cannot match any dictionary entry. And GeoParquet gets bounding-box pruning at row-group and page level.
ClickHouse's figures are 11x on a 100M-row lazy-read example and 226 row groups reduced to 12 with dictionary filtering; I have no object store in the test environment, so those are theirs. There is also a native bigquery table function and engine with INSERT, and a URL database engine where table names are URLs against a base, which is convenient for public datasets and nothing I would expose to untrusted users.
One removal to plan for in ClickHouse 26.8: the Apache Arrow library-based reader and writer for Arrow and ArrowStream formats is gone; only the native implementation remains. The Arrow-based Parquet reader went in 26.5. If you have pipelines that toggled input_format_parquet_use_native_reader or its Arrow equivalents for compatibility reasons, those settings no longer do anything and the edge cases they worked around need retesting.
Text search in ClickHouse 26.8: tokenizers for CJK and a faster posting-list path
The text index, which got phrase search with positions in 26.7 (ClickHouse measured 40x on a 22.67M-row Hacker News phrase query), gains four tokenizers in ClickHouse 26.8: chinese (dictionary plus HMM segmentation), japanese (MeCab), icu (locale-aware Unicode segmentation) and splitByRegexp for bespoke boundaries. For anyone running log analytics for an Asian-market product this closes the gap that pushed those workloads to Elasticsearch.
CREATE TABLE app_logs
(
ts DateTime64(3),
service LowCardinality(String),
message String,
INDEX idx_msg message TYPE text(tokenizer = 'icu') GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(ts)
ORDER BY (service, ts);
-- per-query: decode posting lists lazily instead of materialising bitmaps up front
SELECT count()
FROM app_logs
WHERE hasToken(message, 'timeout')
SETTINGS text_index_posting_list_apply_mode = 'lazy';Behind the API, posting lists are now decoded per block instead of eagerly materialised into bitmaps, a count() over a single token can be answered from index metadata without touching the parts, and there is an optional cache for tokens known to be absent. The on-disk format v2 with positions is the default; on a mixed-version cluster pin text_index_serialization_version = 'v1_with_codec' until the rollout completes, otherwise 26.3 replicas cannot read parts written by ClickHouse 26.8.
Breaking changes in ClickHouse 26.8 that will actually bite
Of the 21 backward-incompatible changes in ClickHouse 26.8, these are the ones I would expect to show up in a support ticket. The full list is in the official changelog; this is the triage.
| Change | Who it hits | Mitigation |
|---|---|---|
max_insert_threads default 1 → auto | Every INSERT SELECT and every large client INSERT: more parts per insert, more merge work, row order within an insert no longer deterministic | Watch system.part_log NewPart counts and system.merges after upgrade; set max_insert_threads = 1 in the profile for pipelines that relied on ordering |
X-ClickHouse-Format header now overrides FORMAT clause | HTTP clients that set the header as a default and override per query | Drop the header or make it per-request |
| Unquoted JSON numbers into DateTime columns parsed as Unix timestamps | JSONEachRow ingestion where numbers were previously coerced differently | Test a sample of every JSON producer against a 26.8 staging node before cutover |
| Views over a single Distributed table push the whole outer query to shards | Anyone using FINAL, SAMPLE or extremes through such views; results can change | optimize_trivial_view_pushdown_to_distributed = 0 to restore old behaviour, then fix the views |
arrayIntersect / arraySymmetricDifference semantics corrected | Queries where an argument contained repeated values; results differ silently | Grep query_log for both functions and re-validate outputs |
Date32 range extended to 0000–9999 | Code converting integers to Date32; day numbers in the old overflow range are now valid dates | Audit toDate32(<integer>) call sites |
| Per-CPU and per-device asynchronous metrics become Map-typed | Every Grafana panel and alert built on system.asynchronous_metrics per-core rows; Prometheus exports change to labels | Rewrite dashboards before upgrade; this is the change most likely to be noticed by the on-call engineer at 3 a.m. |
include_from no longer defaults to /etc/metrika.xml | Long-lived installs that keep macros, Keeper hosts or credentials there | Set <include_from> explicitly in config.xml before restarting on 26.8, or the server will start without your macros |
| Library dictionary source removed; MySQL source TLS paths removed | Dictionaries using SOURCE(LIBRARY(...)) or ssl_ca file paths | Migrate to PEM-inline parameters (ssl_ca_pem etc.); library dictionaries need a rewrite |
| Patch parts v2 on-disk format | Lightweight UPDATE users on mixed-version clusters | Pin patch_parts_version = 'v1' during rollout |
If you are coming from 26.3 you also inherit the intermediate releases. The default x86 build targets x86-64-v3 since 26.6, so AVX2 is mandatory (check grep -c avx2 /proc/cpuinfo on every node, including the small utility instances people forget about; the amd64compat build exists for older CPUs). Insert deduplication changed twice: 26.6 switched the default insert_deduplication_version to new_unified_hash, which deduplicates on the whole inserted block rather than per part, and 26.7 removed the legacy modes entirely; the server refuses to start if the setting is pinned to old_separate_hashes or compatible_double_hashes.
S3 server credentials stopped being used for user queries by default in 26.7, and max_bytes_ratio_before_external_join went from 0 to 0.5 in 26.5, which means hash joins now spill to disk at half of available memory rather than failing. That last one is a behaviour improvement that will nevertheless surprise anyone who tuned around the old OOM.
Upgrading to ClickHouse 26.8: our stance as of September 2026
On 25.8 LTS: upgrade, and do it this quarter. You are out of support as of 29 August and every one of the 26.4 to 26.8 breaking changes is between you and a patched build. Go directly to ClickHouse 26.8 rather than stopping at 26.3, with one specific caveat that the changelog only states obliquely. 25.8 writes only the legacy insert-deduplication hashes to Keeper; 26.8 recognises only the unified hash. An insert that was acknowledged on 25.8 and retried by the client after the replica came up on ClickHouse 26.8 will not be deduplicated, because the two builds never agree on a hash.
That is not a problem if your rolling upgrade runs under a write freeze, which ours always do, and it is not a problem for the 26.3 to 26.8 path because 26.3 already wrote both hashes. If you cannot freeze writes and you have retry-heavy ingestion (Kafka consumers, client-side retry loops), stage through a 26.3 build for one full replicated_deduplication_window_seconds before moving on. Either way, verify with a deliberate duplicate insert on staging and a count() before and after.
On 26.3 LTS: you have until March 2027 and no forcing function. My recommendation is to adopt ClickHouse 26.8 at the second or third patch build, which at the current cadence means October, after running it on a replica for at least two weeks with production traffic mirrored or with your dashboard query set replayed from system.query_log. The adaptive aggregator and IEJoin alone justify the move for analytics workloads; the serving-layer features justify it for anyone with an API in front of ClickHouse. The regression risk is concentrated in the max_insert_threads default and the metrics schema change, both of which are cheap to test and cheap to pin.
The rolling-upgrade shape we use for LTS to LTS, in outline: freeze schema changes; back up Keeper coordination data (snapshots and Raft log) as well as table data; set compatibility = '26.3' in the default profile so the new binary keeps old setting defaults; pin the on-disk format settings above; upgrade Keeper followers then leader; upgrade replicas one at a time, draining each through the proxy and checking system.replication_queue and system.replicas before moving to the next; then, in a separate change window after a soak period, remove the compatibility setting and the format pins one at a time.
Every step has a verification query and a rollback path, and the rollback path is tested on staging before the production window is scheduled. The details are cluster-specific, and none of the above should be applied to a production system without testing on your own topology first and confirming that your DR posture can absorb a failed step.
Frequently asked questions about ClickHouse 26.8
Is ClickHouse 26.8 an LTS release?
Yes. ClickHouse 26.8 was released on 27 August 2026 and is supported until 27 August 2027. ClickHouse designates the March and August releases as LTS each year.
Is ClickHouse 25.8 still supported?
No. 25.8 LTS support ended on 29 August 2026. The supported LTS lines today are 26.3 (until March 2027) and ClickHouse 26.8 (until August 2027).
What are the biggest performance improvements in ClickHouse 26.8?
For most analytics workloads on ClickHouse 26.8: the adaptive GROUP BY aggregator (2.4x on a high-cardinality key in my test), bounded-heap GROUP BY with LIMIT (4.6x faster and thousands of times less memory), the IEJoin algorithm for inequality joins (hundreds of times faster than the hash fallback), per-partition DISTINCT and window execution, and column statistics collected on INSERT by default, which improves join ordering.
Can I upgrade directly from ClickHouse 25.8 to ClickHouse 26.8?
Yes, a rolling upgrade across two LTS lines is supported, but you inherit 57 breaking changes across 26.4 to 26.8 and must verify AVX2 support, the insert deduplication hash change (retries across the cutover are not deduplicated, so freeze writes during the rollout), S3 credential handling and the metrics schema change before cutover. Use compatibility = '25.8' during the rollout and advance it deliberately afterwards.
Does ClickHouse 26.8 require AVX2?
The default x86-64 build of ClickHouse 26.8 has required AVX2 since 26.6 (Intel Haswell or later, AMD Excavator or later). Older CPUs need a non-default build or an ARM migration.
What is the adaptive aggregator in ClickHouse 26.8?
A GROUP BY execution strategy in ClickHouse 26.8, enabled by default through enable_adaptive_aggregator, in which each thread aggregates into a local hash table until it reaches 16,384 keys or 4 MiB, then freezes that table for parallel merging and starts a fresh one. It removes the need to guess key cardinality up front.
Where ChistaDATA fits in your ClickHouse 26.8 upgrade
Every measurement in this post came from a two-core sandbox running ClickHouse 26.8, which is enough to demonstrate mechanisms and nowhere near enough to predict your cluster. If you are planning a 25.8 or 26.3 to ClickHouse 26.8 upgrade, want the breaking-change audit run against your actual query log, or want the new aggregation and join paths validated against your dashboards before the default changes reach production, that is the work our ClickHouse consulting and 24x7 ClickHouse support teams do every week on open-source ClickHouse, with no vendor lock-in. As always: test on your own workload before trusting anyone's numbers, including mine, and keep the DR posture that lets you roll back.
References: ClickHouse 26.8 release call (27 August 2026); ClickHouse OSS changelog 2026, v26.8 section; ClickHouse 26.7 release post; endoflife.date/clickhouse for support windows; Khayyat et al., "Lightning Fast and Space Efficient Inequality Joins", VLDB 2015. All measurements: ClickHouse 26.8.2.7, 2 vCPU / 7 GiB, September 2026.