Skip to content

ClickHouse Release 25.10

neutral avatar 400804ae96
Nov 7, 2025 ยท 22 minutes read

Another month goes by, which means itโ€™s time for another release!

ClickHouse version 25.10 contains 20 new features ๐Ÿ‘ป 30 performance optimizations ๐Ÿ”ฎ 103 bug fixes ๐ŸŽƒ

This release introduces a collection of join improvements, a new data type for vector search, late materialization of secondary indices, and more!

New contributors

A special welcome to all the new contributors in 25.10! The growth of ClickHouse's community is humbling, and we are always grateful for the contributions that have made ClickHouse so popular.

Below are the names of the new contributors:

0xgouda, Ahmed Gouda, Albert Chae, Austin Bonander, ChaiAndCode, David E. Wheeler, DeanNeaht, Dylan, Frank Rosner, GEFFARD Quentin, Giampaolo Capelli, Grant Holly, Guang, Guang Zhao, Isak Ellmer, Jan Rada, Kunal Gupta, Lonny Kapelushnik, Manuel Raimann, Michal Simon, Narasimha Pakeer, Neerav, Raphaรซl Thรฉriault, Rui Zhang, Sadra Barikbin, copilot-swe-agent[bot], dollaransh17, flozdra, jitendra1411, neeravsalaria, pranav mehta, zlareb1, |2ustam, ะะฝะดั€ะตะน ะšัƒั€ะณะฐะฝัะบะธะน, ะั€ั‚ะตะผ ะฎั€ะพะฒ

Hint: if youโ€™re curious how we generate this listโ€ฆ here.

You can also view the slides from the presentation.

Lazy columns replication in JOINs

Contributed by Pavel Kruglov

"When will you stop optimizing join performance?"
We will never stop!

This release once again brings JOIN performance optimizations.

The first join improvement in 25.10 is lazy columns replication, a new optimization that reduces CPU and memory usage when JOINs produce many duplicate values.

When running JOIN queries (including those using the arrayJoin function), values from the input tables are often replicated in the result, especially when there are many matches for a given key.

As an example, consider a hits table containing anonymized web analytics data, sketched below with two columns, ClientIP and URL:

25.10-image1.png

When we run a self-join:

SELECT ...
FROM
    hits AS t1 INNER JOIN hits AS t2
    ON t1.ClientIP = t2.ClientIP;

Then the result can heavily duplicate values from both sides:

25.10-image2.png

For large columns (like URL), this replication consumes significant CPU cycles and memory, as the same values are repeatedly copied in memory.

With 25.10, ClickHouse no longer spends CPU and memory replicating identical values during JOINs.

Instead, weโ€™ve introduced a new internal representation for replicated columns like URL.

Rather than physically replicating data, ClickHouse now keeps the original non-replicated column alongside a compact index column that points to it:

25.1-image3.png

We call this mechanism lazy columns replication; it defers physical value replication until itโ€™s actually needed (and often, it never is).

To control this behavior, use the settings

Inspecting the effect in practice

To measure the effect, we benchmarked this feature on an AWS EC2 m6i.8xlarge instance (32 vCPUs, 128 GiB RAM) using the hits table.

Here is how you can create and load this table on your own.

First, we ran the example self-join without lazy replication:

SELECT sum(cityHash64(URL))
FROM
    hits AS t1 INNER JOIN hits AS t2
    ON t1.ClientIP = t2.ClientIP
SETTINGS
    enable_lazy_columns_replication = 0,
    allow_special_serialization_kinds_in_output_formats = 0;
โ”Œโ”€sum(cityHash64(URL))โ”€โ”
โ”‚  8580639250520379278 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

1 row in set. Elapsed: 83.396 sec. Processed 199.99 million rows, 10.64 GB (2.40 million rows/s., 127.57 MB/s.)
Peak memory usage: 4.88 GiB.

Then, we ran the same query with lazy columns replication enabled:

