ClickHouse 26.8 LTS vs 26.3: Pipelined SQL, Iceberg Writes and Upgrade Risk
The feature everyone will write about in ClickHouse 26.8 LTS is the |> pipe operator. The change that will actually decide whether your upgrade goes smoothly is a materialized-view race condition that has been silently dropping rows since long before 26.3, and is now fixed — partially. If you run incremental materialized views and you populate them on creation, your backfill behaviour changes the moment you restart on 26.8, and it changes differently depending on whether your inserts arrive on one server or across replicas.
This matters now because 26.3 and 26.8 are consecutive long-term-support lines, and an LTS-to-LTS hop is how most production ClickHouse clusters actually move. You skip five monthly releases in one restart, which means five releases’ worth of default changes land at once.
This post is an upgrade guide, not a feature tour. It ends with a prioritised pre-upgrade test checklist and explains why each item sits where it does.
What this covers: the pipe operator and what it desugars into, atomic POPULATE and its replication caveat, the Iceberg write path into S3 Tables and Snowflake Horizon, the Parquet and join changes that alter query plans without touching SQL, the backward-incompatible defaults you inherit, and a test ordering that puts correctness before performance.
Context and Background
ClickHouse ships a release roughly every month and designates a handful of them as long-term support. Version 26.8 is one of those: the official release announcement states in its opening lines that “The ClickHouse 26.8 release contains 98 new features, 128 performance optimizations, 556 bug fixes and is a long-term support release.”
The first LTS tag, v26.8.1.2041-lts, appeared at the end of August 2026; the changelog dates the 26.8 line to 2026-08-27, while the GitHub release for the first LTS build is dated 2026-08-30. Three weeks later the patch line had already reached v26.8.10.6-lts. That cadence is itself a data point: an LTS line in ClickHouse is not frozen, it is actively patched, and by the time most teams get around to upgrading there are a dozen patch releases sitting on top of the .1 build. Upgrade to the current patch, never to .1.
The previous LTS line is 26.3, most recently at v26.3.33.24-lts. Teams that follow the LTS track therefore jump 26.3 → 26.8 in a single maintenance window. That is a five-release gap, and ClickHouse does not hold behavioural changes back for LTS boundaries. Everything that changed in 26.4 through 26.8 arrives together.
This is a different risk profile from a monthly upgrade. On a monthly cadence a changed default surfaces in isolation, and you can attribute a regression to it quickly. On an LTS hop several changed defaults interact: insert parallelism changes part counts, a statistics default changes join orders, and a Parquet reader default changes how much you read from object storage. If a dashboard gets slower afterwards, you have three plausible causes instead of one.
The site has covered ClickHouse mostly as an engine choice so far — see the ClickHouse vs Druid vs Pinot real-time OLAP decision record and the DuckDB vs ClickHouse embedded analytics ADR. This is the first version-level piece, and version-level detail is where upgrade pain lives.
Two primary sources carry almost everything below: the release blog linked above, and the ClickHouse changelog, which is considerably more precise about defaults and gating than the blog is. Where the two differ in emphasis, the changelog wins, and I flag those cases explicitly.
Pipelined SQL: What the Pipe Operator Actually Compiles To
ClickHouse 26.8 adds a |> pipe operator that lets a query be written as a sequence of stages rather than a single SELECT block. Each |> wraps everything before it into a subquery, so the resulting abstract syntax tree is identical to the equivalent nested-subquery form. Nothing is materialized between stages; the optimizer sees one query.
That last sentence is the whole answer to “is it slower?” — it is not, because there is no intermediate result to be slower about.

