{"slug": "clickhouse-release-26-9", "title": "ClickHouse release 26.9", "summary": "ClickHouse released version 26.9, containing 56 new features, 135 performance optimizations, and 464 bug fixes. The release adds conditional boundaries for LIMIT, incremental refreshes for append-only materialized views, disk spilling for high-cardinality DISTINCT queries, time-limited access tokens, faster min, max, and count queries, and expanded PromQL support, plus arithmetic operations between DateTime and Time values controlled by the date_time_overflow_behavior setting.", "body_md": "September has rolled around, and ClickHouse 26.9 has arrived with another packed collection of new features.\n\nThe ClickHouse 26.9 release contains 56 new features 🍁 135 performance optimizations 🍎 and 464 bug fixes 🐿️.\n\nThis release brings conditional boundaries for `LIMIT`, incremental refreshes for append-only materialized views, and disk spilling for high-cardinality `DISTINCT` queries.\n\nWe’ll also look at time-limited access tokens, faster `min`, `max`, and `count` queries, expanded PromQL support, and several quality-of-life improvements.\n\n## New contributors\n\nA special welcome to all the new contributors in 26.9! The growth of ClickHouse's community is humbling, and we are always grateful for the contributions that have made ClickHouse so popular.\n\nBelow are the names of the new contributors:\n\n*Aaron Harlap, Actuele AI, Alex Francoeur, Alex Prabhat Bara, AlexF, Anand Kumar Shaw, Anton Kovalenko, Aparajita Pandey, Brandon Pereira, Claude, Denys Stetsenko, Dmitrii Bezrukov, Evandro Leopoldino Gonçalves, Friedrich ten Hagen, George Viamontes, Gülçin Yıldırım Jelinek, Hamza Wasim, Hank Hoffmeier, Héctor Pablos, Itamar Tempelhof, Ivan N. Taranov, Ivan Tkachev, Ivan Tkatchev, Jithin Zachariah, Jordan Bertasso, Jords, Joshua, Juanjo, Kelly Toole, Lucas, Luis Neves, Luís Lizardo, Marat Dulin, Mike Shi, Navneet Kumar, Pablo Francisco Pérez Hidalgo, Paul Annesley, Philip Li, Pratham Nayak, Pratheesh, SamWolfberg, Sankalp Thakur, Sebastian Vercruyssse, Serhiy Bzhezytskyy, Takayuki Enomoto, Thien Phan, Vadim Ilves, XanderYoon, Yongqiang Tian, alexprabhat99, anand-tradesea, aparajita, bakhtiiartashbolotov, cuishuang, jithinzac, kasimtj, key-arg, kyungryun, linsen, maederm, mariahlynnenagy, miao tang, mosya415, ngagejason, sakshichitnis27, sleepingeight, statxc, t, tars, zhanglangning*\n\nHint: if you’re curious how we generate this list… [here](https://gist.github.com/gingerwizard/5a9a87a39ba93b422d8640d811e269e9).\n\nYou can also [view the slides from the presentation](https://presentations.clickhouse.com/2026-release-26.9).\n\n## Arithmetic operations between DateTime and Time\n\n### Contributed by Yarik Briukhovetskyi\n\nAs of ClickHouse 26.9, you can use `Time` values as offsets when adding to or subtracting from `DateTime` values. The result retains the `DateTime` value’s timezone.\n\nLet’s have a look at some simple examples:\n\n```\n1SELECT\n2    now() AS now,\n3    now + toTime('02:00:00'),\n4    toTime('04:00:00') + now,\n5    now64() AS now64,\n6    now64 - toTime('02:00:00'),\n7    now64 - toTime64('02:00:00.417', 3)\n8FORMAT Vertical;\n1Row 1:\n2──────\n3now:                      2026-09-21 13:49:06\n4plus(now, to⋯02:00:00')): 2026-09-21 15:49:06\n5plus(toTime(⋯:00'), now): 2026-09-21 17:49:06\n6now64:                    2026-09-21 13:49:06.440\n7minus(now64,⋯02:00:00')): 2026-09-21 11:49:06.440\n8minus(now64,⋯0.417', 3)): 2026-09-21 11:49:06.023\n```\n\nIf the calculation falls outside the range supported by the result type, [`date_time_overflow_behavior`](https://clickhouse.com/docs/reference/settings/formats/date-time#date_time_overflow_behavior) determines whether ClickHouse throws an exception, clamps the result to the nearest boundary, or leaves the overflow unchecked.\n\nThe maximum value that we can store in `DateTime` is `2106-02-07 06:28:15`. Let’s see what happens if we add one second to that time:\n\n```\n1SELECT\n2      toDateTime('2106-02-07 06:28:15', 'UTC')\n3      + toTime('00:00:01');\n1┌─plus(toDateT⋯00:00:01'))─┐\n2│      1970-01-01 00:00:00 │\n3└──────────────────────────┘\n```\n\nIt’s overflowed back to the minimum value of `DateTime`, which is expected as the default value of `date_time_overflow_behavior` is `ignore`.  But, maybe, we prefer to get an exception if the value overflows:\n\n```\n1SELECT toDateTime('2106-02-07 06:28:15', 'UTC') + toTime('00:00:01')\n2SETTINGS date_time_overflow_behavior = 'throw';\n1Received exception:\n2Code: 321. DB::Exception: Value 4294967296 is out of bounds of type DateTime: In scope SELECT toDateTime('2106-02-07 06:28:15', 'UTC') + toTime('00:00:01') SETTINGS date_time_overflow_behavior = 'throw'. (VALUE_IS_OUT_OF_RANGE_OF_DATA_TYPE)\n```\n\nOr, we can use `saturate`, in which case it will return the maximum value for that type:\n\n```\n1SELECT toDateTime('2106-02-07 06:28:10', 'UTC') + toTime('00:00:10')\n2SETTINGS date_time_overflow_behavior = 'saturate';\n1┌─plus(toDateT⋯00:00:10'))─┐\n2│      2106-02-07 06:28:15 │\n3└──────────────────────────┘\n```\n\nFinally, let’s run the timezone of the initial values and the calculated values:\n\n```\n1SELECT\n2    now() AS now,\n3    timezoneOf(now),\n4    now + toTime('02:00:00') AS future,\n5    timezoneOf(future)\n6FORMAT Vertical;\n1Row 1:\n2──────\n3now:                2026-09-21 14:37:21\n4timezoneOf(now):    Europe/London\n5future:             2026-09-21 16:37:21\n6timezoneOf(future): Europe/London\n```\n\n## PromQL private preview\n\n### Contributed by Vitaly Baranov, Nikita Mikhaylov, Valery Petrov, Minh Vu\n\nClickHouse 26.9 expands PromQL support with more functions, additional Prometheus HTTP API endpoints, and direct `SELECT` queries against TimeSeries tables.\n\nPromQL and the TimeSeries table engine are now available in private preview on ClickHouse Cloud, letting you store metrics in ClickHouse and query them from ClickStack, Grafana, clickhouse-client, or SQL.\n\nYou can learn more in the [Introducing ClickHouse's new TimeSeries engine](https://clickhouse.com/blog/introducing-promql) blog post.\n\n## CREATE TOKEN\n\n### Contributed by Alexey Milovidov\n\nClickHouse 26.9 introduces [CREATE TOKEN](https://github.com/ClickHouse/ClickHouse/pull/116957), which lets a user create a time-limited credential for applications, scripts, CI jobs, and agents without exposing or replacing their main password.\n\nThe token can be restricted to a subset of a user’s existing privileges, reducing the impact of a leaked credential.\n\nLet’s see how it works. First, we’ll create a small table:\n\n```\n1CREATE TABLE ourTable\n2(\n3    id UInt8\n4);\n5\n6INSERT INTO ourTable VALUES (1);\n```\n\nNext, let’s create a user called `alexey`:\n\n```\n1CREATE USER alexey IDENTIFIED WITH sha256_password BY 'main-password';\n2GRANT SELECT, INSERT ON ourTable TO alexey;\n3GRANT CREATE TOKEN ON *.* TO alexey;\n```\n\n`alexey` has `SELECT` and `INSERT` power on this table, and can also create a token for himself.\n\nNext, we’ll connect as `alexey` and create a token that lasts for 30 days and can only run `SELECT` queries against `ourTable`:\n\n```\n1CREATE TOKEN\n2VALID FOR INTERVAL 30 DAY\n3GRANTS (SELECT ON ourTable);\n```\n\nThe statement returns the generated token and its expiry:\n\n```\n1┌─token────────────────────────────┬─────────valid_until─┐\n2│ NXuRnBywn4HcHCIyBWC2WHT2Cfn4xQLb │ 2026-10-21 15:07:57 │\n3└──────────────────────────────────┴─────────────────────┘\n```\n\nClickHouse displays the token only once, so make sure you copy it down.\n\nWe can then connect with the token and run a `SELECT` query:\n\n```\n1./clickhouse client \\\n2--user alexey \\\n3--password 'NXuRnBywn4HcHCIyBWC2WHT2Cfn4xQLb' \\\n4--query \"SELECT * FROM ourTable\"\n11\n```\n\nThat works fine, just as we expected. But what about if we try to insert into the table using our token?\n\n```\n1./clickhouse client \\\n2--user alexey \\\n3--password 'NXuRnBywn4HcHCIyBWC2WHT2Cfn4xQLb' \\\n4--query \"INSERT INTO ourTable VALUES (2)\"\n1Code: 497. DB::Exception: alexey: Not enough privileges. To execute this query, it's necessary to have the grant INSERT(id) ON db.`table`. (ACCESS_DENIED)\n```\n\nThat doesn’t work as `alexey` only has `SELECT` access when authenticated using the token.\n\nA token never grants more privileges than the user already has, and it stops working when it expires or if the user is removed. If you don’t provide a `VALID UNTIL` or `VALID FOR`, the default lifetime is 30 minutes.\n\n## LIMIT with boundary conditions\n\n### Contributed by Zakhar Kravchuk and Nihal Miaji\n\nClickHouse 26.9 extends `LIMIT` with boundary conditions that start and stop output based on values in the ordered result stream, a capability that, to our knowledge, is not currently available in any other database.\n\n- `AFTER` includes the row that matches its condition.\n- `UNTIL` stops before its matching row\n- You can add `ALL` to apply the boundary each time the condition matches.\n\nTry the different combinations below to see which rows each query returns.\n\nThis functionality is particularly useful for analyzing log data, so let’s explore an Nginx dataset used in the [Compressing nginx logs 170x with column storage](https://clickhouse.com/blog/log-compression-170x) blog post.\n\nFirst, let’s create a table:\n\n```\n1CREATE TABLE nginx_logs\n2(\n3    timestamp DateTime,\n4    ip String,\n5    method LowCardinality(String),\n6    path String,\n7    status UInt16,\n8    response_bytes UInt64,\n9    referer String,\n10    user_agent String\n11)\n12ENGINE = MergeTree\n13ORDER BY (timestamp, ip, path);\n```\n\nNext, we’ll ingest the data:\n\n```\n1INSERT INTO nginx_logs\n2WITH extractGroups(\n3    line,\n4    '^(\\\\S+) - \\\\S+ \\\\[([^\\\\]]+)\\\\] \"(\\\\S+) (.*) [^ ]+\" (\\\\d+) (\\\\d+) \"([^\"]*)\" \"(.*)\"$'\n5) AS fields\n6SELECT\n7    assumeNotNull(parseDateTimeBestEffortOrNull(fields[2])) AS timestamp,\n8    fields[1] AS ip,\n9    fields[3] AS method,\n10    fields[4] AS path,\n11    toUInt16(fields[5]) AS status,\n12    toUInt64(fields[6]) AS response_bytes,\n13    fields[7] AS referer,\n14    fields[8] AS user_agent\n15FROM s3(\n16    'https://datasets-documentation.s3.eu-west-3.amazonaws.com/http_logs/nginx-66.log.gz',\n17    LineAsString\n18)\n19WHERE length(fields) = 8\n20  AND parseDateTimeBestEffortOrNull(fields[2]) IS NOT NULL;\n```\n\nNow, let’s get a quick overview of the data.\n\n```\n1SELECT\n2    count() AS rows,\n3    min(timestamp) AS first_timestamp,\n4    max(timestamp) AS last_timestamp,\n5    countIf(status >= 500) AS server_errors\n6FROM nginx_logs;\n1Row 1:\n2──────\n3rows:            66514081 -- 66.51 million\n4first_timestamp: 2019-01-24 00:00:00\n5last_timestamp:  2019-02-24 00:00:00\n6server_errors:   69857\n```\n\nThe following query starts at the first `5xx` response and returns five requests. `AFTER` is inclusive, so the request that satisfies the condition is included.\n\n```\n1SELECT timestamp, path, status\n2FROM nginx_logs\n3WHERE ip = '91.243.160.31'\n4  AND timestamp >= '2019-01-24 00:00:00'\n5  AND timestamp < '2019-02-03 00:00:00'\n6ORDER BY timestamp, ip, path, method, status, response_bytes\n7LIMIT 5 AFTER status >= 500;\n1┌───────────timestamp─┬─path─────────────────────────────────────────────────────────┬─status─┐\n2│ 2019-01-24 06:54:01 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n3│ 2019-01-24 06:54:02 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n4│ 2019-01-24 06:54:06 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n5│ 2019-01-24 06:54:15 │ /product/552/1168/مایکروفر-رومیزی-ال-جی-مدل-MS93SCR          │    200 │\n6│ 2019-01-24 06:54:16 │ /image/552/product/50x50                                     │    200 │\n7└─────────────────────┴──────────────────────────────────────────────────────────────┴────────┘\n```\n\n`UNTIL` is exclusive. This query starts at the first server error and stops before the first response with a status below `500`:\n\n```\n1SELECT timestamp, path, status\n2FROM nginx_logs\n3WHERE ip = '91.243.160.31'\n4  AND timestamp >= '2019-01-24 00:00:00'\n5  AND timestamp < '2019-02-03 00:00:00'\n6ORDER BY timestamp, ip, path, method, status, response_bytes\n7LIMIT 100\n8    AFTER status >= 500\n9    UNTIL status < 500;\n1┌───────────timestamp─┬─path─────────────────────────────────────────────────────────┬─status─┐\n2│ 2019-01-24 06:54:01 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n3│ 2019-01-24 06:54:02 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n4│ 2019-01-24 06:54:06 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n5└─────────────────────┴──────────────────────────────────────────────────────────────┴────────┘\n```\n\nAn `AFTER` boundary is applied only once, unless we use `ALL`, which reapplies it whenever another row matches. This query returns every server error and the next two requests after it:\n\n```\n1SELECT timestamp, path, status\n2FROM nginx_logs\n3WHERE ip = '91.243.160.31'\n4  AND timestamp >= '2019-01-24 00:00:00'\n5  AND timestamp < '2019-02-03 00:00:00'\n6ORDER BY timestamp, ip, path, method, status, response_bytes\n7LIMIT 3 AFTER status >= 500 ALL;\n1┌───────────timestamp─┬─path─────────────────────────────────────────────────────────┬─status─┐\n2│ 2019-01-24 06:54:01 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n3│ 2019-01-24 06:54:02 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n4│ 2019-01-24 06:54:06 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n5│ 2019-01-24 06:54:15 │ /product/552/1168/مایکروفر-رومیزی-ال-جی-مدل-MS93SCR          │    200 │\n6│ 2019-01-24 06:54:16 │ /image/552/product/50x50                                     │    200 │\n7│ 2019-01-28 23:27:00 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n8│ 2019-01-28 23:27:01 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n9│ 2019-01-28 23:27:05 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n10│ 2019-01-28 23:27:14 │ /product/552/1168/مایکروفر-رومیزی-ال-جی-مدل-MS93SCR          │    200 │\n11│ 2019-01-28 23:27:15 │ /image/552/product/50x50                                     │    200 │\n12│ 2019-02-02 13:43:29 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n13│ 2019-02-02 13:43:30 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n14│ 2019-02-02 13:43:34 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n15│ 2019-02-02 13:43:43 │ /product/552/1168/مایکروفر-رومیزی-ال-جی-مدل-MS93SCR          │    200 │\n16│ 2019-02-02 13:43:44 │ /image/552/product/50x50                                     │    200 │\n17└─────────────────────┴──────────────────────────────────────────────────────────────┴────────┘\n```\n\nThere are three separate incidents in this period, on rows 2, 7, and 12. Within each burst, every 500 responses reapplies the three-row boundary.\n\nThe consecutive failures therefore, extend the active window until two consecutive non-5xx requests occur after the final failure. In this example, both are successful 200 responses.\n\n`ALL` can also reapply the starting boundary while `UNTIL` terminates each range:\n\n```\n1SELECT timestamp, path, status\n2FROM nginx_logs\n3WHERE ip = '91.243.160.31'\n4  AND timestamp >= '2019-01-24 00:00:00'\n5  AND timestamp < '2019-02-03 00:00:00'\n6ORDER BY timestamp, ip, path, method, status, response_bytes\n7LIMIT 100\n8    AFTER status >= 500 ALL\n9    UNTIL status < 500;\n1┌───────────timestamp─┬─path─────────────────────────────────────────────────────────┬─status─┐\n2│ 2019-01-24 06:54:01 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n3│ 2019-01-24 06:54:02 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n4│ 2019-01-24 06:54:06 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n5│ 2019-01-28 23:27:00 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n6│ 2019-01-28 23:27:01 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n7│ 2019-01-28 23:27:05 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n8│ 2019-02-02 13:43:29 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n9│ 2019-02-02 13:43:30 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n10│ 2019-02-02 13:43:34 │ /product/28579/57435/اجاق-گاز-صفحه-ای-داتیس-مدل-DG-503-Ultra │    500 │\n11└─────────────────────┴──────────────────────────────────────────────────────────────┴────────┘\n```\n\nUnlike the previous query, this output excludes the successful requests after each failure burst. `UNTIL` closes the range at the first non-5xx response, while `ALL` continues scanning for the next burst.\n\n## Incremental refreshable materialized views\n\n### Contributed by Smita Kulkarni\n\nClickHouse 26.9 adds `APPEND INCREMENTAL` to [refreshable materialized views](https://clickhouse.com/docs/concepts/features/materialized-views/refreshable-materialized-view). Instead of scanning the entire source table on every refresh, ClickHouse processes only the rows committed since the previous refresh.\n\nThis can be used to incrementally copy append-only data into another ClickHouse table or replicate an event stream from a MergeTree table into an Iceberg data lake.\n\nLet’s have a look at how to use this feature with Iceberg.\n\nWe’ll create an append-only stream of order events in ClickHouse and periodically copy them into an Iceberg table, demonstrating that each refresh processes only newly committed rows.\n\nFirst, we’ll create our ClickHouse table:\n\n```\n1CREATE TABLE order_events\n2(\n3    event_id UInt64,\n4    event_time DateTime,\n5    order_id UInt64,\n6    event_type LowCardinality(String),\n7    country LowCardinality(String),\n8    amount Decimal(10, 2)\n9)\n10ORDER BY (event_time, order_id, event_id)\n11SETTINGS\n12    enable_block_number_column = 1,\n13    enable_block_offset_column = 1,\n14    add_minmax_index_for_block_number_column = 1,\n15    add_minmax_index_for_block_offset_column = 1,\n16    part_minmax_index_columns = 'with_block_number_offset';\n```\n\nThe block-number and block-offset columns provide the cursor that ClickHouse uses to identify rows committed after the previous refresh.\n\nNext, we’ll enable Iceberg inserts and create an Iceberg table on my local filesystem:\n\n```\n1SET allow_insert_into_iceberg = 1;\n2CREATE TABLE lake_order_events\n3(\n4    event_id UInt64,\n5    event_time DateTime,\n6    order_id UInt64,\n7    event_type String,\n8    country String,\n9    amount Decimal(10, 2)\n10)\n11ENGINE = IcebergLocal('lake_order_events', 'Parquet');\n```\n\nThe `lake_order_events` directory contains the Iceberg metadata, manifests, and Parquet data files.\n\nFinally, we’ll create a materialized view that will copy the newly committed events to Iceberg every hour:\n\n```\n1CREATE MATERIALIZED VIEW order_events_to_iceberg\n2REFRESH EVERY 1 HOUR APPEND INCREMENTAL\n3TO lake_order_events\n4AS\n5SELECT event_id, event_time, order_id, event_type, country, amount\n6FROM order_events;\n```\n\nTime to ingest some data!\n\n```\n1INSERT INTO order_events VALUES\n2    (1, '2026-09-22 09:05:00', 1001, 'placed', 'UK',       0),\n3    (2, '2026-09-22 09:17:00', 1002, 'placed', 'Germany',  0),\n4    (3, '2026-09-22 09:20:00', 1001, 'paid',   'UK',      89.99),\n5    (4, '2026-09-22 09:31:00', 1002, 'paid',   'Germany', 129.00);\n```\n\nThese events will be copied across to the Iceberg table when the materialized view triggers each hour, but to speed things up, we’ll trigger a refresh:\n\n```\n1SYSTEM REFRESH VIEW order_events_to_iceberg;\n2SYSTEM WAIT VIEW order_events_to_iceberg;\n```\n\nAnd, if we query the Iceberg table:\n\n```\n1SELECT * FROM lake_order_events;\n1┌─event_id─┬─────────────────event_time─┬─order_id─┬─event_type─┬─country─┬─amount─┐\n2│        1 │ 2026-09-22 09:05:00.000000 │     1001 │ placed     │ UK      │      0 │\n3│        2 │ 2026-09-22 09:17:00.000000 │     1002 │ placed     │ Germany │      0 │\n4│        3 │ 2026-09-22 09:20:00.000000 │     1001 │ paid       │ UK      │  89.99 │\n5│        4 │ 2026-09-22 09:31:00.000000 │     1002 │ paid       │ Germany │    129 │\n6└──────────┴────────────────────────────┴──────────┴────────────┴─────────┴────────┘\n```\n\nAll the records have made their way across. Next, let’s add some more rows to simulate changes to those orders:\n\n```\n1INSERT INTO order_events VALUES\n2    (5, '2026-09-22 10:03:00', 1001, 'shipped',  'UK',        0),\n3    (6, '2026-09-22 10:11:00', 1002, 'refunded', 'Germany', -129.00),\n4    (7, '2026-09-22 10:28:00', 1003, 'placed',   'France',     0);\n```\n\nWe’ll manually refresh the materialized view again and then query the Iceberg table again:\n\n```\n1┌─event_id─┬─────────────────event_time─┬─order_id─┬─event_type─┬─country─┬─amount─┐\n2│        1 │ 2026-09-22 09:05:00.000000 │     1001 │ placed     │ UK      │      0 │\n3│        2 │ 2026-09-22 09:17:00.000000 │     1002 │ placed     │ Germany │      0 │\n4│        3 │ 2026-09-22 09:20:00.000000 │     1001 │ paid       │ UK      │  89.99 │\n5│        4 │ 2026-09-22 09:31:00.000000 │     1002 │ paid       │ Germany │    129 │\n6│        5 │ 2026-09-22 10:03:00.000000 │     1001 │ shipped    │ UK      │      0 │\n7│        6 │ 2026-09-22 10:11:00.000000 │     1002 │ refunded   │ Germany │   -129 │\n8│        7 │ 2026-09-22 10:28:00.000000 │     1003 │ placed     │ France  │      0 │\n9└──────────┴────────────────────────────┴──────────┴────────────┴─────────┴────────┘\n```\n\nWe can see the three new events are there.\n\nFor an Iceberg target, ClickHouse stores the incremental cursor in the snapshot summary. The cursor and newly appended data are therefore committed as part of the same Iceberg snapshot. We can query `system.iceberg_history` to see this:\n\n```\n1SELECT\n2    made_current_at, operation,\n3    summary['added-records'] AS added_records,\n4    summary['total-records'] AS total_records,\n5    summary['total-data-files'] AS total_data_files,\n6    summary['clickhouse.refresh-cursor'] != '' AS has_refresh_cursor\n7FROM system.iceberg_history\n8WHERE table = 'lake_order_events'\n9ORDER BY made_current_at;\n1┌─────────made_current_at─┬─operation─┬─added_records─┬─total_records─┬─total_data_files─┬─has_refresh_cursor─┐\n2│ 2026-09-22 11:03:47.549 │ APPEND    │ 4             │ 4             │ 1                │                  1 │\n3│ 2026-09-22 11:04:36.247 │ APPEND    │ 3             │ 7             │ 2                │                  1 │\n4└─────────────────────────┴───────────┴───────────────┴───────────────┴──────────────────┴────────────────────┘\n```\n\nThe first refresh added four records, while the second added only the three new events, taking the total from four to seven. We can also see that both snapshots contain a refresh cursor.\n\nClickHouse stores this cursor in the Iceberg snapshot alongside the newly appended data. If ClickHouse restarts, the next refresh resumes from that position instead of replaying the same events. The cursor only advances when the append succeeds, so it always stays in sync with the data.\n\nThis approach works best with append-only data, such as events, logs, audit records, and other immutable facts.\n\n## min, max, and count from column statistics\n\n### Contributed by Alexey Milovidov\n\nClickHouse stores minimum and maximum values for numeric-like columns in each data part. As of 26.9, it can use those statistics to answer min, max, and count queries without reading the underlying column data.\n\nLet’s try it with the Nginx logs table that we used earlier. We’ll use `response_bytes` rather than `timestamp`. Since `timestamp` is the first column in the table’s sorting key, ClickHouse can already answer that query from existing metadata.\n\nWe'll prefix our query with `EXPLAIN` so that we can see the query plan:\n\n```\n1EXPLAIN\n2SELECT\n3    min(response_bytes),\n4    max(response_bytes),\n5    count()\n6FROM nginx_logs;\n1┌─explain──────────────────────────────────────────────────────────┐\n2│ Output: min(response_bytes), max(response_bytes), count()        │\n3│                                                                  │\n4│ Aggregating                                                      │\n5│ │  Keys:                                                         │\n6│ │  Aggregates: min(response_bytes), max(response_bytes), count() │\n7│ │  Skip merging: 0                                               │\n8│ └──ReadFromPreparedSource (_statistics_min_max_projection)       │\n9└──────────────────────────────────────────────────────────────────┘\n```\n\nIf we look at the last line, we can see that instead of reading the `response_bytes` column, ClickHouse prepares the result from its column statistics.\n\nWe can disable this optimization using the setting `use_statistics_for_min_max_aggregation`, and now ClickHouse has to scan the `response_bytes` column to compute the result, as shown in the following query:\n\n```\n1EXPLAIN\n2SELECT\n3    min(response_bytes),\n4    max(response_bytes),\n5    count()\n6FROM nginx_logs\n7SETTINGS use_statistics_for_min_max_aggregation = 0;\n1┌─explain──────────────────────────────────────────────────────────┐\n2│ Output: min(response_bytes), max(response_bytes), count()        │\n3│                                                                  │\n4│ Aggregating                                                      │\n5│ │  Keys:                                                         │\n6│ │  Aggregates: min(response_bytes), max(response_bytes), count() │\n7│ │  Skip merging: 0                                               │\n8│ └──ReadFromMergeTree (default.nginx_logs)                        │\n9│       Read type: Default                                         │\n10│       Parts: 5 | Granules: 8122                                  │\n11│       Output: response_bytes                                     │\n12└──────────────────────────────────────────────────────────────────┘\n```\n\n## `system.session_query_ids` \n\n### Contributed by Vladimir Cherkasov\n\nClickHouse 26.9 introduces a new system table, `system.session_query_ids`, which keeps track of all the query ids in the current session in execution order.\n\nWe can query that table like this:\n\n```\n1SELECT * FROM system.session_query_ids;\n1┌─sequence_number─┬─query_id─────────────────────────────┐\n2│               1 │ 8ebdec91-0636-44e7-9d1a-66974c2d0fe3 │\n3│               2 │ 471e8bef-2042-4394-9a08-3538f4ebcf85 │\n4│               3 │ f9757bd2-8fe3-4b14-b681-8e938a396880 │\n5│               4 │ 36903bb2-9f85-420b-b37b-66f0d23ce41e │\n6│               5 │ 4127be18-3e58-45f6-8153-5ff9a0d04675 │\n7│               6 │ a81309ab-3498-4ba9-9217-8145406a168d │\n8└─────────────────┴──────────────────────────────────────┘\n```\n\nThe `system.query_log` table includes a `query_id` per entry, which means we can now check which queries we just ran, without having to manually specify query ids when querying that table:\n\n```\n1SELECT query, query_duration_ms FROM system.query_log\n2WHERE query_id IN (SELECT query_id FROM system.session_query_ids)\n3AND type = 'QueryFinish' \n4ORDER BY event_time_microseconds;\n1┌─query─────────────────────────────────────────────────────────────┬─query_duration_ms─┐\n2│ SELECT * FROM lake_order_events;                                  │                10 │\n3│ system flush logs;                                                │                 0 │\n4│ SELECT query, query_duration_ms FROM system.query_log            ↴│                 3 │\n5│↳WHERE query_id IN (SELECT query_id FROM system.session_query_ids)↴│                   │\n6│↳  AND type = 'QueryFinish' ORDER BY event_time_microseconds;      │                   │\n7│ SELECT * FROM system.session_query_ids;                           │                 0 │\n8│ set output_format_pretty_row_numbers=0;                           │                 0 │\n9│ SELECT * FROM system.session_query_ids;                           │                 0 │\n10└───────────────────────────────────────────────────────────────────┴───────────────────┘\n```\n\n## Limits on table size and table count\n\n### Contributed by Alexey Milovidov\n\nClickHouse 26.9 lets us put limits on how large an individual table can grow, as well as how many tables can be created in a database.\n\nThis is useful for multi-tenant, temporary, and demo environments, where we don’t want one workload to consume everything.\n\nLet’s start by creating a table that can contain a maximum of three rows:\n\n```\n1CREATE TABLE limited_events\n2(\n3    id UInt64,\n4    message String\n5)\n6ENGINE = MergeTree\n7ORDER BY id\n8SETTINGS max_table_size_rows = 3;\n```\n\nWe’ll insert three rows:\n\n```\n1INSERT INTO limited_events VALUES\n2    (1, 'started'),\n3    (2, 'processing'),\n4    (3, 'finished');\n```\n\nIf we insert one more row, it still succeeds:\n\n```\n1INSERT INTO limited_events VALUES\n2    (4, 'one past limit');\n3\n4SELECT count()\n5FROM limited_events;\n1┌─count()─┐\n2│       4 │\n3└─────────┘\n```\n\nThe limit is checked against the table’s current size when an `INSERT` starts. This means the `INSERT` that takes the table from three to four rows can finish. The next `INSERT` sees that the table is already over the limit and is rejected:\n\n```\n1INSERT INTO limited_events VALUES\n2    (5, 'rejected');\n1Code: 1016. DB::Exception: Table size limit exceeded: the total number of rows in active data parts of table default.limited_events is 4, which exceeds the 'max_table_size_rows' setting value (3). (TABLE_SIZE_LIMIT_EXCEEDED)\n```\n\nWe can also limit a table by its compressed or uncompressed size using `max_table_size_bytes_compressed` and `max_table_size_bytes_uncompressed`.\n\nWe can put a limit on the number of tables in a database as well. Let’s create a database that can contain two tables:\n\n```\n1CREATE DATABASE tenant\n2ENGINE = Atomic\n3SETTINGS max_tables = 2;\n```\n\nWe can create the first two tables as usual:\n\n```\n1CREATE TABLE tenant.events (id UInt64)\n2ENGINE = MergeTree\n3ORDER BY id;\n4\n5CREATE TABLE tenant.users (id UInt64)\n6ENGINE = MergeTree\n7ORDER BY id;\n```\n\nBut if we try to create a third table, ClickHouse rejects it:\n\n```\n1CREATE TABLE tenant.audit_log (id UInt64)\n2ENGINE = MergeTree\n3ORDER BY id;\n1Code: 724. DB::Exception: Too many tables in database `tenant`. The limit (database setting `max_tables`) is set to 2, the current number is 2. (TOO_MANY_TABLES)\n```\n\n## DISTINCT in external memory\n\n### Contributed by Nihal Z. Miaji\n\nConceptually, `DISTINCT` needs to keep track of the values it has already seen. ClickHouse has several optimizations that reduce this work, but a high-cardinality query may still require maintaining a large in-memory set.\n\nClickHouse 26.9 can spill this hash set to disk, just like `GROUP BY` and `ORDER BY`, instead of allowing it to keep growing until the query runs out of memory.\n\nLet’s see what that looks like by returning all the distinct values in a sequence of 100 million numbers, while using [`max_memory_usage`](https://clickhouse.com/docs/reference/settings/session-settings/max-memory-usage#max_memory_usage) to limit the query to 150 MB of memory:\n\n```\n1SELECT DISTINCT number\n2FROM numbers_mt(100_000_000)\n3FORMAT `NULL`\n4SETTINGS max_memory_usage = 150_000_000\n1Received exception:\n2Code: 241. DB::Exception: Query memory limit exceeded: would use 244.33 MiB (attempt to allocate chunk of 127.00 MiB), maximum: 143.05 MiB: While executing ExternalDistinctTransform. (MEMORY_LIMIT_EXCEEDED)\n```\n\nIt's unable to process the query as there isn't enough memory. We can allow `DISTINCT` to spill its intermediate state to disk by setting [`max_bytes_before_external_distinct`](https://clickhouse.com/docs/reference/settings/session-settings/max-bytes#max_bytes_before_external_distinct):\n\n```\n1SELECT DISTINCT number\n2FROM numbers_mt(100_000_000)\n3FORMAT `NULL`\n4SETTINGS\n5    max_memory_usage = 150_000_000,\n6    max_bytes_before_external_distinct = 25_000_000;\n10 rows in set. Elapsed: 1.676 sec. Processed 100.00 million rows, 800.00 MB (59.65 million rows/s., 477.21 MB/s.)\n2Peak memory usage: 105.63 MiB.\n```\n\nThis time, ClickHouse starts writing the `DISTINCT` data to temporary files when it reaches around 25 MB. The whole query can use up to 150 MB, leaving enough memory to read and merge those files at the end.\n\nThere's one more interesting thing to keep in mind when using this feature. Spilling to disk reduces memory usage, but it doesn't remove the need for memory completely. ClickHouse still needs memory for buffers, the query pipeline, and merging those temporary files that it's spilled to disk.\n\nLet's see what happens if we reduce the overall query limit to 100 MB, while keeping the spill to disk threshold at 25 MB:\n\n```\n1SELECT DISTINCT number\n2FROM numbers_mt(100_000_000)\n3FORMAT `NULL`\n4SETTINGS\n5    max_memory_usage = 100_000_000,\n6    max_bytes_before_external_distinct = 25_000_000;\n1Received exception:\n2Code: 241. DB::Exception: Query memory limit exceeded: would use 98.94 MiB (attempt to allocate chunk of 4.13 MiB), maximum: 95.37 MiB: While executing BufferingFromFileSource. (MEMORY_LIMIT_EXCEEDED)\n```\n\nThe data has been spilled successfully, but ClickHouse runs out of memory while reading the temporary files back. We can tell from `BufferingFromFileSource` that the failure occurred during processing of the spilled files, rather than during the construction of the original in-memory set.\n\nTo fix that, we need to increase `max_memory_usage` back to 150MB.\n\nClickHouse automatically enables external `DISTINCT` when [`max_bytes_ratio_before_external_distinct`](https://clickhouse.com/docs/reference/settings/session-settings/max-bytes#max_bytes_ratio_before_external_distinct) is set to `0.5`. This means that `DISTINCT` starts spilling when it reaches half of the memory available.\n\n## Bracket syntax for JSON subcolumns\n\n### Contributed by Pavel Kruglov\n\nClickHouse 26.9 adds bracket syntax for accessing paths in a `JSON` value. This makes it easier to write nested paths work with keys that contain characters such as dots or spaces.\n\nLet's have a look at how this works with help from an in-memory example:\n\n```\n1WITH '{\n2    \"user\": {\"name\": \"Alex\"},\n3    \"release.version\": \"26.9\",\n4    \"first name\": \"Alexey\",\n5    \"tags\": [\"database\", \"analytics\"]\n6}'::JSON AS json\n7SELECT\n8    json.user.name AS dot_notation,\n9    json['user']['name'] AS bracket_notation,\n10    json['release.version'] AS release_version,\n11    json['first name'] AS first_name;\n1┌─dot_notation─┬─bracket_notation─┬─release_version─┬─first_name─┐\n2│ Alex         │ Alex             │ 26.9            │ Alexey     │\n3└──────────────┴──────────────────┴─────────────────┴────────────┘\n```\n\n### Get started today\n\nInterested in seeing how ClickHouse works on your data? Get started with ClickHouse Cloud in minutes and receive $300 in free credits.\n\n[Sign up](https://console.clickhouse.cloud/signUp?loc=blog-cta-2268-get-started-today-sign-up&utm_blogctaid=2268)", "url": "https://wpnews.pro/news/clickhouse-release-26-9", "canonical_source": "https://clickhouse.com/blog/clickhouse-release-26-09", "published_at": "2026-09-23 00:00:00+00:00", "updated_at": "2026-10-08 11:46:34.387313+00:00", "lang": "en", "topics": ["ai-infrastructure", "developer-tools"], "entities": ["ClickHouse", "ClickHouse 26.9", "Yarik Briukhovetskyi"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/clickhouse-release-26-9", "markdown": "https://wpnews.pro/news/clickhouse-release-26-9.md", "text": "https://wpnews.pro/news/clickhouse-release-26-9.txt", "jsonld": "https://wpnews.pro/news/clickhouse-release-26-9.jsonld"}}