SELECT sum(cityHash64(URL))
FROM
    hits AS t1 INNER JOIN hits AS t2
    ON t1.ClientIP = t2.ClientIP
SETTINGS
    enable_lazy_columns_replication = 1,
    allow_special_serialization_kinds_in_output_formats = 1;
โ”Œโ”€sum(cityHash64(URL))โ”€โ”
โ”‚  8580639250520379278 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

1 row in set. Elapsed: 4.078 sec. Processed 199.99 million rows, 10.64 GB (49.04 million rows/s., 2.61 GB/s.)
Peak memory usage: 4.57 GiB.

Result: โ€œLazy columns replicationโ€ made this self-join over 20ร— faster while slightly reducing peak memory use, by avoiding unnecessary copying of large string values.

Bloom filters in JOINs

Contributed by Alexander Gololobov

The next join optimization generalizes a technique already used in ClickHouseโ€™s full sorting merge join algorithm, where joined tables can be filtered by each otherโ€™s join keys before the actual join takes place.

In 25.10, a similar optimization has been introduced for ClickHouseโ€™s fastest join algorithm, the parallel hash join.

The join is sped up by โ‘  building a bloom filter from the joinโ€™s right side join key data at runtime and passing this filter to the โ‘ก scan in the joinโ€™s left side data. The diagram below sketches this for the parallel hash joinโ€™s physical query plan (โ€œquery pipelineโ€). You can read how the rest of this plan works here.

25.10-image3.png

This optimization is controlled by the setting enable_join_runtime_filters.

We benchmarked this feature on an AWS EC2 m6i.8xlarge instance (32 vCPUs, 128 GiB RAM) using the TPC-H dataset with scale factor 100. Below, weโ€™ll first inspect how the optimization changes the query plan, and then measure its impact in practice.

Inspecting the logical plan

The easiest way to look under the hood of a JOIN query is by inspecting its logical query plan with EXPLAIN plan.

Letโ€™s do that for a simple join between the TPC-H orders and customer tables on the custkey column, where we disabled the bloom filter based pre-filtering:

EXPLAIN plan
SELECT *
FROM orders, customer
WHERE o_custkey = c_custkey
SETTINGS enable_join_runtime_filters = 0;

The relevant part of the plan looks like this:

...                             
Join                                                       
...
ReadFromMergeTree (default.orders)                   
ReadFromMergeTree (default.customer)

Weโ€™ll skip the rest of the plan and focus on the core mechanics.

Reading the output from bottom to top, we can see that ClickHouse plans to read the data from the two tables, orders and customer, and perform the join.

Next, letโ€™s inspect the logical query plan for the same join, but this time with runtime pre-filtering enabled:

EXPLAIN plan
SELECT *
FROM orders, customer
WHERE o_custkey = c_custkey
SETTINGS enable_join_runtime_filters = 1;

The relevant parts of the plan look like this:

...
Join                                                                                                                                                                                             
...                                                                                                                                                                         
Prewhere filter column: __filterContains(_runtime_filter_14211390369232515712_0, __table1.o_custkey)                                                                          
...
BuildRuntimeFilter (Build runtime join filter on __table2.c_custkey (_runtime_filter_14211390369232515712_0))
...

Reading the plan from bottom to top, we can see that ClickHouse first โ‘  builds a Bloom filter from the join key values on the right-hand side (customer) table.

This runtime filter is then โ‘ก applied as a PREWHERE filter on the left-hand side (orders) table, allowing irrelevant rows to be skipped before the join is executed.

Running the query with and without runtime filtering

Now letโ€™s actually run a slightly extended version of that join query, this time joining orders, customer, and nation, and calculating the average order total for customers from France.

Weโ€™ll start with runtime pre-filtering disabled:

SELECT avg(o_totalprice)
FROM orders, customer, nation
WHERE (c_custkey = o_custkey) AND (c_nationkey = n_nationkey) AND (n_name = 'FRANCE')
SETTINGS enable_join_runtime_filters = 0;
โ”Œโ”€โ”€avg(o_totalprice)โ”€โ”
โ”‚ 151149.41468432106 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