Figure 1: A five-stage pipeline and the nested-SELECT rewrite that EXPLAIN SYNTAX reveals.
Figure 1 traces a pipeline from its source table through four |> stages and shows how each stage becomes another layer of wrapping around the previous query. The innermost node is a bare scan; each successive wrapper adds one clause. The terminal node makes the important point: the nesting is syntactic, and the optimizer collapses it into a single plan before execution.
The stages, and the one distinction most coverage gets wrong
The supported stages are WHERE, AGGREGATE ... GROUP BY, EXTEND, SELECT, ORDER BY and LIMIT. A pipeline reads in execution order: pick a table, filter it, aggregate it, sort it, cut it.
FROM uk_price_paid
|> WHERE (town = 'LONDON') AND (date >= '2024-01-01')
|> AGGREGATE count() AS sales, round(median(price)) AS median_price
GROUP BY district
|> ORDER BY median_price DESC
|> LIMIT 10;
Here is the distinction to get right. Putting FROM before SELECT is not new in 26.8 — ClickHouse has allowed it since 22.12. A query that reads FROM uk_price_paid SELECT district, count() ... WHERE ... GROUP BY ... is a conventional SQL query with one clause moved. The pipe operator is the new part, and the two are frequently conflated in secondary coverage of the release. If your team already writes FROM-first queries, you have been using a four-year-old feature, not a 26.8 one.
In a query that starts with FROM, the SELECT clause is now optional and defaults to selecting everything. That is what makes FROM t |> WHERE ... a complete query without a projection.
EXPLAIN SYNTAX, and a breaking change hiding inside it
EXPLAIN SYNTAX is how you check what a pipeline became. For the query above it returns a stack of nested selects: an innermost SELECT * FROM uk_price_paid, wrapped by a WHERE subquery, wrapped by the GROUP BY projection, wrapped by the ORDER BY, with LIMIT 10 on the outside. The pipelined SQL walkthrough prints this output in full and is worth reading beside your own EXPLAIN results.
EXPLAIN SYNTAX itself changed in 26.8, and this is a genuine backward-incompatible change that has nothing to do with pipes. It now returns the reformatted query as a single String record with embedded newlines, rather than one record per line. So SELECT count() FROM (EXPLAIN SYNTAX ...) now returns 1. The behaviour is controlled by a new single_record option that defaults to 1; set it to 0 to get the historical per-line output back. Other EXPLAIN kinds — PLAN, PIPELINE, AST — keep their per-line tree output.
If you have tooling that parses EXPLAIN SYNTAX row by row — a linter, a query-rewrite test harness, a CI check that asserts on plan shape — it breaks on 26.8 and it breaks quietly, because the query still succeeds and just returns one row instead of many.
Where pipelines earn their keep, and where they do not
The honest case for |> is incremental construction. Every |> is a checkpoint at which the preceding text is a complete, runnable query. You can build a five-stage transformation by running it after each stage, which is meaningfully better than the CTE workflow of commenting out the tail of a query and re-adding it.
The second real win is extending an existing aggregation. If you have a query that computes a median per district, you can append |> AGGREGATE round(avg(district_median)) AS average_district_median GROUP BY county and aggregate the aggregate, without hand-writing the wrapping subquery. The alias from the earlier stage is available as a column in the next one.
Where pipelines do not help: anything you need to reference more than once. A CTE can be referenced twice; a pipeline stage cannot, because it is not named. Complex queries with shared intermediate results still want WITH. Pipelines also read poorly when stages are long — a 15-line AGGREGATE stage in the middle of a chain is harder to scan than the same logic in a named CTE.
And there is a sharp edge worth knowing before anyone on your team ships a pipeline to production: stage order is semantic. Moving |> LIMIT 10 above an aggregation limits the input to that aggregation, not the output of the query. The aggregate is then computed over ten arbitrary rows. In conventional SQL the same mistake requires deliberately writing a subquery with a LIMIT in it, which looks wrong; in pipeline form it looks like a reordering. Treat stage order in code review the way you treat operator precedence.
Finally, pipelines work anywhere ClickHouse expects a SELECT: subqueries, INSERT ... SELECT, and view definitions. A view whose body is a pipeline is queried with ordinary SQL afterwards, so adopting pipelines in your ETL does not force them on your consumers.
INSERT INTO expensive_london_sales (date, price, district)
FROM uk_price_paid
|> WHERE town = 'LONDON' AND price >= 1000000
|> SELECT date, price, district;
Atomic POPULATE: The Correctness Fix That Reorders Your Migration Plan
This is the item that belongs first in your test plan, and it gets almost no attention in release coverage because it reads like a bug fix rather than a feature.
Before ClickHouse 26.8 LTS, CREATE MATERIALIZED VIEW ... POPULATE had a race. The view subscribes to new inserts on the source table, and separately takes a snapshot of existing data to backfill from. An insert that started before the view existed, and committed after the snapshot was taken, was seen by neither path. Those rows were silently lost — no error, no warning, no log line.

