If you run ClickHouse in production, you're probably on 26.3 LTS. And now 26.8 LTS has been announced, which means the LTS-to-LTS upgrade conversation starts again.
Here's the thing most release posts skip: this is not a one-release hop.
Going from 26.3 LTS to 26.8 LTS means crossing 26.4, 26.5, 26.6 and 26.7 as well. Every breaking change in those four releases applies to you, and some of the ones most likely to ruin your day aren't in 26.8 at all.
So instead of writing another "here are the 26.8 features" post, I wanted to write the thing I'd actually want before scheduling this upgrade: what breaks, what silently changes, what order to do things in, and what you get for the trouble.
A note on release timing
As of writing (27 August 2026), 26.8 has been announced but is not fully released yet.
The release branch is cut and versioned (v26.8.1.1-lts), but the tag and Docker images have not been published yet, and the upstream changelog still marks the 26.8 section as in progress.
By the time you read this, the tag has probably landed. Check for yourself:
curl -s https://raw.githubusercontent.com/ClickHouse/ClickHouse/master/utils/list-versions/version_date.tsv \
| awk -F'\t' '$1 ~ /^v26\.8\./ {print "26.8 is released - newest: " $1 " (" $2 ")"; f=1; exit}
END {if (!f) print "26.8 not released yet"}'
version_date.tsv is the list ClickHouse maintains of every released version and its date, so this is the most direct answer available - no auth, no rate limit, nothing to download. As of writing it prints 26.8 not released yet.
Worth knowing: the Docker image will lag whatever that command tells you. The Docker Official Images repo trails the GitHub tags by a few patch versions - clickhouse:lts currently resolves to 26.3.20.7 even though 26.3.24.4 has already shipped. So don't treat a missing image as evidence the release hasn't happened.
Either way, the timing works in your favour. Historically ClickHouse LTS releases pick up several patch releases quickly - 26.7 had five within a month, and 26.3 is already at 26.3.24 - so waiting for roughly 26.8.3 before touching production is the normal conservative play. Use the gap to prepare.
The scale of what you're crossing
| Release | Date | Breaking | New features | Perf improvements |
|---|---|---|---|---|
| 26.4 | 2026-04-30 | 6 | 34 | 45 |
| 26.5 | 2026-05-21 | 10 | 31 | 48 |
| 26.6 | 2026-06-25 | 10 | 50 | 79 |
| 26.7 | 2026-07-22 | 11 | 35 | 117 |
| 26.8 LTS | 2026-08-27 | 21 | 49 | 127 |
| Total | 58 | 199 | 416 |
A note on that 58. The upstream changelog lists 58 backward-incompatible entries across these five releases, but one is recorded twice - the http_max_fields reduction (PR #103285) appears in both the 26.4 and 26.5 sections, presumably from a backport. That leaves 57 unique breaking changes, which is the number in the title.
Two further caveats, since I'd rather you knew than found out: three of 26.8's 21 entries still carry unresolved TODO markers upstream, and one of those is flagged "wait for the revert-of-revert," so it may not survive to the final notes. The 26.8 section is also still marked in progress, so this count can move - more likely up than down. Everything here was counted on 27 August 2026.
On top of all that, roughly 70 settings changed their default value across the five releases. Those don't appear in any "backward incompatible" list, and they're where a lot of the surprises actually live.
I've grouped everything below by what it does to you, not by which release it landed in. That's the more useful ordering when you're planning an upgrade.
Part 1: Three things that can stop the upgrade dead
Start here. These aren't "test carefully" items - they're "the server won't start" or "you can't go back" items.
1. The x86 build now requires AVX2 (26.6)
The default x86 build moved from x86-64-v2 (SSE4.2) to x86-64-v3, which needs AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE and XSAVE. In practice: Intel Haswell or newer, AMD Excavator or newer.
Virtually every x86 CPU after 2015 supports this. But if you have older hardware anywhere in the fleet, or you're running on a hypervisor that masks CPU flags, the binary simply won't run.
Check every node before you plan anything else:
grep -o 'avx2' /proc/cpuinfo | head -1
No output means you need the amd64compat build, which targets plain x86-64.
This one is easy to miss because it doesn't appear in the 26.8 notes at all.
2. insert_deduplication_version has a mandatory migration order (26.6 → 26.7)
If your config sets insert_deduplication_version to old_separate_hashes or compatible_double_hashes, a 26.7+ server refuses to start. Not a warning - a refusal.
The migration path has to happen before you upgrade:
- Run a release that supports
compatible_double_hashes(it writes both the legacy and unified hashes). - Keep it running for at least
replicated_deduplication_window_seconds- one hour by default - for replicated tables. For non-replicated tables withnon_replicated_deduplication_window > 0, the window is count-based, so run it for at least that many inserts. - Remove the setting, or set it to
new_unified_hash. - Now upgrade.
If you never set this setting, you can ignore all of it. Worth a quick SELECT * FROM system.server_settings WHERE name = 'insert_deduplication_version' to be sure.
While we're here: 26.6 also changed what dedup means. It now works on the whole inserted block per insert rather than per part or partition. A retry of the same insert is still deduplicated, but two different inserts that happen to produce an identical part are no longer cross-deduplicated, and reordered inserts of the same rows are no longer deduplicated either. If you were relying on the old behaviour as a correctness guarantee, that's worth thinking about.
3. S3 stops using the server's own credentials for user queries (26.7)
This is the one I'd most expect to catch people out, because of how it fails.
S3 access originating from user SQL no longer resolves the server's own cloud credentials by default - not environment variables, not IMDS/IRSA, not instance profiles, not AWS config files, not role_arn-based STS, not GCP OAuth metadata. Such requests now need explicit credentials or NOSIGN. Named collections default use_environment_credentials to 0.
To restore the old behaviour you need both:
SET use_environment_credentials = 1;
SET s3_allow_server_credentials_in_user_queries = 1;
The nastier part is startup behaviour. A persistent S3 or S3Queue table, a dynamic S3 disk, or a DataLakeCatalog whose definition resolves such credentials now loads anonymously rather than aborting server startup. That's controlled by s3_load_table_anonymously_if_credentials_restricted, which is on by default.
So the server comes up clean, everything looks healthy, and then queries against those tables fail at read time. Test every S3 access path before this hop, not after.
Part 2: Changes that alter results without throwing an error
These are the ones that won't show up in your logs. Your queries keep running; the answers change.
Loose date strings now parse (26.5)
date_time_input_format and cast_string_to_date_time_mode both moved from basic to best_effort.
Strings like 2024 April 4 or Apr 15, 2020 10:30:00 used to be rejected. Now they parse. If you were relying on strict parsing to reject malformed input at the door, that door is now open - bad data lands and gets interpreted rather than bouncing.
CAST preserves the source time zone (26.5)
CAST to DateTime or DateTime64 without an explicit time zone now preserves the time zone of its source argument, matching what toDateTime and toDateTime64 already did.
More correct, but it changes displayed timestamps in existing queries. If you have dashboards where times shifted after an upgrade, this is a likely culprit.
FINAL on a JOIN stopped leaking to other tables (26.6)
This was a bug fix, but it changes results.
Previously, FINAL on the left-most table of a JOIN was also applied to the other joined tables. That was never intended. Now it isn't.
If you have queries that were quietly depending on the old behaviour, they'll start returning pre-merge duplicates from the right-hand side. There's a compatibility setting:
SET analyzer_compatibility_apply_final_to_all_joined_tables = 1;
It's registered in the settings history, so compatibility set below 26.6 restores it automatically.
Unquoted JSON numbers become timestamps (26.8)
In JSONEachRow and similar formats, an unquoted number for a DateTime/DateTime64 column is now read as a Unix timestamp with optional sub-second precision - consistent with Values, CAST and toDateTime64.
Concretely, before this change:
-
1703363853.035was rejected outright - A bare
1703363853was read as the raw scaled tick value, producing a1970-...timestamp
Now both do what you'd expect. Quoted strings and ClickHouse's own JSON output are unaffected.
For most people this silently fixes things. But if your pipeline was compensating for the old raw-ticks behaviour - pre-scaling values, or writing quoted strings specifically to avoid it - you'll now get a double correction.
SET input_format_read_datetime_number_as_raw_value = 1;
toDate32 reinterprets a whole numeric range (26.8)
Date32 extended its range from [1900-01-01, 2299-12-31] to [0000-01-01, 9999-12-31], matching DateTime64.
The subtle consequence: in toDate32(N), values in [120530, 2932896] are now interpreted as day numbers - dates from 2300-01-01 to 9999-12-31 - rather than Unix timestamps in early 1970. If you feed epoch seconds into toDate32, you get wildly different results.
Array function semantics fixed (26.8)
arrayIntersect and arraySymmetricDifference no longer count a value repeated within a single argument as if it appeared in several arguments:
SELECT arrayIntersect([1], [2], [1, 1]); -- [] (was [1])
SELECT arrayIntersect([1, 2], [2], [1, 1, 2]); -- [2] (was [1, 2])
SELECT arraySymmetricDifference([1], [2, 2]); -- [2, 1] (was [1])
A value now counts for an argument only if it was present in every argument before it, so the result contains exactly the values present in all arguments. arrayUnion is unaffected.
There's a nice side effect: arrayIntersect now builds its hash table from the smallest argument rather than all of them - up to 1.85x faster and about a third less memory when argument sizes differ a lot.
Trivial views over Distributed tables push down (26.8)
For a view whose body is a plain SELECT over a single Distributed table, the whole outer query now goes to the shards. This is optimize_trivial_view_pushdown_to_distributed, on by default.
Two observable changes: FINAL and SAMPLE written on the view reference now propagate to the shard-local table instead of being silently ignored, and extremes isn't reported on single-shard clusters.
If you had views where FINAL was quietly a no-op, your results change - more correct, probably slower.
Part 3: Operational behaviour changes
max_insert_threads now defaults to auto (26.8)
Probably the biggest operational change in 26.8. INSERT SELECT is now parallelised across available CPU cores by default, and max_insert_threads can also fan out the write side of a plain INSERT where the destination path can safely do so.
Faster ingestion, but with consequences:
-
More data parts from the same query, so more merge pressure and a real risk of
too_many_partson tables with tightparts_to_throw_insert -
Different row order within an insert, which matters for anything depending on insertion order - a non-deterministic
ORDER BYtie-break,_part/_block_numberassumptions, a ReplacingMergeTree without a proper version column
One detail worth knowing if you diff configs: the declared default is literally 0 in both 26.7 and 26.8. What changed is the setting's type, from UInt64 to MaxThreads. Under UInt64, both 0 and 1 meant single-threaded. Under MaxThreads, 0 resolves to auto. So system.settings reads 0 before and after while the behaviour flips underneath you.
Upstream records the change as 1 → 0 in its compatibility mapping, which looks contradictory until you realise 1 is what you now set to get the old behaviour - not what the declaration used to say.
SET max_insert_threads = 1; -- restores the old behaviour
Lightweight update patch parts get a v2 format (26.8)
Patch parts produced by lightweight UPDATE now use a v2 on-disk format, sorted by (sorting_key..., _block_number, _block_offset) with a new merge algorithm. Peak memory during apply is bounded by the largest equal-sort-key run rather than the whole patch, and updates crossing merge boundaries no longer fall back to an in-memory Join apply. Old-format patch parts stay readable.
For replicated clusters, this needs a rolling-upgrade pin. Note that patch_parts_version is a MergeTree setting, not a session setting, so SET patch_parts_version = 'v1' will throw. Use the config:
<merge_tree>
<patch_parts_version>v1</patch_parts_version>
</merge_tree>
Or per table:
ALTER TABLE my_table MODIFY SETTING patch_parts_version = 'v1';
Hold that until every replica is on 26.8, then remove it.
The same logic applies to text indexes: text_index_serialization_version defaults to v2_with_positions in 26.8, which persists token positions for phrase search. Older servers can't read that format, so pin it to v1_with_codec during the rollout.
Hash joins now spill by default (26.5)
max_bytes_ratio_before_external_join moved from 0 to 0.5. Hash joins now spill to disk at 50% of the memory limit.
Generally a good thing - fewer OOM kills. But if you had joins comfortably fitting in memory, some of them will now start writing to disk and get slower. Watch join-heavy query latency after the upgrade.
REPLACE PARTITION from an empty source is now rejected (26.6)
Previously, ALTER TABLE ... REPLACE PARTITION ... FROM ... where the source table had no parts in that partition would silently drop the destination partition and write nothing. A genuine data-loss footgun.
It's now rejected with BAD_ARGUMENTS. If you have automation that relied on the old behaviour as a clearing mechanism, it will start failing - which is the correct outcome, but it will fail. Use DROP PARTITION explicitly, or set allow_replace_partition_from_empty_source = 1.
AggregatingMergeTree validates schemas at creation (26.7)
AggregatingMergeTree now rejects, at table creation time, schemas where a column is neither part of the sorting key nor an aggregate-state measure (AggregateFunction / SimpleAggregateFunction).
Those columns were silently collapsed to an arbitrary value during background merges, producing wrong results for anything grouping or filtering on them. Now you find out up front.
Existing tables keep working, but any automation that recreates such tables will start failing. allow_dimensions_outside_sorting_key = 1 restores the old permissiveness.
HTTP request limits tightened (26.4)
http_max_fields dropped from 1,000,000 to 1,000, and http_max_field_name_size from 128 KB to 4 KB, to limit pre-authentication memory usage. New http_max_request_header_size and http_headers_read_timeout settings arrived alongside.
If any client sends a lot of headers or URL parameters - some role-based auth proxies do - it'll start getting rejected.
Part 4: The tooling around ClickHouse
This is the part upgrade checklists usually miss. ClickHouse itself starts fine; the dashboards, exporters and scripts built around it don't.
Asynchronous metrics became maps (26.8)
Per-CPU-core and per-device metrics were consolidated into single key-value metrics. OSUserTimeCPU0, OSUserTimeCPU1, OSUserTimeCPU2 became a single OSUserTimeCPU metric holding a map from core number to value.
The same applies to the other OS*TimeCPU* metrics, plus CPUFrequencyMHz_*, Temperature*, EDAC*, Block*_*, Network(Receive|Send)*_*, Disk*_*, *BlobsQueueEstimate and AsyncLogging*QueueSize.
Downstream:
-
system.asynchronous_metricsgainskey_values Map(LowCardinality(String), Float64). The plainvaluecolumn isNaNfor those metrics -
system.asynchronous_metric_loglogs one row per key via a newkeycolumn - Prometheus exports them as labels:
ClickHouseAsyncMetrics_BlockReadBytes{device="sda"} - Graphite
MetricsTransmittersends<prefix>.<Metric>.<key>
Any Grafana panel keyed on the old metric names goes blank. Not errors - just empty charts, which is worse because nobody notices for a week.
EXPLAIN PLAN output format changed (26.7)
explain_query_plan_default moved from legacy to pretty, so EXPLAIN PLAN now defaults to actions=1, compact=1, pretty=1.
If you have anything that parses EXPLAIN output - query validators, cost estimators, index advisors, any "explain this query" feature in an internal tool - it breaks. This one is in 26.7, so a 26.8-only reading of the changelog misses it entirely.
SET explain_query_plan_default = 'legacy';
EXPLAIN SYNTAX returns one row (26.8)
EXPLAIN SYNTAX now returns the reformatted query as a single String record with embedded newlines, instead of one record per line. So SELECT count() FROM (EXPLAIN SYNTAX ...) returns 1.
Two ways to restore the old shape - the per-statement option and the session setting:
EXPLAIN SYNTAX single_record = 0 SELECT 1;
-- or session-wide:
SET explain_syntax_single_record = 0;
Other EXPLAIN kinds (PLAN, PIPELINE, AST) keep their per-line tree output.
System table changes
| Change | Release | Impact |
|---|---|---|
system.users.valid_until is now Array(DateTime64(0)), was Array(DateTime)
|
26.8 | Deadlines past 2106 are exact. Tooling reading this column needs to handle the new type |
show_data_lake_catalogs_in_system_tables renamed to show_remote_databases_in_system_tables and broadened |
26.6 | At the default 0, MySQL and PostgreSQL databases are now hidden from system.tables, system.columns and system.completions - not just data lake catalogs. Schema browsers will show fewer databases |
system.instrumentation.parameters renamed to arguments; HANDLER PARAMETERS syntax renamed to HANDLER ARGUMENTS
|
26.6 | Old names are gone, not aliased |
system.histogram_metric_log deprecated |
26.5 | Histograms are now a nested column on system.metric_log
|
allow_feature_tier numbers shifted (26.8)
A new private preview tier sits between experimental and beta, so the ordering is now experimental < private preview < beta < production.
The numeric levels moved with it: 0 allows all tiers, 1 excludes experimental, 2 additionally excludes private preview, 3 allows production only.
If you pinned allow_feature_tier = 2 to mean "production settings only", you now need 3. This is filed under New Feature in the changelog, but it's a silent behaviour change to an existing config value.
Part 5: Removals - the grep list
Things that no longer exist. Each of these is a cheap search through your codebase and configs.
| Removed | Release | Replacement |
|---|---|---|
library dictionary source (SOURCE(LIBRARY(...))) |
26.8 | Fails with UNKNOWN_ELEMENT_IN_CONFIG. dictionaries_lib_path is inert |
include_from defaulting to /etc/metrika.xml
|
26.8 | Add <include_from>/etc/metrika.xml</include_from> explicitly. Separately-loaded users.xml and XML dictionary configs each need their own |
MySQL source ssl_ca / ssl_cert / ssl_key as file paths from SQL |
26.8 | Use ssl_ca_pem / ssl_cert_pem / ssl_key_pem with inline contents. Paths still work in the server config file |
NATS nats_credential_file from SQL |
26.8 | Use inline nats_credentials, or a named collection in the server config |
Apache Arrow-based Arrow / ArrowStream reader and writer |
26.8 | Native implementation only. The *_use_native_reader / _writer settings are accepted but ignored |
Config-based workload scheduling (resources, workload_classifiers sections) |
26.7 |
CREATE RESOURCE / CREATE WORKLOAD. Old sections ignored with a warning |
snowflakeToDateTime, snowflakeToDateTime64, dateTimeToSnowflake, dateTime64ToSnowflake
|
26.7 | The snowflakeID* variants. Deprecated since 24.6 |
hasColumnInTable remote form (hostname/username/password args) |
26.7 | Only hasColumnInTable(database, table, column). The remote mode allowed arbitrary outbound connections and leaked credentials into query logs |
zip / zipx backup archives on object storage |
26.7 | Use tar.gz. Zip needs seeking to read its central directory, which is very slow over object storage |
Naive Bayes models as XML config + .bin files |
26.7 | Recreate as dictionaries with the NAIVE_BAYES layout. Upside: roughly 49x less memory and 11x faster on the cited model |
kql table function |
26.5 | SET dialect = 'kusto' |
| Arrow-based Parquet reader and writer | 26.5 | Native implementation |
KQL array_sort_asc / array_sort_desc
|
26.6 | Now UNKNOWN_FUNCTION
|
allow_experimental_query_deduplication |
26.6 | Removed entirely |
Arguments to RANK / DENSE_RANK
|
26.5 | Per SQL standard these take zero arguments. allow_rank_dense_rank_arguments = 1 restores leniency |
Also worth checking: icebergHash and icebergBucket now reject 128/256-bit integers and Decimal256 (26.6) instead of silently truncating them into colliding hashes, and nested Dynamic/Variant are rejected in min/max aggregates and minmax indexes (26.6).
Part 6: What you actually get for it
It's worth saying: this is a lot of upgrade work, and the payoff is real. 416 performance improvements across the five releases, most of them enabled by default.
Aggregation and grouping. A new adaptive parallel GROUP BY algorithm where each thread aggregates into its own cache-resident hash table until it hits a threshold, then freezes it. Bounded-heap pruning for GROUP BY ... ORDER BY ... LIMIT. Smaller hash-table cells for single-String keys. Parallelised final merge of single-level tables. Aggregations without aggregate functions now use HashSet instead of HashMap, up to 1.8x faster.
Reads. Lazy materialization for Parquet on object storage - on a 200 MB S3 file, an ORDER BY ... LIMIT 10 read 3.3 MB instead of 171 MB. That's 51x less I/O and 8x faster. read_in_order_use_virtual_row on by default cuts peak memory when reading in primary key order over tables with many parts. Dictionary-page-based row group skipping in the Parquet V3 reader.
Joins. IEJoin, a sort-based algorithm for ON clauses with two inequality comparisons - previously those ran as a CROSS JOIN with a filter, INNER only. parallel_full_sorting_merge, which shards a merge join by join-key hash across threads, benchmarked around 2.4x faster and 3.3x less memory than parallel_hash. Constant-false JOIN conditions no longer read the non-contributing side.
Per-partition processing. DISTINCT, window functions and IN (subquery) set building can now keep each partition's rows in a single stream when the partition expression is a deterministic function of the relevant columns, skipping the hash scatter entirely.
On the feature side, the ones I'd actually reach for:
-
Pipe operators -
FROM t |> WHERE ... |> AGGREGATE ... |> ORDER BY ..., GoogleSQL-style. In a query starting withFROM,SELECTis optional and defaults toSELECT * -
run_query_in_background- the server accepts the query, returns immediately, and runs it to completion regardless of what happens to the connection. Built for longINSERT SELECT,CTASandPOPULATE -
CREATE HANDLER/ALTER HANDLER/DROP HANDLER- custom HTTP handlers defined in SQL, persisted locally or in Keeper, withcurrentHandler(),currentRequestURL()andsystem.handlers. This could genuinely replace some small API services -
Atomic
POPULATE-CREATE MATERIALIZED VIEW ... POPULATEis now locally atomic, so rows inserted through the same server during population aren't missed or duplicated. Local insert path only, and it needs a snapshot-capable source.POPULATEalso works withTOnow -
system.user_query_log- every user sees their own query log rows without needing access tosystem.query_log. Useful if you're building multi-tenant tooling -
create_union_system_log_tables- auto-maintainedall_...tables likesystem.all_query_log, unioning a log table, its rotated versions, and the same table across cluster replicas - Keeper on-disk storage via a custom LSM tree, with similar performance to the in-memory storage, and a Raft topology view in the Keeper dashboard
-
GROUPSwindow frames (SQL:2011), where boundaries count peer groups rather than physical rows or value distances -
New text index tokenizers - Japanese (MeCab), Chinese (jieba-style), ICU locale-aware segmentation, and
splitByRegexp, which keeps tokens likeC++andC#intact -
Data lake expansion - BigQuery table function and engine, Snowflake Horizon (read and write Iceberg), S3 Tables catalog with working
INSERT, andPuffinformat support
The upgrade plan
Here's the order I'd actually do this in.
Step 0: Audit before you touch anything
-- Dictionaries using the removed library source
SELECT name, source FROM system.dictionaries WHERE source ILIKE '%library%';
-- AggregatingMergeTree schemas that 26.7 would now reject
SELECT database, table, name, type
FROM system.columns
WHERE (database, table) IN (
SELECT database, name FROM system.tables
WHERE engine LIKE '%AggregatingMergeTree%'
)
AND is_in_sorting_key = 0
AND type NOT LIKE '%AggregateFunction%';
-- Non-default settings you're carrying
SELECT name, value, default FROM system.settings WHERE changed;
SELECT name, value FROM system.server_settings WHERE changed;
-- Removed functions still in use
SELECT DISTINCT query FROM system.query_log
WHERE event_date >= today() - 30
AND type = 'QueryFinish'
AND (query ILIKE '%snowflakeToDateTime%'
OR query ILIKE '%dateTimeToSnowflake%'
OR query ILIKE '%array_sort_asc%'
OR query ILIKE '%array_sort_desc%'
OR query ILIKE '%kql(%')
LIMIT 100;
Plus a manual pass over:
-
/proc/cpuinfoon every node for AVX2 -
insert_deduplication_versionin your config - Every S3 access path - tables, disks, catalogs, named collections
-
/etc/metrika.xmlreliance - MySQL
ssl_*paths and NATSnats_credential_filein named collections and dictionaries - Backup scripts using zip to object storage
-
resources/workload_classifiersconfig sections -
allow_feature_tier = 2in config - Ingestion clients writing unquoted epoch numbers into
DateTime64viaJSONEachRow
Step 1: Use compatibility as a shock absorber
SET compatibility = '26.3';
Set this globally, upgrade the binaries, confirm the cluster is healthy, then peel it back one release at a time - 26.4, 26.5, 26.6, 26.7 - with a validation pass between each.
This restores the great majority of the ~70 default flips in one move, which turns "57 breaking changes at once" into something you can bisect. It does not cover removals, renames or type changes. Those need actual code fixes, which is what Step 0 finds.
Step 2: Pin the on-disk formats during the rollout
While a mixed-version cluster exists:
-
patch_parts_version = 'v1'(MergeTree setting, config orALTER TABLE) text_index_serialization_version = 'v1_with_codec'
Remove both once every replica is on 26.8.
Two other on-disk changes are safe by design and don't need pinning: packed_skip_index_max_bytes (defaults to 1 MiB, affects only new parts, still readable by older servers) and compute_exact_num_defaults_for_sparse_columns (the flag in serialization.json is ignored by older versions, so parts survive a downgrade).
Step 3: Watch these after the upgrade
-
Part counts and merge queue depth in
system.partsandsystem.merges- themax_insert_threadseffect -
system.errorsforUNACCEPTABLE_URL,ILLEGAL_COLUMN,UNKNOWN_ELEMENT_IN_CONFIGandBAD_ARGUMENTS, which cover most of the new rejections - Monitoring gaps where per-CPU and per-device async metrics used to be
-
Join memory profiles -
max_bytes_ratio_before_external_join = 0.5means spilling starts where it previously didn't - Anything that parses EXPLAIN output
Final thoughts
The pattern I'd take away from this: the changes most likely to hurt you aren't in the release you're upgrading to. AVX2, the S3 credential change, the dedup migration ordering and the EXPLAIN output format all landed in 26.6 and 26.7. If you read only the 26.8 notes - which is what most people do for an LTS upgrade - you miss all four.
The second pattern: a meaningful share of the risk sits around ClickHouse rather than inside it. Dashboards keyed on metric names, scripts parsing EXPLAIN output, schema browsers reading system.tables, tooling reading system.users. The server will start fine and those will quietly break.
None of which is an argument against upgrading. 416 performance improvements, mostly on by default, is a genuinely good deal - and 26.3 won't be the current LTS forever.
Just budget for it as a five-release jump, because that's what it is.
Verified against the upstream CHANGELOG.md, SettingsChangesHistory.cpp and the 26.8 release branch source as of 27 August 2026. If you spot something that's drifted since, let me know in the comments.
Top comments (0)