1 row in set. Elapsed: 1.005 sec. Processed 165.00 million rows, 1.92 GB (164.25 million rows/s., 1.91 GB/s.)
Peak memory usage: 1.24 GiB.

Then, we run the same query again, this time with runtime pre-filtering enabled:

SELECT avg(o_totalprice)
FROM orders, customer, nation
WHERE (c_custkey = o_custkey) AND (c_nationkey = n_nationkey) AND (n_name = 'FRANCE')
SETTINGS enable_join_runtime_filters = 1;
โ”Œโ”€โ”€avg(o_totalprice)โ”€โ”
โ”‚ 151149.41468432106 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

1 row in set. Elapsed: 0.471 sec. Processed 165.00 million rows, 1.92 GB (350.64 million rows/s., 4.08 GB/s.)
Peak memory usage: 185.18 MiB.

Result:

With runtime pre-filtering enabled, the same query ran 2.1ร— faster while using nearly 7ร— less memory.

By filtering rows early with Bloom filters, ClickHouse avoids scanning and processing unnecessary data, delivering faster joins and lower resource usage.

Push-down of complex conditions in JOINs

Contributed by Yarik Briukhovetskyi

ClickHouse can now push down complex OR conditions in JOIN queries to filter each table earlier, before the join actually happens.

This optimization works when every branch of an OR condition includes at least one filter (predicate) for each table involved in the join.

For example:

(t1.k IN (1,2) AND t2.x = 100)
OR
(t1.k IN (3,4) AND t2.x = 200)

In this case, both sides of the join (t1 and t2) have predicates in every branch.

ClickHouse can therefore combine and push them down as:

  • t1.k IN (1,2,3,4) for the left table

  • t2.x IN (100,200) for the right table

This allows both tables to be pre-filtered before the join, reducing the data read and improving performance.

This optimization is available under the setting use_join_disjunctions_push_down.

To see how this optimization works in practice, weโ€™ll look at a simple example using the TPC-H dataset (scale factor 100) on an AWS EC2 m6i.8xlarge instance (32 vCPUs, 128 GiB RAM).

Weโ€™ll join the customer and nation tables on c_nationkey, using a condition that contains two OR branches, each filtering both sides of the join.

Inspecting the logical plan

First, letโ€™s inspect the logical query plan for this query without the push-down optimization:

EXPLAIN plan
SELECT *
FROM customer AS c
INNER JOIN nation AS n
    ON c.c_nationkey = n.n_nationkey
WHERE (c.c_name LIKE 'Customer#00000%' AND n.n_name = 'GERMANY')
   OR (c.c_name LIKE 'Customer#00001%' AND n.n_name = 'FRANCE')
SETTINGS use_join_disjunctions_push_down = 0;

In this plan, ClickHouse simply reads data from both tables and applies the full filter during the join:

Join                                                       
...
ReadFromMergeTree (default.customer)
ReadFromMergeTree (default.nation)

Now, letโ€™s enable the new optimization:

EXPLAIN plan
SELECT *
FROM customer AS c
INNER JOIN nation AS n
    ON c.c_nationkey = n.n_nationkey
WHERE (c.c_name LIKE 'Customer#00000%' AND n.n_name = 'GERMANY')
   OR (c.c_name LIKE 'Customer#00001%' AND n.n_name = 'FRANCE')
SETTINGS use_join_disjunctions_push_down = 1;

This time, ClickHouse identifies that both branches contain predicates for both tables.

It derives separate filters for each side, pushing them down so that both customer and nation are filtered before the join:

Join
...
Filter
ReadFromMergeTree (default.customer)
...
Filter
ReadFromMergeTree (default.nation)

Benchmarking the effect

Next, letโ€™s actually run the query with the optimization disabled and enabled to see the performance difference.

Without push-down:

SELECT *
FROM customer AS c
INNER JOIN nation AS n
    ON c.c_nationkey = n.n_nationkey
WHERE (c.c_name LIKE 'Customer#00000%' AND n.n_name = 'GERMANY')
   OR (c.c_name LIKE 'Customer#00001%' AND n.n_name = 'FRANCE')
SETTINGS use_join_disjunctions_push_down = 0;
788 rows in set. Elapsed: 0.240 sec. Processed 15.00 million rows, 2.93 GB (62.56 million rows/s., 12.21 GB/s.)
Peak memory usage: 261.30 MiB.

With push-down enabled:

SELECT *
FROM customer AS c
INNER JOIN nation AS n
    ON c.c_nationkey = n.n_nationkey
WHERE (c.c_name LIKE 'Customer#00000%' AND n.n_name = 'GERMANY')
   OR (c.c_name LIKE 'Customer#00001%' AND n.n_name = 'FRANCE')
SETTINGS use_join_disjunctions_push_down = 0;
788 rows in set. Elapsed: 0.010 sec. Processed 24.60 thousand rows, 4.81 MB (2.47 million rows/s., 482.53 MB/s.)
Peak memory usage: 4.30 MiB.

Result:

With push-down enabled, the same query ran 24ร— faster and used over 60ร— less memory.

By pushing filters for both sides of the join down to the table scan, ClickHouse avoids reading and processing millions of irrelevant rows.

Automatically build column statistics for MergeTree tables

Contributed by Anton Popov

This is the fourth join-related optimization in this release, albeit an indirect one.

In the previous release, ClickHouse introduced automatic global join reordering, allowing the engine to efficiently reorder complex join graphs spanning dozens of tables. This resulted in significant improvements, for example, a 1,450x speedup and 25ร— reduction in memory usage on one TPC-H example query.

Global join reordering works best when column statistics are available for the join keys and filters involved. Until now, these statistics had to be created manually for each column.

Starting with 25.10, ClickHouse can now automatically create statistics for all suitable columns in a MergeTree table using the new table-level setting auto_statistics_types.

This setting defines which types of statistics to build (for example, minmax, uniq, countmin):

CREATE TABLE tpch.orders (...) ORDER BY (o_orderkey)
SETTINGS auto_statistics_types = 'minmax, uniq, countmin';

This enables statistics generation for all columns in the table automatically.

You can also configure it globally for all MergeTree tables in your server configuration:

$ cat /etc/config.d/merge_tree.yaml
merge_tree:
    auto_statistics_types: 'minmax, uniq, countmin'

By keeping statistics up to date automatically, ClickHouse can make smarter join and filter decisions, improving query planning and reducing both memory use and runtime without manual tuning.

These four features (lazy columns replication, bloom filters in JOINs, push-down of complex conditions, and automatic column statistics) are the latest in a long line of JOIN optimizations in ClickHouse, and they wonโ€™t be the last.

QBit data type

Contributed by Raufs Dunamalijevs

QBit is a data type for vector embeddings that lets you tune search precision at runtime. It uses a bit-sliced data layout where every number is sliced by bits, and at query time, we specify, how many (most significant) bits to take.

CREATE TABLE vectors (
    id UInt64, name String, ...
    vec QBit(BFloat16, 1536)
) ORDER BY ();
SELECT id, name FROM vectors
ORDER BY L2DistanceTransposed(vector, target, 10)
LIMIT 10;

Raufs Dunamalijevs has written in detail about the QBit in the blog post โ€˜We built a vector search engine that lets you choose precision at query timeโ€™.

SQL updates

Contributed by Nihal Z. Miaji, Surya Kant Ranjan, Simon Michal

The ClickHouse 25.10 release sees several additions to the supported SQL syntax.

First up is general support for the <=> (IS NOT DISTINCT FROM) operator, which was previously only supported in the JOIN ON part of a query. This operator offers equality comparison that treats NULLs as identical. Letโ€™s have a look at how it works:

SELECT NULL <=> NULL, NULL = NULL;
โ”Œโ”€isNotDistincโ‹ฏNULL, NULL)โ”€โ”ฌโ”€equals(NULL, NULL)โ”€โ”
โ”‚                        1 โ”‚ แดบแตแดธแดธ               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Next up, we have negative limit and offset. This is useful for a query where we want to retrieve the n most recent records, but return them in ascending order. Letโ€™s explore this feature using the UK property prices dataset.

Imagine we want to find properties sold for over ยฃ10 million since 2024, in descending date order. We could write the following query:

SELECT date, price, county, district
FROM uk.uk_price_paid
WHERE date >= '2024-01-01' AND price > 10_000_000
ORDER BY date DESC LIMIT 10;
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€dateโ”€โ”ฌโ”€โ”€โ”€โ”€priceโ”€โ”ฌโ”€countyโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€districtโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 2025-03-13 โ”‚ 12000000 โ”‚ CHESHIRE WEST AND CHESTER โ”‚ CHESHIRE WEST AND CHESTER โ”‚
โ”‚ 2025-03-06 โ”‚ 18375000 โ”‚ STOKE-ON-TRENT            โ”‚ STOKE-ON-TRENT            โ”‚
โ”‚ 2025-03-06 โ”‚ 10850000 โ”‚ HERTFORDSHIRE             โ”‚ HERTSMERE                 โ”‚
โ”‚ 2025-03-04 โ”‚ 11000000 โ”‚ PORTSMOUTH                โ”‚ PORTSMOUTH                โ”‚
โ”‚ 2025-03-04 โ”‚ 18000000 โ”‚ GREATER LONDON            โ”‚ HAMMERSMITH AND FULHAM    โ”‚
โ”‚ 2025-03-03 โ”‚ 12500000 โ”‚ ESSEX                     โ”‚ BASILDON                  โ”‚
โ”‚ 2025-02-20 โ”‚ 16830000 โ”‚ GREATER LONDON            โ”‚ CITY OF WESTMINSTER       โ”‚
โ”‚ 2025-02-13 โ”‚ 13950000 โ”‚ GREATER LONDON            โ”‚ KENSINGTON AND CHELSEA    โ”‚
โ”‚ 2025-02-07 โ”‚ 81850000 โ”‚ ESSEX                     โ”‚ EPPING FOREST             โ”‚
โ”‚ 2025-02-07 โ”‚ 24920000 โ”‚ GREATER LONDON            โ”‚ HARINGEY                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

But letโ€™s say we want to get those same most recent 10 sales, but have them sorted in ascending order by date. This is where negative limit functionality comes in handy. We can adjust the ORDER BY and LIMIT parts of the query like so:

SELECT date, price, county, district
FROM uk.uk_price_paid
WHERE date >= '2024-01-01' AND price > 10_000_000
ORDER BY date LIMIT -10;

And then weโ€™ll see the following results:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€dateโ”€โ”ฌโ”€โ”€โ”€โ”€priceโ”€โ”ฌโ”€countyโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€districtโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 2025-02-07 โ”‚ 29240000 โ”‚ GREATER LONDON            โ”‚ MERTON                    โ”‚
โ”‚ 2025-02-07 โ”‚ 75960000 โ”‚ WARRINGTON                โ”‚ WARRINGTON                โ”‚
โ”‚ 2025-02-13 โ”‚ 13950000 โ”‚ GREATER LONDON            โ”‚ KENSINGTON AND CHELSEA    โ”‚
โ”‚ 2025-02-20 โ”‚ 16830000 โ”‚ GREATER LONDON            โ”‚ CITY OF WESTMINSTER       โ”‚
โ”‚ 2025-03-03 โ”‚ 12500000 โ”‚ ESSEX                     โ”‚ BASILDON                  โ”‚
โ”‚ 2025-03-04 โ”‚ 11000000 โ”‚ PORTSMOUTH                โ”‚ PORTSMOUTH                โ”‚
โ”‚ 2025-03-04 โ”‚ 18000000 โ”‚ GREATER LONDON            โ”‚ HAMMERSMITH AND FULHAM    โ”‚
โ”‚ 2025-03-06 โ”‚ 18375000 โ”‚ STOKE-ON-TRENT            โ”‚ STOKE-ON-TRENT            โ”‚
โ”‚ 2025-03-06 โ”‚ 10850000 โ”‚ HERTFORDSHIRE             โ”‚ HERTSMERE                 โ”‚
โ”‚ 2025-03-13 โ”‚ 12000000 โ”‚ CHESHIRE WEST AND CHESTER โ”‚ CHESHIRE WEST AND CHESTER โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