Figure 2: The POPULATE race, the fix, and the boundary of the guarantee.
Figure 2 shows both branches from the same starting state — an in-flight insert and a view that does not yet exist. The legacy branch loses the whole insert. The default 26.8 branch subscribes and snapshots together under a brief exclusive lock on the source, delivering each row exactly once. The two trailing nodes are the part the release blog does not cover: the scope of the guarantee.
The mechanism, and why it loses exactly one insert’s worth of rows
The release blog demonstrates the failure with a reproducible script. A source table holds 100,000 rows. A second client inserts 50 more, slowed with sleepEachRow(0.05) so the insert spans about two and a half seconds. Half a second in, a CREATE MATERIALIZED VIEW mv TO dst POPULATE AS SELECT id FROM src runs with materialized_views_populate_atomically = 0.
The result:
| sourceRows | dstRows | dstDistinct | lost | duplicated |
|---|---|---|---|---|
| 100050 | 100000 | 100000 | 50 | 0 |
All 50 rows land in the source. None reach the destination. With the setting at its 26.8 default of 1, the same script yields 100050 rows in both tables, zero lost and zero duplicated.
The count is exactly 50 rather than some partial number because an insert decides which materialized views will receive its data once, when the query starts — not per row and not per block. The whole insert either goes to the view or it does not. That granularity is worth internalising: the loss window is per-statement, so a single large INSERT ... SELECT overlapping a POPULATE loses everything it wrote, not a slice of it.
The caveat the release blog does not state
Here the changelog is materially more informative than the blog, and this is one of the cases where the source detail changes the advice.
The changelog describes POPULATE as locally atomic. The guarantee covers the local insert path only. Inserts arriving on another replica, or through a distributed write path, are outside the cut. The fix also requires a source that can provide a pinned snapshot — the MergeTree family and Memory. Other table engines, along with CREATE OR REPLACE / REPLACE forms and views created inside Replicated databases, keep the legacy non-atomic population.
Read that list against a real production topology. A sharded, replicated cluster ingesting through a Distributed table, creating a view via ON CLUSTER inside a Replicated database, gets none of the new guarantee. The correct operational advice for that shape has not changed: quiesce writes, or backfill with an explicit INSERT INTO ... SELECT against a bounded partition range rather than relying on POPULATE.
What has changed is that the single-server and single-replica-target cases are now safe by default, which covers a large fraction of smaller deployments and most staging environments. The danger is a team that reads the release blog, concludes POPULATE is now safe, and applies that conclusion to a replicated cluster where it is not.
POPULATE with TO is now legal
A smaller but practically useful change: POPULATE can now be combined with TO, backfilling an existing target table. Before 26.8 that combination was a syntax error, which forced the two-step pattern of creating the view and then running a separate backfill insert. If you have migration scripts that work around the old restriction, they still work — but the workaround is no longer needed for new work.
Pair this with the new run_query_in_background setting. Setting run_query_in_background = 1 makes the server accept the query, return an empty result immediately, and run it to completion regardless of what happens to the connection. The changelog names CREATE MATERIALIZED VIEW ... POPULATE explicitly as an intended use, alongside INSERT ... SELECT and CREATE TABLE ... AS SELECT. Track progress in system.processes and completion in system.query_log by query_id. Note that the result is discarded — this is for statements whose value is their side effect, not for reading data.
The Iceberg Write Path: DataLakeCatalog to S3 Tables and Horizon
ClickHouse 26.8 turns ClickHouse from an Iceberg reader into an Iceberg writer for two managed catalogs. This section stays scoped to ClickHouse’s write path; for the table format itself, the Apache Iceberg v4 vs v3 analysis covers manifests and commit structure in detail.

Figure 3: The 26.8 write path through DataLakeCatalog into S3 Tables or Horizon, and the read path back through Puffin deletion vectors.
Figure 3 shows both catalog targets converging on the same object-storage data files, and the read path returning through manifest prefetching and Puffin metadata. The important structural point is that the catalog type is a setting on one database engine, not two separate integrations — the write semantics are shared.
Enabling writes
Two settings gate everything:
SET allow_database_iceberg = 1;
SET allow_insert_into_iceberg = 1;
For Amazon S3 Tables, AWS’s managed Iceberg service, the catalog is attached as a database:
CREATE DATABASE tables
ENGINE = DataLakeCatalog(
'https://s3tables.us-east-1.amazonaws.com/iceberg'
)
SETTINGS
catalog_type = 's3tables',
region = 'us-east-1',
warehouse = 'arn:aws:s3tables:us-east-1:123456789012:bucket/analytics';
Tables then behave like tables. SHOW TABLES FROM tables lists them, and a backtick-quoted namespaced name is both selectable and now writable:
SELECT * FROM tables.`ns.events` LIMIT 10;
INSERT INTO tables.`ns.events` VALUES (…);
Creating new tables is supported as well as inserting into existing ones.
Snowflake Horizon follows the same shape with catalog_type = 'horizon', a personal access token in catalog_credential, an auth_scope of the form session:role:<ROLE>, and vended_credentials = 1. ClickHouse reads the underlying data files directly from object storage and commits changes through the catalog.
The significance is not that ClickHouse can write Parquet — it always could. It is that ClickHouse now participates in a catalog’s commit protocol, so a ClickHouse write is visible to every other engine pointed at the same catalog, with the catalog arbitrating. That moves ClickHouse from a fast read-side cache to a legitimate write participant in a lakehouse. It also moves your failure surface: an INSERT now depends on catalog availability, authentication and credential vending, none of which sit in the ClickHouse failure domain. Treat these inserts as network operations against a third-party control plane.
Puffin deletion vectors, and the question they finally answer
Iceberg stores statistics and deletion vectors in Puffin files. ClickHouse 26.8 adds Puffin and PuffinMetadata as input formats, usable with the file, url, s3 and similar table functions.
SELECT referenced_data_file, deleted_rows
FROM url('https://.../file_properties_ok.puffin', 'Puffin');
The result names the referenced Parquet file and returns the deleted row positions — in the sample file from the ClickHouse test suite, positions [2, 5]. PuffinMetadata exposes the blob type and properties; for that file the blob type is deletion-vector-v1 with a cardinality of 2.
This is a debugging capability more than a query capability, and it answers a question that has been genuinely hard to answer from outside a full Iceberg engine: why does a row that exists in the data file not appear when I query the table? You no longer need access to the Parquet file to find out — the deletion information lives in the Puffin file and is now directly inspectable.
Manifest prefetching
Before reading an Iceberg table, ClickHouse reads manifest files describing its data and delete files. On tables with many small manifests that metadata phase can dominate startup latency, because each manifest is a separate object-storage request.
In 26.8 ClickHouse prefetches the next manifest while parsing the current one, overlapping storage reads with CPU work. Delete manifests are read and decoded concurrently, controlled by iceberg_delete_manifest_decode_concurrency, which defaults to 4. Because delete manifests must be processed before any data file is read, this concurrency directly shortens time-to-first-row on tables with heavy delete history.
The release also fixes the S3 bucket-region cache for data lake catalogs, eliminating repeated region-discovery requests. If your Iceberg queries showed a fixed per-query latency floor that did not scale with data volume, that cache is a likely culprit and the fix is free.
Parquet, Joins and the Performance Surface You Cannot See
The changes in this section alter query plans without any change to your SQL. That makes them the second priority in a test plan: they do not corrupt data, but they can change latency and cost in both directions.