We can also provide a negative offset alongside a negative limit to paginate through results. To see the next 10 most recent sales sorted in ascending order by date, we can write the following query:

SELECT date, price, county, district
FROM uk.uk_price_paid
WHERE date >= '2024-01-01' AND price > 10_000_000
ORDER BY date LIMIT -10 OFFSET -10;
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€dateโ”€โ”ฌโ”€โ”€โ”€โ”€โ”€priceโ”€โ”ฌโ”€countyโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€districtโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 2025-01-21 โ”‚  10650000 โ”‚ NOTTINGHAMSHIRE โ”‚ ASHFIELD            โ”‚
โ”‚ 2025-01-21 โ”‚  22722671 โ”‚ GREATER LONDON  โ”‚ CITY OF WESTMINSTER โ”‚
โ”‚ 2025-01-22 โ”‚ 109500000 โ”‚ GREATER LONDON  โ”‚ CITY OF LONDON      โ”‚
โ”‚ 2025-01-24 โ”‚  11700000 โ”‚ THURROCK        โ”‚ THURROCK            โ”‚
โ”‚ 2025-01-25 โ”‚  75570000 โ”‚ GREATER LONDON  โ”‚ CITY OF WESTMINSTER โ”‚
โ”‚ 2025-01-29 โ”‚  12579711 โ”‚ SUFFOLK         โ”‚ MID SUFFOLK         โ”‚
โ”‚ 2025-01-31 โ”‚  29307333 โ”‚ GREATER LONDON  โ”‚ EALING              โ”‚
โ”‚ 2025-02-07 โ”‚  81850000 โ”‚ ESSEX           โ”‚ EPPING FOREST       โ”‚
โ”‚ 2025-02-07 โ”‚  24920000 โ”‚ GREATER LONDON  โ”‚ HARINGEY            โ”‚
โ”‚ 2025-02-07 โ”‚ 151420000 โ”‚ GREATER LONDON  โ”‚ MERTON              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

And if we wanted to get the next 10, weโ€™d change the last line of the query to say LIMIT -10 OFFSET -20, and so on.

Finally, ClickHouse now supports LIMIT BY ALL. Letโ€™s have a look at an example where we can use this clause. The following query returns information about residential properties sold for more than ยฃ10 million in Greater London:

SELECT town, district, type
FROM uk.uk_price_paid
WHERE county = 'GREATER LONDON' AND price > 10_000_000 AND type <> 'other'
ORDER BY price DESC
LIMIT 10;
โ”Œโ”€townโ”€โ”€โ”€โ”ฌโ”€districtโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€typeโ”€โ”€โ”€โ”€โ”€โ”
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ flat     โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ flat     โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ flat     โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ flat     โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ flat     โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ terraced โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ flat     โ”‚
โ”‚ LONDON โ”‚ KENSINGTON AND CHELSEA โ”‚ terraced โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ terraced โ”‚
โ”‚ LONDON โ”‚ KENSINGTON AND CHELSEA โ”‚ flat     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The City of Westminster has been returned many times, which makes sense, as itโ€™s a costly part of the city. Letโ€™s say we only want to return each combination of (town,district,type) once. We could do this using the LIMIT BY syntax:

SELECT town, district, type
FROM uk.uk_price_paid
WHERE county = 'GREATER LONDON' AND price > 10_000_000 AND type <> 'other'
ORDER BY price DESC
LIMIT 1 BY town, district, type
LIMIT 10;
โ”Œโ”€townโ”€โ”€โ”€โ”ฌโ”€districtโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€typeโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ flat          โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ terraced      โ”‚
โ”‚ LONDON โ”‚ KENSINGTON AND CHELSEA โ”‚ terraced      โ”‚
โ”‚ LONDON โ”‚ KENSINGTON AND CHELSEA โ”‚ flat          โ”‚
โ”‚ LONDON โ”‚ KENSINGTON AND CHELSEA โ”‚ detached      โ”‚
โ”‚ LONDON โ”‚ SOUTHWARK              โ”‚ flat          โ”‚
โ”‚ LONDON โ”‚ KENSINGTON AND CHELSEA โ”‚ semi-detached โ”‚
โ”‚ LONDON โ”‚ CITY OF WESTMINSTER    โ”‚ detached      โ”‚
โ”‚ LONDON โ”‚ CAMDEN                 โ”‚ detached      โ”‚
โ”‚ LONDON โ”‚ CITY OF LONDON         โ”‚ detached      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Alternatively, instead of having to list all field names after the LIMIT BY, we use LIMIT BY ALL:

SELECT town, district, type
FROM uk.uk_price_paid
WHERE county = 'GREATER LONDON' AND price > 10_000_000 AND type <> 'other'
ORDER BY price DESC
LIMIT 1 BY ALL
LIMIT 10;

And weโ€™ll get back the same set of records.

Arrow Flight server and client compatibility

Contributed by zakr600, Vitaly Baranov

In ClickHouse 25.8, we introduced the Arrow Flight integration, which made it possible to use ClickHouse as an Arrow Flight server or client.

The integration was initially quite rudimentary, but it has been developed over the last couple of months. As of ClickHouse 25.10, we can query the ClickHouse Arrow Flight server using the ClickHouse Arrow Flight client.

We can add a config file containing the following to our ClickHouse Server:

arrowflight_port: 6379
arrowflight:
  enable_ssl: false
  auth_required: false

Weโ€™ll then have an Arrow Flight Server running on port 6379. At the moment, you can only query the default database, but we can use the new alias table engine to work around this:

CREATE TABLE uk_price_paid
ENGINE = Alias(uk, uk_price_paid);

And then we can query that table using our Arrow client:

SELECT max(price), count()
FROM arrowflight('localhost:6379', 'uk_price_paid', 'default', '');
โ”Œโ”€max(price)โ”€โ”ฌโ”€โ”€count()โ”€โ”
โ”‚  900000000 โ”‚ 30452463 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Late materialization of secondary indices

Contributed by George Larionov

The 25.10 release also introduces settings that allow us to delay the materialization of secondary indices. We might want to do this if we have tables that contain indices that take a while to populate (e.g., the approximate vector search index).

Letโ€™s see how this works with help from some DBpedia embeddings. Weโ€™ll ingest them into the following table:

CREATE OR REPLACE TABLE dbpedia
(
  id      String,
  title   String,
  text    String,
  vector  Array(Float32) CODEC(NONE),
  INDEX vector_idx vector TYPE vector_similarity('hnsw', 'L2Distance', 1536)
) ENGINE = MergeTree
ORDER BY (id);

Weโ€™ll then download one Parquet file that contains around 40,000 embeddings:

wget https://huggingface.co/api/datasets/Qdrant/dbpedia-entities-openai3-text-embedding-3-large-1536-1M/parquet/default/train/0.parquet

Now letโ€™s insert those records into our table:

INSERT INTO dbpedia
SELECT `_id` AS id, title, text, 
       `text-embedding-3-large-1536-embedding` AS vector
FROM file('0.parquet');
0 rows in set. Elapsed: 6.161 sec. Processed 38.46 thousand rows, 367.26 MB (6.24 thousand rows/s., 59.61 MB/s.)
Peak memory usage: 932.41 MiB.