Figure 4: Where lazy materialization and dictionary filter pushdown cut bytes read, and the new failure mode lazy materialization introduces.
Figure 4 separates the two independent optimisations. The left branch is row-group elimination — min/max statistics, then bloom filters, then the new dictionary page filter. The right branch is the two-pass lazy materialization path. The final node is the failure mode the two-pass design creates, which is discussed below.
Lazy materialization for ORDER BY … LIMIT
For queries with ORDER BY and LIMIT, ClickHouse can read only the columns needed for sorting and filtering, apply the limit, then fetch the remaining columns for the rows that survived. This is enabled by default via query_plan_optimize_lazy_materialization_for_object_storage, which also requires query_plan_optimize_lazy_materialization.
The measured effect, from repeated checks on ClickHouse 26.8.2.7 against a public hits.parquet dataset on S3: the same top-ten query read approximately 6.6–6.7 GB with the optimization disabled and 1.5 GB with it enabled, returning identical rows. That is roughly a four-fold reduction in bytes pulled from object storage, which on a cloud deployment is a direct line-item saving as well as a latency win.
A related change extends lazy materialization to local Parquet files read through the file table function and the File table engine, controlled by query_plan_optimize_lazy_materialization_for_file and also enabled by default. Distributed query plans using make_distributed_plan gained lazy materialization for ORDER BY ... LIMIT k as well.
The new failure mode nobody mentions
Lazy materialization reads the file twice. If the file changed between the two passes, the second read is reading something different from the first.
ClickHouse 26.8 handles this by failing closed with a new FILE_CHANGED_DURING_READ error. That is the correct behaviour — the alternative is silently mismatched rows — but it is a new error class in a code path that previously could not produce it, and it is enabled by default.
If you query Parquet files that are being rewritten in place, in a staging directory, by a nightly export, or by anything that replaces a file under an unchanged name, you can now get query failures on 26.8 where 26.3 returned results. The results 26.3 returned may well have been wrong, but “wrong” and “failed” trigger different alerts and different pagers. Test any pipeline that reads files while another process writes them.
Dictionary filter pushdown
The native Parquet V3 reader can now skip entire row groups using the dictionary page, in addition to min/max statistics and bloom filters, for equality and IN conditions where a column chunk is fully dictionary-encoded. If the requested value is absent from the dictionary, the row group cannot contain it.
The measured effect on an equality filter over a high-cardinality string column: 12 data pages processed with dictionary filtering enabled, against 226 with it disabled, both returning the same count of 200.
input_format_parquet_dictionary_filter_push_down defaults to 1048576 — a 1 MiB limit on dictionary page size, above which the optimization is skipped because reading the dictionary would cost more than it saves. Setting it to 0 disables the feature entirely.
This matters most where min/max statistics are useless: a column whose values are scattered across the whole key space, so every row group’s min/max range contains your predicate, and where bloom filters were never written. That combination is extremely common in exported analytics Parquet, and until 26.8 there was no way to prune it.
GeoParquet queries also gained spatial pruning, using bounding-box information to skip row groups and pages and applying spatial predicates during row reading.
Joins: two of these are opt-in, and coverage gets that wrong
Three join changes matter, and the distinction between “on by default” and “available if you ask” is the thing to get right.
Column statistics on INSERT — on by default. Statistics are now materialized on INSERT when the table’s current active size plus the written block size is at most materialize_statistics_on_insert_max_table_size, which defaults to 25 GiB. The check is per written block, so a first bulk load into an empty table may materialize statistics for each block. The release blog reports a 29% improvement across all TPC-H benchmarks; the changelog is more specific about why it matters, noting that TPC-H Q5 and Q8 no longer time out at scale factor 40. The purpose is to give the cost-based join optimizer accurate cardinality estimates for freshly loaded dimension tables, avoiding pathological join orders. Large established fact tables continue to materialize statistics during merges rather than on insert.
This is on by default and changes join plans. It is why join plans sit third in the test checklist.
IEJoin — opt-in. Joins whose ON clause contains two inequality comparisons between the joined tables previously executed as a CROSS JOIN with a filter, INNER only, which is dramatically slower on large tables. ClickHouse 26.8 adds a sort-based IEJoin algorithm supporting ALL INNER/LEFT/RIGHT/FULL JOIN and SEMI/ANTI LEFT/RIGHT JOIN. The changelog is explicit that it is enabled by adding ie_join to the join_algorithm setting — it is not automatic. The release blog’s phrasing (“they will now use the sort-based IEJoin algorithm instead”) reads as though it is default behaviour; the changelog’s gating statement is the one to trust and the one to act on. If you have interval-overlap joins, you must opt in.
parallel_full_sorting_merge — opt-in. A new join_algorithm value that shards a full sorting merge join by the hash of the join keys into independent per-shard merge joins running across all threads. It keeps the low streaming memory profile of a merge join while parallelising it: the changelog cites a benchmark at roughly 2.4x faster and 3.3x less memory than parallel_hash. Caveats: ASOF joins fall back to a single full_sorting_merge; hash-incompatible key types (floating point, JSON, Object, Dynamic) skip the hash-scatter rewrite; and the result is not ordered. That last one bites anyone who was relying on incidental ordering from full_sorting_merge.
The distributed cost-based optimizer — experimental. ClickHouse 26.8 adds a Cascades cost-based optimizer for distributed query plans, choosing between shuffle, broadcast, replicated and local join strategies and inserting exchange operators by estimated cost. It is gated behind enable_cascades_optimizer = 1 together with make_distributed_plan = 1, and the changelog labels it experimental. Do not include it in an LTS upgrade; evaluate it separately.
The Operational Surface: Background Queries, Wire Protocol, and Query Generation
Several smaller 26.8 changes affect operations rather than query results.
PostgreSQL-style regex operators. ClickHouse now supports ~, ~*, !~ and !~* for case-sensitive match, case-insensitive match, and their negations. The implementer’s own note in the release blog is that he is not a fan and thinks they make SQL look too much like Bash — PostgreSQL compatibility won.
The consequential half of that change is elsewhere: the same work means psql commands \d, \dt and \dv now work over the PostgreSQL wire protocol. \d <table> still uses PostgreSQL syntax ClickHouse does not support. If you have BI tools, migration utilities or human operators connecting over the wire protocol, their behaviour changes — which is why anything touching that protocol is the fourth item in the checklist.
Query-to-JSON and back. parseQueryToJSON returns a query’s abstract syntax tree as JSON; formatQueryFromJSON converts it back to SQL. An experimental clickhouse_json dialect, gated behind enable_json_ast_dialect, accepts a JSON AST instead of SQL text.
This is for machines, not people. Anything that generates queries can emit a syntax tree instead of a string, which removes concatenation, escaping bugs and SQL-injection surface entirely. For teams routing model-generated queries at a ClickHouse cluster that is structurally better than string sanitisation — there is no string to inject into. It is experimental, so treat it as a direction rather than a production plan.
User lifecycle and query visibility. CREATE USER ... VALID FOR INTERVAL computes an expiry from the current time as a shorthand for the existing VALID UNTIL, and stores it in VALID UNTIL form. Note the associated breaking change: the valid_until column of system.users is now Array(DateTime64(0)) instead of Array(DateTime), so deadlines past 2106 are exact. Tooling reading that column needs to handle the new type.
A new system.user_query_log system table contains only the current user’s queries, letting every user inspect their own history without being granted access to system.query_log. Queries against system.query_log still raise a permission error without the grant. For multi-tenant clusters this removes a standing awkward choice between opaque debugging and over-broad grants.
Tokenizers. 26.8 adds four: japanese (MeCab-based, external dictionary verified against a configured dictionary_sha), chinese (jieba-style, embedded, with a granularity parameter defaulting to coarse_grained), icu(locale) for scripts without word spacing such as Thai and Khmer, and splitByRegexp for explicit separator patterns. The last one is the practical fix for the long-standing problem that the default splitByNonAlpha tokenizer collapses C++, C# and F# to a single bare letter and matches them against each other.
Trade-offs, Gotchas, and What Goes Wrong
The backward-incompatible section of the 26.8 changelog is long, and I am deliberately not quoting a count — numbers for this have circulated in secondary coverage that I could not confirm against the changelog. Read the section yourself. These are the entries most likely to affect a 26.3 cluster.
max_insert_threads default changed from 1 to auto. This resolves to the number of available CPU cores and parallelises INSERT SELECT by default. It can change the number of parts created and the order of inserted rows. More parts means more merge pressure; changed row order within a part changes compression ratios. Restore the old behaviour with max_insert_threads = 1 or by setting compatibility below 26.8.
Lightweight UPDATE patch parts use a new v2 on-disk format. Peak memory is now bounded by the largest equal-sort-key run instead of the full patch. Old-format patch parts remain readable, but during a rolling upgrade you must keep patch_parts_version = 'v1' (or use compatibility) until every replica is upgraded. Skip this and a mixed-version cluster will produce parts the older replicas cannot apply.
Trivial view pushdown to distributed tables is on by default. For views whose body is a plain SELECT over a single Distributed table, the whole outer query is now pushed to the shards. FINAL and SAMPLE written on the view reference are now propagated to the shard-local table instead of being ignored, and extremes is not reported on single-shard clusters. Propagating a previously-ignored FINAL changes both results and cost. Disable with optimize_trivial_view_pushdown_to_distributed = 0.
Asynchronous metrics became maps. Per-CPU-core and per-device metrics collapsed into single key-value metrics: OSUserTimeCPU0, OSUserTimeCPU1 and friends are now one OSUserTimeCPU metric with a map. The same applies to CPUFrequencyMHz_*, Temperature*, EDAC*, Block*_*, network and disk metrics. The Prometheus endpoint exports them with labels. If your dashboards read the old scalar names, set asynchronous_metrics_key_values_mode to legacy_names, or both during migration — the setting applies via SYSTEM RELOAD CONFIG without a restart. This is the single most likely cause of a blank monitoring dashboard after upgrade.
Removals. The library dictionary source is gone — SOURCE(LIBRARY(...)) now fails with UNKNOWN_ELEMENT_IN_CONFIG and dictionaries_lib_path is obsolete. The Apache Arrow library-based reader and writer for Arrow/ArrowStream are gone; the native implementation, default since 26.7, is the only one, and input_format_arrow_use_native_reader / output_format_arrow_use_native_writer are accepted but inert.
Semantics. arrayIntersect and arraySymmetricDifference no longer treat a value repeated inside one argument as if it appeared in several: arrayIntersect([1], [2], [1, 1]) now returns [] rather than [1]. Date32 extended from [1900-01-01, 2299-12-31] to [0000-01-01, 9999-12-31], so toDate32(N) for N in [120530, 2932896] is now a day number rather than a 1970 timestamp. Unquoted JSON numbers for DateTime/DateTime64 in JSONEachRow are now Unix timestamps with optional sub-second precision — previously a bare integer produced a 1970 date. A window PARTITION BY or ORDER BY over an AggregateFunction column is now rejected with ILLEGAL_COLUMN.
Configuration and credentials. include_from no longer defaults to /etc/metrika.xml; if you relied on that implicit substitution file, declare it explicitly in every affected config, including separately loaded users.xml and XML dictionary configs. MySQL source TLS credentials can no longer be given as file paths from SQL — pass contents via the new ssl_ca_pem, ssl_cert_pem, ssl_key_pem parameters, which are masked like passwords. The NATS engine takes credentials inline via nats_credentials and no longer accepts nats_credential_file from SQL. PostgreSQL and MaterializedPostgreSQL database engines now respect remote_url_allow_hosts.
The compatibility setting deserves a closing note. Setting compatibility below 26.8 restores many changed defaults at once, and it is a reasonable first move on upgrade day: upgrade binaries with old behaviour pinned, verify stability, then lift the pin and re-test. It is a rollback lever that does not require a downgrade.
Practical Recommendations
Upgrade to the current 26.8 patch release rather than v26.8.1, and read the backward-incompatible section of the changelog in full before scheduling the window — it is the only document that states which behaviours are gated and which are default. Stage the upgrade on a replica of production data with production query shapes, not synthetic load; most of what changed in 26.8 is plan-dependent, and plan changes need real cardinalities to reproduce.
The test ordering below is deliberate, and the principle behind it is: test things that can corrupt or lose data before things that can only be slow.
- Materialized views that use
POPULATE. First, because this is the only change on the list that can silently produce wrong data. Verify which of your views fall inside the new atomic guarantee —MergeTreeorMemorysource, local insert path, notCREATE OR REPLACE, not inside aReplicateddatabase — and treat everything outside it as unchanged from 26.3. Re-check any runbook that saysPOPULATEis safe now. - Parquet and object-storage read paths. Second, because lazy materialization is on by default and introduces a new failure class,
FILE_CHANGED_DURING_READ, in a code path that previously could not fail that way. Exercise every pipeline that reads files while another process writes them. Confirm the byte-reduction benefit while you are there — it should be visible in your object-storage metrics, not just in query time. - Join plans. Third, because column statistics on
INSERTare on by default below the 25 GiB threshold and will change join orders on freshly loaded tables. This changes performance, not correctness, so it ranks below the first two. CaptureEXPLAIN PLANoutput for your ten most expensive joins before the upgrade and diff after. Separately decide whether to opt intoie_joinandparallel_full_sorting_merge— do that as a second change, after the upgrade is stable. - Anything touching the PostgreSQL wire protocol. Last, because the surface is narrow and the blast radius is contained to clients that use it. Reconnect every BI tool, migration utility and
psqlsession; confirm\d,\dtand\dvbehave as your tooling expects.
Alongside those four, do these before you call the upgrade done:
- Pin
patch_parts_version = 'v1'for the duration of any rolling upgrade if you use lightweightUPDATE. - Set
asynchronous_metrics_key_values_mode = 'both'before the upgrade so dashboards keep working during migration. - Check for tooling that parses
EXPLAIN SYNTAXrow by row; it now returns one row. - Grep configs for
/etc/metrika.xml,SOURCE(LIBRARY,input_format_arrow_use_native_reader, and MySQL/NATS credential file paths declared in SQL. - Decide on
compatibilitypolicy: pin below26.8on day one, or accept new defaults and monitor closely.
Frequently Asked Questions
Is ClickHouse 26.8 an LTS release?
Yes. The release announcement states directly that 26.8 is a long-term support release, alongside its counts of 98 new features, 128 performance optimizations and 556 bug fixes. The first LTS build is v26.8.1.2041-lts, and the patch line had reached v26.8.10.6-lts within three weeks. The previous LTS line is 26.3, most recently v26.3.33.24-lts, which makes 26.3 → 26.8 a genuine LTS-to-LTS hop.
Does the ClickHouse pipe operator make queries slower?
No. Each |> wraps the preceding query in a subquery, producing the same abstract syntax tree as the equivalent nested-subquery form, and ClickHouse optimizes the whole query before executing it. No intermediate stage is materialized. You can confirm the rewrite on your own queries with EXPLAIN SYNTAX, which prints the desugared nested-SELECT form the optimizer actually receives.
Is atomic POPULATE safe on a replicated ClickHouse cluster?
Not fully. The changelog describes the guarantee as locally atomic: it covers the local insert path only, and requires a source that can pin a snapshot — the MergeTree family or Memory. Inserts arriving on another replica or through a distributed write path fall outside the cut, and views created with CREATE OR REPLACE, or inside Replicated databases, keep the legacy non-atomic population. On those topologies, continue quiescing writes or backfilling explicitly.
What does ClickHouse Iceberg write support actually cover?
ClickHouse 26.8 can insert into and create tables in Amazon S3 Tables and read and write Iceberg tables through the Snowflake Horizon catalog, both via the DataLakeCatalog database engine with allow_database_iceberg and allow_insert_into_iceberg enabled. It commits through the catalog and reads data files directly from object storage. It also adds Puffin and PuffinMetadata input formats for inspecting deletion vectors.
Which ClickHouse 26.8 join improvements are enabled by default?
Only column statistics on INSERT, which materialize when table size plus written block size is under materialize_statistics_on_insert_max_table_size, default 25 GiB, and which the release blog credits with a 29% improvement across TPC-H. The sort-based IEJoin requires adding ie_join to the join_algorithm setting, parallel_full_sorting_merge is another opt-in join_algorithm value, and the Cascades distributed cost-based optimizer is experimental behind enable_cascades_optimizer and make_distributed_plan.
How much does Parquet lazy materialization actually save?
On repeated checks against a public hits.parquet dataset on ClickHouse 26.8.2.7, an ORDER BY ... LIMIT 10 query read approximately 6.6–6.7 GB from S3 with the optimization disabled and 1.5 GB with it enabled, returning identical rows. Dictionary filter pushdown, measured separately on an equality predicate, processed 12 data pages against 226 with it disabled. Both are on by default; the second is capped by a 1 MiB dictionary-page limit.
Further Reading
- ClickHouse vs Druid vs Pinot: a real-time OLAP architecture decision record — where ClickHouse sits against the other real-time OLAP engines before you commit to an upgrade path.
- DuckDB vs ClickHouse for embedded analytics — the single-node comparison, relevant if 26.8’s local-Parquet lazy materialization changes your calculus.
- Apache Iceberg v4 vs v3: root manifests and single-file commits — the table-format background behind ClickHouse’s new catalog write path.
- InfluxDB vs TimescaleDB vs ClickHouse for IoT time series — ingest-side comparison, where the
max_insert_threadsdefault change matters most. - ClickHouse 26.8 release announcement and Pipelined SQL in ClickHouse 26.8 — the two primary vendor sources.
- The ClickHouse changelog — more precise than the blog on defaults and gating; the authoritative list of backward-incompatible changes.
By Riju — about