It takes just over 6 seconds to ingest the records, while also materializing the HNSW index.

Letโ€™s now create a copy of the dbpedia table:

create table dbpedia2 as dbpedia;

We can now choose to delay the point at which index materialization happens by configuring the following setting:

SET exclude_materialize_skip_indexes_on_insert='vector_idx';

If we repeat our earlier insert statement, but on dbpedia2:

INSERT INTO dbpedia2
SELECT `_id` AS id, title, text, 
       `text-embedding-3-large-1536-embedding` AS vector
FROM file('0.parquet');

We can see itโ€™s significantly quicker:

0 rows in set. Elapsed: 0.522 sec. Processed 38.46 thousand rows, 367.26 MB (73.68 thousand rows/s., 703.59 MB/s.)
Peak memory usage: 931.08 MiB.

We can see whether the index has been materialized by writing the following query:

SELECT table, data_compressed_bytes, data_uncompressed_bytes, marks_bytes FROM system.data_skipping_indices
WHERE name = 'vector_idx';
โ”Œโ”€tableโ”€โ”€โ”€โ”€โ”ฌโ”€data_compressed_bytesโ”€โ”ฌโ”€data_uncompressed_bytesโ”€โ”ฌโ”€marks_bytesโ”€โ”
โ”‚ dbpedia  โ”‚             124229003 โ”‚               128770836 โ”‚          50 โ”‚
โ”‚ dbpedia2 โ”‚                     0 โ”‚                       0 โ”‚           0 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

In dbpedia2, we can see that the number of bytes taken is 0, which is what weโ€™d expect. The index will be materialized in the background merge process, but if we want to make it happen immediately, we can run this query:

ALTER TABLE dbpedia2 MATERIALIZE INDEX vector_idx 
SETTINGS mutations_sync = 2;

Re-running the query against the data_skipping_indices table will return the following output:

โ”Œโ”€tableโ”€โ”€โ”€โ”€โ”ฌโ”€data_compressed_bytesโ”€โ”ฌโ”€data_uncompressed_bytesโ”€โ”ฌโ”€marks_bytesโ”€โ”
โ”‚ dbpedia  โ”‚             124229003 โ”‚               128770836 โ”‚          50 โ”‚
โ”‚ dbpedia2 โ”‚             124237137 โ”‚               128769912 โ”‚          50 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Alternatively, we can query the system.parts table if we want to see whether any indices have been materialized for a given part:

SELECT name, table, secondary_indices_compressed_bytes, secondary_indices_uncompressed_bytes, secondary_indices_marks_bytes 
FROM system.parts;
Row 1:
โ”€โ”€โ”€โ”€โ”€โ”€
name:                                 all_1_1_0
table:                                dbpedia
secondary_indices_compressed_bytes:   124229003 -- 124.23 million
secondary_indices_uncompressed_bytes: 128770836 -- 128.77 million
secondary_indices_marks_bytes:        50

Row 2:
โ”€โ”€โ”€โ”€โ”€โ”€
name:                                 all_1_1_0_2
table:                                dbpedia2
secondary_indices_compressed_bytes:   124237137 -- 124.24 million
secondary_indices_uncompressed_bytes: 128769912 -- 128.77 million
secondary_indices_marks_bytes:        50

We can even disable building indices during merges by using the following setting:

CREATE TABLE t (...)
SETTINGS materialize_skip_indexes_on_merge = false;

Or exclude certain (heavy) indices from calculation:

CREATE TABLE t (...)
SETTINGS exclude_materialize_skip_indexes_on_merge = 'vector_idx';

Share this post

  • Y Combinator icon
  • X icon
  • Bluesky icon
  • Facebook icon
  • LinkedIn icon

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!

Recent posts

Karolina Ruiz Rogelj and Melvyn Peignon ยท Jul 10, 2026
Luke Gannon ยท Jul 10, 2026