<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Maksim Grishchenko</title>
    <description>The latest articles on DEV Community by Maksim Grishchenko (@maksim-gr).</description>
    <link>https://dev.to/maksim-gr</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191549%2F0427cbe3-e3cc-42f1-86d5-89090f940590.jpg</url>
      <title>DEV Community: Maksim Grishchenko</title>
      <link>https://dev.to/maksim-gr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maksim-gr"/>
    <language>en</language>
    <item>
      <title>Generating Kafka ClickHouse pipelines from a JSON sample</title>
      <dc:creator>Maksim Grishchenko</dc:creator>
      <pubDate>Thu, 22 Oct 2026 20:12:32 +0000</pubDate>
      <link>https://dev.to/maksim-gr/why-i-am-scared-of-complex-nested-json-50bk</link>
      <guid>https://dev.to/maksim-gr/why-i-am-scared-of-complex-nested-json-50bk</guid>
      <description>&lt;p&gt;This post isn't about ClickHouse internals. It's about a part of my day-to-day work that kept eating my time. You want to build something great, and instead you sit there struggling to understand what is going on. We consume a lot of data straight from Kafka into ClickHouse, using ClickHouse's own Kafka consumer (the &lt;code&gt;Kafka&lt;/code&gt; table engine) and materialized views. Data is constantly flowing, and the message format keeps changing. Every new topic meant the same job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;find a message on the topic and check its fields,&lt;/li&gt;
&lt;li&gt;write several migrations that have to be applied in a particular order (we use &lt;a href="https://github.com/golang-migrate/migrate" rel="noopener noreferrer"&gt;golang-migrate&lt;/a&gt;),&lt;/li&gt;
&lt;li&gt;apply them and check that data shows up (not on prod, we have a separate environment for that).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a flat message with a few fields that's fine. When the message has nested objects or arrays, it gets slow and easy to get wrong. At some point I noticed how much time I was spending on it and started looking for a faster way.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Writing the Kafka → ClickHouse migrations by hand was the slow part, especially for nested messages. A shell script around &lt;code&gt;clickhouse local&lt;/code&gt; helped, but it needed a 663 MB binary and silently mixed up nested fields. clickforge generates the same five statements in milliseconds with no dependencies. It doesn't make changing a running flow any easier, which is still the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow: streams, raw, datalake
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Kafka&lt;/code&gt; table is a consumer, not storage. A plain &lt;code&gt;SELECT&lt;/code&gt; from it reads messages and moves the consumer group on, so each message can be read only once (&lt;a href="https://clickhouse.com/docs/engines/table-engines/integrations/kafka" rel="noopener noreferrer"&gt;docs&lt;/a&gt;). That's why nobody queries &lt;code&gt;streams&lt;/code&gt; directly. Once a materialized view is attached, ClickHouse reads the topic in the background and pushes each batch of messages through the view into the target table. We want to keep every message as it arrived, so the first view writes into &lt;code&gt;raw&lt;/code&gt;, and a second view on &lt;code&gt;raw&lt;/code&gt; fills the typed &lt;code&gt;datalake&lt;/code&gt; table.&lt;/p&gt;

&lt;p&gt;Each topic gets three tables and two materialized views:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kafka topic
    ↓
streams.video_events     Kafka engine table: reads the topic, stores nothing
    ↓  streams.video_events_mv
raw.video_events         the original message as a string, plus Kafka metadata
    ↓  raw.video_events_mv
datalake.video_events    typed columns, extracted from the message with JSONExtract
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The migration, layer by layer
&lt;/h3&gt;

&lt;p&gt;One topic is one up migration with five statements, run in this order. The SQL below is what clickforge generates for &lt;code&gt;video_events.json&lt;/code&gt;, a sample of video player events (the shell script produced the same five statements). I've cut the column lists down where they repeat.&lt;/p&gt;

&lt;p&gt;The first statement creates the consumer. It has a single &lt;code&gt;message&lt;/code&gt; column, and &lt;code&gt;kafka_format = 'RawBLOB'&lt;/code&gt; puts the whole Kafka message into it as one string, so nothing is parsed at this stage. &lt;code&gt;Kafka(kafka)&lt;/code&gt; takes the broker settings from a named collection called &lt;code&gt;kafka&lt;/code&gt;. I've replaced our internal topic and consumer group names with plain ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;`streams`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events`&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="n"&gt;clickhouse_datalake&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;`message`&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Kafka&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;kafka_topic_list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s1"&gt;'video_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kafka_group_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s1"&gt;'clickhouse-video_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kafka_format&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'RawBLOB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Credentials don't go into the migration. They live in the named collection, which is created once per cluster, either in the server config or with SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;NAMED&lt;/span&gt; &lt;span class="n"&gt;COLLECTION&lt;/span&gt; &lt;span class="n"&gt;kafka&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="n"&gt;kafka_broker_list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'kafka1:9092,kafka2:9092'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kafka_security_protocol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sasl_ssl'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kafka_sasl_mechanism&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SCRAM-SHA-512'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kafka_sasl_username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'clickhouse'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kafka_sasl_password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way the migrations can sit in git without passwords, and every topic table just says &lt;code&gt;Kafka(kafka)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;raw&lt;/code&gt;, a normal replicated table that stores the message unchanged, together with where it came from. &lt;code&gt;_key&lt;/code&gt;, &lt;code&gt;_offset&lt;/code&gt;, &lt;code&gt;_partition&lt;/code&gt;, &lt;code&gt;_timestamp_ms&lt;/code&gt; and &lt;code&gt;_topic&lt;/code&gt; are virtual columns of the &lt;code&gt;Kafka&lt;/code&gt; engine, and &lt;code&gt;_row_created&lt;/code&gt; is the time ClickHouse wrote the row. If the schema changes or the typed table turns out to be wrong, I can rebuild &lt;code&gt;datalake&lt;/code&gt; from &lt;code&gt;raw&lt;/code&gt; without reading the topic again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;`raw`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events`&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="n"&gt;clickhouse_datalake&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;`message`&lt;/span&gt;       &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`_key`&lt;/span&gt;          &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`_offset`&lt;/span&gt;       &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`_partition`&lt;/span&gt;    &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`_timestamp_ms`&lt;/span&gt; &lt;span class="n"&gt;DateTime64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`_topic`&lt;/span&gt;        &lt;span class="n"&gt;LowCardinality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`_row_created`&lt;/span&gt;  &lt;span class="nb"&gt;DateTime&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;nowInBlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReplicatedMergeTree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/clickhouse/{cluster}/tables/raw/video_events/{shard}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{replica}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;toYYYYMM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_row_created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;_row_created&lt;/span&gt;
    &lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;index_granularity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third is &lt;code&gt;datalake&lt;/code&gt;, the table people query. Each field of the message becomes a typed column, and the Kafka timestamp, topic and write time are carried over from &lt;code&gt;raw&lt;/code&gt;. It's partitioned by the month of the Kafka timestamp, so a message lands in the month of its Kafka timestamp, not the month it was loaded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;`datalake`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events`&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="n"&gt;clickhouse_datalake&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;`event_id`&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`user_id`&lt;/span&gt; &lt;span class="n"&gt;Int64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="nv"&gt;`client_ip`&lt;/span&gt; &lt;span class="k"&gt;Nullable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`country_code`&lt;/span&gt; &lt;span class="k"&gt;Nullable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`platform`&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;`_timestamp_ms`&lt;/span&gt; &lt;span class="n"&gt;DateTime64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`_topic`&lt;/span&gt;        &lt;span class="n"&gt;LowCardinality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;`_row_created`&lt;/span&gt;  &lt;span class="nb"&gt;DateTime&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;nowInBlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReplicatedMergeTree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/clickhouse/{cluster}/tables/datalake/video_events/{shard}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{replica}'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;toYYYYMM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_timestamp_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;_timestamp_ms&lt;/span&gt;
    &lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;index_granularity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth connects &lt;code&gt;raw&lt;/code&gt; to &lt;code&gt;datalake&lt;/code&gt;. A materialized view works like an insert trigger: every block of rows inserted into &lt;code&gt;raw&lt;/code&gt; goes through this &lt;code&gt;SELECT&lt;/code&gt;, and the result is inserted into &lt;code&gt;datalake&lt;/code&gt;. This is where the message gets parsed, one &lt;code&gt;JSONExtract&lt;/code&gt; per column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;`raw`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events_mv`&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="n"&gt;clickhouse_datalake&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="nv"&gt;`datalake`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events`&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;JSONExtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'event_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'String'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;`event_id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;JSONExtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Int64'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;`user_id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;JSONExtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'client_ip'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Nullable(String)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nv"&gt;`client_ip`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;`_timestamp_ms`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;`_topic`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;`_row_created`&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`raw`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events`&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;cast_keep_nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cast_keep_nullable = 1&lt;/code&gt; keeps &lt;code&gt;Nullable&lt;/code&gt; when a value is cast to another type. By default it's dropped: on 26.9, &lt;code&gt;CAST(toNullable(toInt32(0)) AS String)&lt;/code&gt; returns &lt;code&gt;String&lt;/code&gt;, and with the setting on it returns &lt;code&gt;Nullable(String)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The last statement connects the consumer to &lt;code&gt;raw&lt;/code&gt;, and the moment it exists, ClickHouse starts reading the topic. &lt;code&gt;_timestamp_ms&lt;/code&gt; is &lt;code&gt;Nullable&lt;/code&gt; on the &lt;code&gt;Kafka&lt;/code&gt; table, so &lt;code&gt;assumeNotNull&lt;/code&gt; turns it into the plain &lt;code&gt;DateTime64(3)&lt;/code&gt; that &lt;code&gt;raw&lt;/code&gt; expects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;`streams`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events_mv`&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="n"&gt;clickhouse_datalake&lt;/span&gt;
    &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="nv"&gt;`raw`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;`message`&lt;/span&gt;        &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;
        &lt;span class="nv"&gt;`_row_created`&lt;/span&gt;   &lt;span class="nb"&gt;DateTime&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;_offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;_partition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assumeNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_timestamp_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;_timestamp_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;_topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nowInBlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;_row_created&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`streams`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`video_events`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's why the order matters. A materialized view only sees rows inserted after it was created, and it doesn't go back for the ones already in the table. &lt;/p&gt;

&lt;p&gt;Changing a running flow is harder than creating it. To change the schema, I drop the &lt;code&gt;streams&lt;/code&gt; table and its view first so the consumer stops, change the tables, and create everything again in the right order. Get the order wrong, or forget to update a view, and the tables just stay empty. There's no error to tell you why, and you can't simply look at the topic from ClickHouse, because reading the &lt;code&gt;Kafka&lt;/code&gt; table consumes the messages. clickforge doesn't solve this. It only removes the bottleneck of writing the DDL.&lt;/p&gt;

&lt;h2&gt;
  
  
  First attempt: a shell script
&lt;/h2&gt;

&lt;p&gt;My first fix was a &lt;a href="https://github.com/Maksim-Gr/clickforge/blob/main/create_ddl_for_kafka.sh" rel="noopener noreferrer"&gt;shell script&lt;/a&gt;. It takes a JSON sample of the topic's messages and lets ClickHouse work out the schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./create_ddl_for_kafka.sh video_events.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside, it runs &lt;code&gt;clickhouse local&lt;/code&gt; to create a temporary table from the file with schema inference, prints &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt;, and then rewrites that output with &lt;code&gt;sed&lt;/code&gt; and &lt;code&gt;awk&lt;/code&gt; into the five statements. The column list goes into the &lt;code&gt;datalake&lt;/code&gt; table, and each column becomes a &lt;code&gt;JSONExtract(message, 'field', 'Type')&lt;/code&gt; line in the materialized view. It took a heavy session with an LLM to make it work, but I was serious, I wanted my time back.&lt;/p&gt;

&lt;p&gt;It worked, but it was never convenient. It needs a ClickHouse binary next to it (&lt;code&gt;./clickhouse.0&lt;/code&gt;, 663 MB on my laptop), and every column comes out &lt;code&gt;Nullable&lt;/code&gt;, because the script turns on &lt;code&gt;schema_inference_make_columns_nullable&lt;/code&gt;. A run took about six seconds. The engine is fixed to &lt;code&gt;ReplicatedMergeTree&lt;/code&gt;, so there's no way to pick another one, and the &lt;code&gt;awk&lt;/code&gt; step that rewrites types gets the brackets wrong once a type is nested.&lt;/p&gt;

&lt;p&gt;At work, a message can be a hundred lines of loosely structured, nested JSON, and it has to land in a flat, typed table. The bigger problem showed up when I ran it on a message with nested fields for this post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Berlin"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"campaign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fall"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;clickhouse local&lt;/code&gt; infers named tuples for the objects, for example &lt;code&gt;tags Tuple(campaign Nullable(String), source Nullable(String))&lt;/code&gt;. The &lt;code&gt;awk&lt;/code&gt; step strips everything in backticks from the type, so the names inside the tuple are lost and the view ends up with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;JSONExtract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Tuple( Nullable(String), Nullable(String))'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without names, &lt;code&gt;JSONExtract&lt;/code&gt; fills the tuple by position. ClickHouse sorted the inferred fields alphabetically (&lt;code&gt;campaign&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;), but the message has them the other way round. Here is what the generated extracts return for the message above on 26.9:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customer: ((NULL),NULL)
items:    [(NULL,'2')]
tags:     ('app','fall')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of &lt;code&gt;customer&lt;/code&gt; is lost, &lt;code&gt;qty&lt;/code&gt; landed in the &lt;code&gt;sku&lt;/code&gt; position, and &lt;code&gt;tags&lt;/code&gt; has &lt;code&gt;campaign = 'app'&lt;/code&gt; and &lt;code&gt;source = 'fall'&lt;/code&gt;. None of these extracts raises an error, so nothing tells you the columns are wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  clickforge
&lt;/h2&gt;

&lt;p&gt;So I wrote a CLI, &lt;a href="https://github.com/Maksim-Gr/clickforge" rel="noopener noreferrer"&gt;clickforge&lt;/a&gt;. Kind of a weird name, but I like it. It's a single binary with no ClickHouse dependency, and it generates the same flow from the same JSON sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;clickforge kafka video_events.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;13 columns inferred from video_events

  event_id              String
  user_id               Int64
  ...
  client_ip             String
  country_code          String
  platform              String

Written: ./video_events_up.sql
Written: ./video_events_down.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That took 7 ms. &lt;code&gt;video_events_up.sql&lt;/code&gt; holds the five statements in the order above, and &lt;code&gt;video_events_down.sql&lt;/code&gt; drops them again. A field is &lt;code&gt;Nullable&lt;/code&gt; only if it's missing or &lt;code&gt;null&lt;/code&gt; in some message, so in this sample only &lt;code&gt;client_ip&lt;/code&gt; and &lt;code&gt;country_code&lt;/code&gt; are &lt;code&gt;Nullable&lt;/code&gt;, because the last event has neither.&lt;/p&gt;

&lt;p&gt;For the nested message, clickforge keeps nested objects as JSON text and turns flat objects into a &lt;code&gt;Map&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customer: {"id":7,"address":{"city":"Berlin"}}
items:    [{'sku':'A1','qty':'2'}]
tags:     {'source':'app','campaign':'fall'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is lost or swapped, but it isn't fully typed either. &lt;code&gt;qty&lt;/code&gt; is the string &lt;code&gt;'2'&lt;/code&gt;, and &lt;code&gt;customer&lt;/code&gt; needs &lt;code&gt;JSONExtract&lt;/code&gt; at query time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing words
&lt;/h2&gt;

&lt;p&gt;clickforge is a small CLI, and it doesn't solve everything. Changing a running flow is still manual and still easy to get wrong. What it gives me is the thing I was missing: a fast, correct scaffold for a new topic, so I can apply the migrations and see data in the tables a few seconds later. Not a bad day if it saves me a couple of hours of work. I'm still working on the CLI and trying to make it better.&lt;/p&gt;

&lt;p&gt;If you want to try it yourself, the JSON samples and the &lt;code&gt;JSONExtract&lt;/code&gt; checks are next to this post: &lt;a href="https://maksim-gr.github.io/posts/clickforge-kafka-pipeline/video_events.json" rel="noopener noreferrer"&gt;video_events.json&lt;/a&gt;, &lt;a href="https://maksim-gr.github.io/posts/clickforge-kafka-pipeline/nested.json" rel="noopener noreferrer"&gt;nested.json&lt;/a&gt;, &lt;a href="https://maksim-gr.github.io/posts/clickforge-kafka-pipeline/extract-check.sql" rel="noopener noreferrer"&gt;extract-check.sql&lt;/a&gt; for the nested test, and &lt;a href="https://maksim-gr.github.io/posts/clickforge-kafka-pipeline/flow-check.sql" rel="noopener noreferrer"&gt;flow-check.sql&lt;/a&gt; for the &lt;code&gt;raw&lt;/code&gt; → &lt;code&gt;datalake&lt;/code&gt; layers.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>dataengineering</category>
      <category>kafka</category>
      <category>rust</category>
    </item>
    <item>
      <title>When ClickHouse merges change your query results</title>
      <dc:creator>Maksim Grishchenko</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:16:58 +0000</pubDate>
      <link>https://dev.to/maksim-gr/when-clickhouse-merges-change-your-query-results-5d99</link>
      <guid>https://dev.to/maksim-gr/when-clickhouse-merges-change-your-query-results-5d99</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/maksim-gr/follow-one-row-what-clickhouse-does-with-your-insert-g83"&gt;Part 1&lt;/a&gt; I followed an order from INSERT through a merge. That answered two questions that came up during coffee breaks at work. This post tackles the third:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why doesn't my &lt;code&gt;SELECT&lt;/code&gt; show the values I expect?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I usually hear this question from people using &lt;code&gt;SummingMergeTree&lt;/code&gt;, &lt;code&gt;AggregatingMergeTree&lt;/code&gt;, or &lt;code&gt;CollapsingMergeTree&lt;/code&gt;. In these engines, a merge can change the rows themselves: it sums them, collapses them, or removes them. As a result, the same query can return different results depending on how many merges have occurred.&lt;/p&gt;

&lt;p&gt;If you skipped Part 1, three things from it matter here. Each inserted block typically creates one or more new parts, and ClickHouse never modifies a part in place; a merge writes a new, larger part and removes the old ones later. Merges run in the background whenever the merge selector decides they're worthwhile, so you can't predict when two rows will end up in the same part. And merges never cross partition boundaries.&lt;/p&gt;

&lt;p&gt;Everything here ran on ClickHouse 26.9.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. When merges change the answer
&lt;/h2&gt;

&lt;p&gt;In Part 1, &lt;code&gt;ReplacingMergeTree&lt;/code&gt; kept the latest version of each order. It's one of several engines that apply special handling when rows with the same sorting key meet. &lt;code&gt;ORDER BY&lt;/code&gt; determines which rows belong together; the engine determines what happens to them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Rows with the same &lt;code&gt;ORDER BY&lt;/code&gt; key become…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ReplacingMergeTree(ver)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the row with the highest &lt;code&gt;ver&lt;/code&gt; (or the last inserted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SummingMergeTree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one row with numeric columns summed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AggregatingMergeTree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one row with aggregate states merged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CollapsingMergeTree(sign)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing, if a &lt;code&gt;+1&lt;/code&gt; row is cancelled by a &lt;code&gt;-1&lt;/code&gt; row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VersionedCollapsingMergeTree(sign, ver)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same, matched by version, in any order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CoalescingMergeTree&lt;/code&gt; (25.6+)&lt;/td&gt;
&lt;td&gt;one row with the latest non-NULL value of each column&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important question is &lt;em&gt;when&lt;/em&gt; that transformation happens. The engine's merge logic is applied at three points, using the same algorithm classes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On insert, within a single insert block. This is why newly inserted parts for these engines start at level 1. &lt;code&gt;MergeTreeDataWriter::mergeBlock&lt;/code&gt; selects the algorithm (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Storages/MergeTree/MergeTreeDataWriter.cpp#L596-L660" rel="noopener noreferrer"&gt;source&lt;/a&gt;):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;   &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merging_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;{&lt;/span&gt;
       &lt;span class="c1"&gt;/// There is nothing to merge in single block in ordinary MergeTree&lt;/span&gt;
       &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;MergeTreeData&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MergingParams&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Ordinary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
       &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;MergeTreeData&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MergingParams&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Replacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;make_shared&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReplacingSortedAlgorithm&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
       &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;MergeTreeData&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MergingParams&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Collapsing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;make_shared&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CollapsingSortedAlgorithm&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
       &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;MergeTreeData&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MergingParams&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Summing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;make_shared&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SummingSortedAlgorithm&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
       &lt;span class="c1"&gt;// ... Aggregating, VersionedCollapsing, Graphite, Coalescing&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;During a background merge, for the selected parts within a single partition (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Storages/MergeTree/MergeTask.cpp#L2929" rel="noopener noreferrer"&gt;&lt;code&gt;MergeTask.cpp&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;At query time, when &lt;code&gt;FINAL&lt;/code&gt; is used, for the parts read by the query (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Processors/QueryPlan/MergeTreeFinalMerge.cpp#L63" rel="noopener noreferrer"&gt;&lt;code&gt;MergeTreeFinalMerge.cpp&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Between these stages, rows with the same key can remain in separate parts, unprocessed. A plain &lt;code&gt;SELECT&lt;/code&gt; sees whatever state the background merges have reached so far. Here's what that means for each engine. In the &lt;code&gt;SummingMergeTree&lt;/code&gt; and &lt;code&gt;CollapsingMergeTree&lt;/code&gt; examples, each INSERT starts its own part, and I then force a full merge with &lt;code&gt;OPTIMIZE TABLE ... FINAL&lt;/code&gt;. The &lt;code&gt;AggregatingMergeTree&lt;/code&gt; example uses batches instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  SummingMergeTree: zero rows disappear
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SummingMergeTree&lt;/code&gt; is a table of counters. The &lt;a href="https://clickhouse.com/docs/engines/table-engines/mergetree-family/summingmergetree" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt; describes it as follows:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The difference is that when merging data parts for &lt;code&gt;SummingMergeTree&lt;/code&gt; tables ClickHouse replaces all the rows with the same primary key (or more accurately, with the same sorting key) with one row which contains summed values for the columns with the numeric data type.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of keeping one row per event, the table combines values for each key as parts merge. A typical use case is daily statistics, such as page views per page per day, often populated by a materialized view. The documentation also recommends retaining the raw events in a plain &lt;code&gt;MergeTree&lt;/code&gt; table alongside it, so a mistake in the sorting key doesn't result in data loss.&lt;/p&gt;

&lt;p&gt;Here's the table I played with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt; &lt;span class="n"&gt;Int64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SummingMergeTree&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ORDER BY (day, page)&lt;/code&gt; defines the sorting key: one counter per page per day.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;views&lt;/code&gt; is numeric, so merges sum it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;title&lt;/code&gt; is a string, so it can't be summed. It's included to show what happens to non-numeric columns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I stopped merges and ran four &lt;code&gt;INSERT&lt;/code&gt; statements. The last one is a correction that cancels out the views for &lt;code&gt;/promo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="n"&gt;STOP&lt;/span&gt; &lt;span class="n"&gt;MERGES&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/home'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Home'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/home'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Home (new title)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/promo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Promo'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-10'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/promo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Promo'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- a correction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the merge, each INSERT is its own part, and nothing has been summed yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;_part&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─_part─────┬─page───┬─views─┬─title────────────┐
│ all_1_1_1 │ /home  │    10 │ Home             │
│ all_2_2_1 │ /home  │     5 │ Home (new title) │
│ all_3_3_1 │ /promo │     7 │ Promo            │
│ all_4_4_1 │ /promo │    -7 │ Promo            │
└───────────┴────────┴───────┴──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plain &lt;code&gt;SELECT&lt;/code&gt; doesn't sum anything, either. It returns all four rows, with two rows per page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────day─┬─page───┬─views─┬─title────────────┐
│ 2026-10-10 │ /home  │     5 │ Home (new title) │
│ 2026-10-10 │ /home  │    10 │ Home             │
│ 2026-10-10 │ /promo │    -7 │ Promo            │
│ 2026-10-10 │ /promo │     7 │ Promo            │
└────────────┴────────┴───────┴──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To get the totals, the query has to sum them itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─page───┬─total─┐
│ /home  │    15 │
│ /promo │     0 │
└────────┴───────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, I switched merges back on and forced a merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;START&lt;/span&gt; &lt;span class="n"&gt;MERGES&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;OPTIMIZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;FINAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;page_stats&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;_part&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─_part─────┬─page──┬─views─┬─title─┐
│ all_1_4_2 │ /home │    15 │ Home  │
└───────────┴───────┴───────┴───────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The four parts became one part, &lt;code&gt;all_1_4_2&lt;/code&gt;: blocks 1 through 4, merged at level 2. The two &lt;code&gt;/home&lt;/code&gt; rows became a single row with 15 views, while the &lt;code&gt;/promo&lt;/code&gt; rows disappeared.&lt;/p&gt;

&lt;p&gt;Both behaviors are documented in the algorithm's header (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Processors/Merges/Algorithms/SummingSortedAlgorithm.h#L11-L15" rel="noopener noreferrer"&gt;source&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Merges several sorted inputs into one.
  * For each group of consecutive identical values of the primary key (the columns by which the data is sorted),
  *  collapses them into one row, summing all the numeric columns except the primary key.
  * If in all numeric columns, except for the primary key, the result is zero, it deletes the row.
  */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first thing that bit me is the obvious one: a key whose numeric values all sum to 0 can disappear. The &lt;code&gt;/promo&lt;/code&gt; views summed to 0, so the merge deleted the row. For page-view counts that's fine, but if the table stores account balances, an account with a zero balance quietly vanishes from it.&lt;/p&gt;

&lt;p&gt;The second one I didn't expect at all. I assumed that a query which sums the rows itself is safe no matter what the merges do, and it mostly is, but not about which rows come back. Before the merge, the &lt;code&gt;GROUP BY&lt;/code&gt; query above returned &lt;code&gt;/promo&lt;/code&gt; with a total of 0; after the merge, the same query returns only &lt;code&gt;/home&lt;/code&gt;. To keep the output the same either way, add &lt;code&gt;HAVING sum(views) != 0&lt;/code&gt;, which hides zero totals before a merge too. (My first version of that query failed with &lt;code&gt;ILLEGAL_AGGREGATION&lt;/code&gt;, because I'd named the result &lt;code&gt;views&lt;/code&gt;, the same as the column, and ClickHouse then read &lt;code&gt;sum(views)&lt;/code&gt; in &lt;code&gt;HAVING&lt;/code&gt; as a sum of the sum. Hence &lt;code&gt;total&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;The third is the &lt;code&gt;title&lt;/code&gt; column, which kept &lt;code&gt;'Home'&lt;/code&gt; and threw away &lt;code&gt;'Home (new title)'&lt;/code&gt;. &lt;br&gt;
Non-numeric columns outside the sorting key aren't summed, and the merge keeps the value from the first row, which is the row from the part with the lowest block number. Insert the two rows in reverse order and &lt;code&gt;'Home (new title)'&lt;/code&gt; wins instead. (Thanks to a reader in the comments for suggesting that test.) The docs call this value arbitrary, and with concurrent inserts "first" means first to commit, not first sent, so I wouldn't rely on it. If you care which title you get, store an &lt;code&gt;updated_at&lt;/code&gt; timestamp with each row and query &lt;code&gt;argMax(title, updated_at)&lt;/code&gt;: the newest title wins, however the parts were merged.&lt;/p&gt;
&lt;h3&gt;
  
  
  AggregatingMergeTree: a plain column changes its value after a merge
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;day&lt;/span&gt;       &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cnt_plain&lt;/span&gt; &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                &lt;span class="c1"&gt;-- the mistake&lt;/span&gt;
    &lt;span class="n"&gt;cnt&lt;/span&gt;       &lt;span class="n"&gt;SimpleAggregateFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;-- the fix&lt;/span&gt;
    &lt;span class="n"&gt;users&lt;/span&gt;     &lt;span class="n"&gt;AggregateFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uniq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AggregatingMergeTree&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is where 26.9 surprised me: the &lt;code&gt;CREATE TABLE&lt;/code&gt; statement fails before I can even insert anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Column(s) cnt_plain of the AggregatingMergeTree table are neither part of the sorting key nor
aggregate measures (AggregateFunction or SimpleAggregateFunction). During background merges, rows
with the same value of the sorting key are collapsed into one, so these columns keep an arbitrary
value, which silently produces wrong results ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check was added in 26.7, and older versions accepted this schema without a word. I only found out because my experiment script, written the old way, failed on 26.9. To show what the check prevents, I recreated the table with &lt;code&gt;SETTINGS allow_dimensions_outside_sorting_key = 1&lt;/code&gt; (the old behavior) and inserted two batches containing 10 and 5 events:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Before merge&lt;/th&gt;
&lt;th&gt;After merge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sum(cnt_plain)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sum(cnt)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uniqMerge(users)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the same query gives 15 before the merge and 10 after it. &lt;code&gt;cnt_plain&lt;/code&gt; retains the first row's value, as the algorithm's header explains (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Processors/Merges/Algorithms/AggregatingSortedAlgorithm.h#L14-L19" rel="noopener noreferrer"&gt;source&lt;/a&gt;): &lt;em&gt;"For columns that are not part of the primary key and which do not have the AggregateFunction type, when merged, the first value is selected."&lt;/em&gt; &lt;code&gt;SimpleAggregateFunction(sum, ...)&lt;/code&gt; and &lt;code&gt;AggregateFunction(...)&lt;/code&gt; columns combine correctly across merge states.&lt;/p&gt;

&lt;p&gt;The same principle applies when querying: always re-aggregate with functions such as &lt;code&gt;sum(cnt)&lt;/code&gt; and &lt;code&gt;uniqMerge(users)&lt;/code&gt;, grouped by the sorting key. Some rows may already have been merged while others haven't; re-aggregation makes the result independent of that state.&lt;/p&gt;

&lt;h3&gt;
  
  
  CollapsingMergeTree: the order of parts matters
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CollapsingMergeTree&lt;/code&gt; models state changes as a log. To update a row, insert a copy of the old state with &lt;code&gt;sign = -1&lt;/code&gt; to cancel it, followed by the new state with &lt;code&gt;sign = 1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;user_state&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_views&lt;/span&gt; &lt;span class="n"&gt;UInt32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sign&lt;/span&gt; &lt;span class="n"&gt;Int8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CollapsingMergeTree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- user 1: state, then cancel + new state (the documented pattern)&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_state&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_state&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- user 2: the cancel row arrives BEFORE the state it cancels&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_state&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;user_state&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;OPTIMIZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;user_state&lt;/span&gt; &lt;span class="k"&gt;FINAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─_part─────┬─user_id─┬─page_views─┬─sign─┐
│ all_1_4_2 │       1 │          6 │    1 │
│ all_1_4_2 │       2 │          5 │   -1 │
│ all_1_4_2 │       2 │          5 │    1 │
└───────────┴─────────┴────────────┴──────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;User 1 collapsed as expected. User 2's pair survived a full merge. The reason is explained in the algorithm's rules (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Processors/Merges/Algorithms/CollapsingSortedAlgorithm.h#L14-L25" rel="noopener noreferrer"&gt;source&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** ...
  * If the number of positive and negative rows is the same, and the last row is positive, then the first negative and last positive rows are written.
  * If the number of positive and negative rows is the same, and the last line is negative, it writes nothing.
  * ...
  */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within each sorting key, rows are processed in part order (by block number). In this example, user 2's &lt;code&gt;-1&lt;/code&gt; part came first, so the group ended with a positive row and both rows survived. With parallel writers, retries, or Kafka partitions, cancellation rows can arrive before the states they cancel.&lt;/p&gt;

&lt;p&gt;You can keep the query correct in two ways. The first is to let &lt;code&gt;sign&lt;/code&gt; do the work in the query, which gives the right answer whether or not the rows have collapsed yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toInt64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page_views&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;page_views&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_state&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns only user 1, with 6 page views, because user 2's rows cancel each other out in the sums.&lt;/p&gt;

&lt;p&gt;The second is to switch to &lt;code&gt;VersionedCollapsingMergeTree(sign, version)&lt;/code&gt;, which I'd honestly prefer whenever the writers aren't strictly ordered. It matches a &lt;code&gt;-1&lt;/code&gt; row with a &lt;code&gt;+1&lt;/code&gt; row of the same version, regardless of their order (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Processors/Merges/Algorithms/VersionedCollapsingAlgorithm.h#L11-L15" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Repeating the out-of-order inserts for user 2 with &lt;code&gt;version = 1&lt;/code&gt; on both rows left no rows after &lt;code&gt;OPTIMIZE TABLE ... FINAL&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule for all of them
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Write every query so it returns the same result whether no merges, some merges, or all possible merges have occurred.&lt;/strong&gt; In practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Query pattern that's always correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FINAL&lt;/code&gt;, or &lt;code&gt;argMax(col, ver) ... GROUP BY key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SummingMergeTree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sum(a), sum(b) ... GROUP BY key HAVING sum(a) != 0 OR sum(b) != 0&lt;/code&gt; (a row is deleted only when all its numeric columns sum to 0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AggregatingMergeTree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;-Merge&lt;/code&gt; combinators and &lt;code&gt;sum&lt;/code&gt;/&lt;code&gt;max&lt;/code&gt;/… for &lt;code&gt;SimpleAggregateFunction&lt;/code&gt;, &lt;code&gt;GROUP BY key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CollapsingMergeTree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sum(col * sign) ... GROUP BY key HAVING sum(sign) &amp;gt; 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VersionedCollapsingMergeTree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same as Collapsing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Remember the limits: merges never cross partition boundaries, and they run only when the merge selector decides they're worthwhile. Treat merges as a storage optimization, and make your query results independent of merge state.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Deleting and updating in place
&lt;/h2&gt;

&lt;p&gt;Parts are immutable, so how do &lt;code&gt;DELETE&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; work? To demonstrate the effect on a realistically sized part, I used a &lt;code&gt;MergeTree&lt;/code&gt; table containing one million orders in a single part, &lt;code&gt;all_1_1_1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lightweight &lt;code&gt;DELETE&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders_mt&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─name────────┬────rows─┬─size─────┐
│ all_1_1_1_2 │ 1000000 │ 3.94 MiB │
└─────────────┴─────────┴──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;count()&lt;/code&gt; now returns 999,990, but the part still physically contains 1,000,000 rows. The part name gained a fifth field, &lt;code&gt;_2&lt;/code&gt;, which is the &lt;em&gt;mutation version&lt;/em&gt;. &lt;code&gt;system.mutations&lt;/code&gt; shows the operation that ran: &lt;code&gt;UPDATE _row_exists = 0 WHERE order_id &amp;lt; 10&lt;/code&gt;. A lightweight &lt;code&gt;DELETE&lt;/code&gt; marks rows in the hidden &lt;code&gt;_row_exists&lt;/code&gt; column but leaves their data in place. The rows are physically removed during later merges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ALTER TABLE ... DELETE&lt;/code&gt;&lt;/strong&gt; (a classic mutation):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders_mt&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;mutations_sync&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─name────────┬───rows─┬─size─────┐
│ all_1_1_1_3 │ 999980 │ 3.94 MiB │
└─────────────┴────────┴──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part was rewritten, and 999,980 rows are now physically present. On a real table, this rewrites every affected part and can generate substantial I/O.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lightweight &lt;code&gt;UPDATE&lt;/code&gt;&lt;/strong&gt; (requires &lt;code&gt;enable_block_number_column = 1&lt;/code&gt; and &lt;code&gt;enable_block_offset_column = 1&lt;/code&gt; on the table):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders_mt&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─name───────────────────────────────────────────────┬───rows─┬─size─────┐
│ all_1_1_1_3                                        │ 999980 │ 3.94 MiB │
│ patch-ca8f64b62d9505701c71c81624162ef0-all_5_5_0_4 │      1 │ 1.05 KiB │
└────────────────────────────────────────────────────┴────────┴──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of rewriting a 4 MiB part to change one value, ClickHouse wrote a 1 KiB patch part containing one row. Reads apply the patch on the fly, and later merges fold it into the main part.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;What it writes&lt;/th&gt;
&lt;th&gt;Data physically removed or changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE FROM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A row mask (&lt;code&gt;_row_exists&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;On later merges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ALTER ... DELETE&lt;/code&gt; / &lt;code&gt;UPDATE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Full rewrite of affected parts&lt;/td&gt;
&lt;td&gt;When the mutation completes (asynchronously by default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UPDATE ... SET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A small patch part&lt;/td&gt;
&lt;td&gt;On later merges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER ... DROP PARTITION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes the partition's data parts&lt;/td&gt;
&lt;td&gt;Immediately at the table level; physical cleanup may follow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Back to the coffee break
&lt;/h2&gt;

&lt;p&gt;So, &lt;em&gt;"Why doesn't my &lt;code&gt;SELECT&lt;/code&gt; show what I expect?"&lt;/em&gt; In &lt;code&gt;SummingMergeTree&lt;/code&gt;, &lt;code&gt;AggregatingMergeTree&lt;/code&gt; and &lt;code&gt;CollapsingMergeTree&lt;/code&gt; tables, the raw rows depend on which merges have happened, so every query should give the same result whether no merges, some merges or all of them have run. In practice that means re-aggregating at query time.&lt;/p&gt;

&lt;p&gt;A few more things I'd tell a colleague. In an &lt;code&gt;AggregatingMergeTree&lt;/code&gt;, put every column either in &lt;code&gt;ORDER BY&lt;/code&gt; or into an aggregate (&lt;code&gt;SimpleAggregateFunction&lt;/code&gt; or &lt;code&gt;AggregateFunction&lt;/code&gt;); since 26.7 ClickHouse won't let you do anything else, which I think is a good call. In a &lt;code&gt;SummingMergeTree&lt;/code&gt;, remember that a row disappears on merge when all its numeric columns sum to zero, so think twice before storing anything where zero is a meaningful value, like balances. If cancel rows can arrive before the rows they cancel, use &lt;code&gt;VersionedCollapsingMergeTree&lt;/code&gt;. And for deletes, use lightweight &lt;code&gt;DELETE&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt; for targeted changes, &lt;code&gt;DROP PARTITION&lt;/code&gt; when a whole partition goes, and classic mutations only when you actually want the full rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;The official docs for &lt;a href="https://clickhouse.com/docs/engines/table-engines/mergetree-family/summingmergetree" rel="noopener noreferrer"&gt;&lt;code&gt;SummingMergeTree&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://clickhouse.com/docs/engines/table-engines/mergetree-family/aggregatingmergetree" rel="noopener noreferrer"&gt;&lt;code&gt;AggregatingMergeTree&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://clickhouse.com/docs/engines/table-engines/mergetree-family/collapsingmergetree" rel="noopener noreferrer"&gt;&lt;code&gt;CollapsingMergeTree&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://clickhouse.com/docs/engines/table-engines/mergetree-family/versionedcollapsingmergetree" rel="noopener noreferrer"&gt;&lt;code&gt;VersionedCollapsingMergeTree&lt;/code&gt;&lt;/a&gt; are worth reading after this, along with the pages on &lt;a href="https://clickhouse.com/docs/guides/developer/lightweight-delete" rel="noopener noreferrer"&gt;lightweight &lt;code&gt;DELETE&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://clickhouse.com/docs/sql-reference/statements/update" rel="noopener noreferrer"&gt;lightweight &lt;code&gt;UPDATE&lt;/code&gt;&lt;/a&gt;. And if you want the actual rules, they live in &lt;a href="https://github.com/ClickHouse/ClickHouse/tree/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Processors/Merges/Algorithms" rel="noopener noreferrer"&gt;&lt;code&gt;src/Processors/Merges/Algorithms/&lt;/code&gt;&lt;/a&gt;; the comments at the top of each file are surprisingly readable.&lt;/p&gt;

&lt;p&gt;All SQL used in these experiments is available in &lt;a href="https://maksim-gr.github.io/posts/clickhouse-merges-change-results/merges-change-results.sql" rel="noopener noreferrer"&gt;a single script&lt;/a&gt; that you can run against a fresh container.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>dataengineering</category>
      <category>sql</category>
    </item>
    <item>
      <title>Follow one row: what ClickHouse does with your INSERT</title>
      <dc:creator>Maksim Grishchenko</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:05:34 +0000</pubDate>
      <link>https://dev.to/maksim-gr/follow-one-row-what-clickhouse-does-with-your-insert-g83</link>
      <guid>https://dev.to/maksim-gr/follow-one-row-what-clickhouse-does-with-your-insert-g83</guid>
      <description>&lt;p&gt;I work as a data infrastructure engineer at a company where ClickHouse comes up at almost every coffee break. The questions I get are usually some version of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Why do I still see the old value? I'm using &lt;code&gt;ReplicatedReplacingMergeTree&lt;/code&gt;."&lt;/li&gt;
&lt;li&gt;"Why are we getting &lt;code&gt;Too many parts&lt;/code&gt; errors?"&lt;/li&gt;
&lt;li&gt;"Why doesn't my &lt;code&gt;SELECT&lt;/code&gt; show the values I expect?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At some point I noticed that all of them come from the same gap. The people asking had never seen the whole flow, from the moment a row is inserted to the moment it's merged, deduplicated or deleted.&lt;/p&gt;

&lt;p&gt;So one day I looked at our ClickHouse cluster and remembered the line from the &lt;em&gt;Inside Out&lt;/em&gt; trailer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do you ever look at someone and wonder: what is going on inside their head?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I decided to look inside ClickHouse's head.&lt;/p&gt;

&lt;p&gt;I knew the semantics. I'd read the official ClickHouse docs and plenty of blog posts. But something was missing, even for me. I decided it's better to touch it once than to hear about it a hundred times.&lt;/p&gt;

&lt;p&gt;I followed one order through its life in ClickHouse: insert, parts, merges, updates and duplicates. I captured real output at each step and looked up in the source code why it behaves that way. Where a single row wasn't enough to show something, I used a bigger batch or a separate test table, and I say so each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"Why do I still see the old value?"&lt;/em&gt; The new version sits in a different part that hasn't been merged with the old one yet, and merges are lazy. If the two versions are in different partitions, they will never be merged. Read with &lt;code&gt;FINAL&lt;/code&gt; or &lt;code&gt;argMax&lt;/code&gt;, and keep every version of a row in the same partition.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Why are we getting &lt;code&gt;Too many parts&lt;/code&gt;?"&lt;/em&gt; A non-empty INSERT normally creates at least one part per partition. Many small INSERTs, or a partition key that's too fine, create parts faster than merges can combine them. Batch on the client, or use async inserts when many clients write at once.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Why doesn't my &lt;code&gt;SELECT&lt;/code&gt; show the values I expect?"&lt;/em&gt; In &lt;code&gt;Summing&lt;/code&gt;, &lt;code&gt;Aggregating&lt;/code&gt; and &lt;code&gt;Collapsing&lt;/code&gt; tables, merges change the rows themselves, so re-aggregate in every query. &lt;a href="https://maksim-gr.github.io/posts/clickhouse-merges-change-results/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; covers this in detail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of the post shows where these answers come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparations
&lt;/h2&gt;

&lt;p&gt;Before we look inside ClickHouse, let's do some preparations. We'll use Docker for simplicity and one small table. Every query here is in &lt;a href="https://maksim-gr.github.io/posts/clickhouse-follow-one-row/follow-one-row.sql" rel="noopener noreferrer"&gt;one script&lt;/a&gt;, so you can check any claim yourself as you read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; ch &lt;span class="nt"&gt;--ulimit&lt;/span&gt; &lt;span class="nv"&gt;nofile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;262144:262144 clickhouse/clickhouse-server:26.9
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; ch clickhouse-client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything happens inside this container. The SQL runs in the &lt;code&gt;clickhouse-client&lt;/code&gt; session you just opened. Shell commands run through &lt;code&gt;docker exec ch ...&lt;/code&gt;, and paths like &lt;code&gt;/var/lib/clickhouse/...&lt;/code&gt; are paths inside the container, not on your machine.&lt;/p&gt;

&lt;p&gt;Everything here ran on ClickHouse 26.9, and several defaults changed recently, so on an older version some numbers will differ. I use the non-replicated engines to keep the setup to one container, but all of it applies to &lt;code&gt;ReplicatedReplacingMergeTree&lt;/code&gt; too. The only difference is who decides which parts to merge: in a replicated table, a leader replica writes that decision into the replication log in Keeper (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Storages/StorageReplicatedMergeTree.cpp#L4730" rel="noopener noreferrer"&gt;source&lt;/a&gt;), and every replica then runs the same merge on its own copy, or, for very large parts, downloads the result (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Storages/MergeTree/MergeFromLogEntryTask.cpp#L101-L112" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;   &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;     &lt;span class="n"&gt;LowCardinality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;     &lt;span class="nb"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;DateTime64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ReplacingMergeTree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;toYYYYMM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When it merges parts, &lt;code&gt;ReplacingMergeTree(updated_at)&lt;/code&gt; keeps the latest version of each &lt;code&gt;order_id&lt;/code&gt;, because &lt;code&gt;ORDER BY order_id&lt;/code&gt; defines which rows ClickHouse considers duplicates, while &lt;code&gt;updated_at&lt;/code&gt; defines which duplicate wins. This partition key is a mistake on purpose. We'll come back to it.&lt;/p&gt;

&lt;p&gt;To watch parts pile up, we stop background merges for now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="n"&gt;STOP&lt;/span&gt; &lt;span class="n"&gt;MERGES&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. An INSERT creates a part
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now64&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now64&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now64&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─name─────────┬─part_type─┬─rows─┬─level─┐
│ 202610_1_1_1 │ Compact   │    1 │     1 │
│ 202610_2_2_1 │ Compact   │    1 │     1 │
│ 202610_3_3_1 │ Compact   │    1 │     1 │
└──────────────┴───────────┴──────┴───────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three INSERTs gave me three parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a part is
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://clickhouse.com/docs/parts" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; describe it like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The data from each table in the ClickHouse MergeTree engine family is organized on disk as a collection of immutable &lt;code&gt;data parts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A data part is created whenever a set of rows is inserted into the table.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So a table is a set of parts, and every INSERT adds new ones. To see which part each row lives in, select the virtual column &lt;code&gt;_part&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─_part────────┬─order_id─┬─status──┐
│ 202610_1_1_1 │        1 │ created │
│ 202610_2_2_1 │        2 │ created │
│ 202610_3_3_1 │        3 │ created │
└──────────────┴──────────┴─────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each part is a directory on disk. &lt;code&gt;system.parts&lt;/code&gt; shows where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_on_disk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─name─────────┬─rows─┬─bytes_on_disk─┬─active─┬─path─────────────────────────────────────────────────────┐
│ 202610_1_1_1 │    1 │          1250 │      1 │ /var/lib/clickhouse/store/621/62173928-.../202610_1_1_1/ │
│ 202610_2_2_1 │    1 │          1250 │      1 │ /var/lib/clickhouse/store/621/62173928-.../202610_2_2_1/ │
│ 202610_3_3_1 │    1 │          1250 │      1 │ /var/lib/clickhouse/store/621/62173928-.../202610_3_3_1/ │
└──────────────┴──────┴───────────────┴────────┴──────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;active = 1&lt;/code&gt; means the part is currently part of the table. Here is what's inside one of these directories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker exec ch ls -l /var/lib/clickhouse/store/621/62173928-.../202610_1_1_1/
total 52
-rw-r----- 1 clickhouse clickhouse 379 Oct 11 12:45 checksums.txt
-rw-r----- 1 clickhouse clickhouse 254 Oct 11 12:45 columns_substreams.txt
-rw-r----- 1 clickhouse clickhouse 138 Oct 11 12:45 columns.txt
-rw-r----- 1 clickhouse clickhouse   1 Oct 11 12:45 count.txt
-rw-r----- 1 clickhouse clickhouse 161 Oct 11 12:45 data.bin
-rw-r----- 1 clickhouse clickhouse  72 Oct 11 12:45 data.cmrk4
-rw-r----- 1 clickhouse clickhouse  10 Oct 11 12:45 default_compression_codec.txt
-rw-r----- 1 clickhouse clickhouse   1 Oct 11 12:45 metadata_version.txt
-rw-r----- 1 clickhouse clickhouse  16 Oct 11 12:45 minmax_updated_at.idx
-rw-r----- 1 clickhouse clickhouse   4 Oct 11 12:45 partition.dat
-rw-r----- 1 clickhouse clickhouse  50 Oct 11 12:45 primary.cidx
-rw-r----- 1 clickhouse clickhouse 410 Oct 11 12:45 serialization.json
-rw-r----- 1 clickhouse clickhouse 536 Oct 11 12:45 statistics.packed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data.bin&lt;/code&gt; holds the values of all four columns, compressed. &lt;code&gt;primary.cidx&lt;/code&gt; is the primary index and &lt;code&gt;data.cmrk4&lt;/code&gt; the marks, which help ClickHouse find rows quickly, and the rest is metadata: row count, column types, the partition, the min and max &lt;code&gt;updated_at&lt;/code&gt;, and checksums of every file.&lt;/p&gt;

&lt;p&gt;How much disk does one order cost? My first answer was 1,250 bytes, the &lt;code&gt;bytes_on_disk&lt;/code&gt; value from &lt;code&gt;system.parts&lt;/code&gt;, and that's what went into the first draft. Then I added up the sizes from &lt;code&gt;ls -l&lt;/code&gt;, got 2,032, and went digging: &lt;code&gt;bytes_on_disk&lt;/code&gt; only counts the files listed in &lt;code&gt;checksums.txt&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Storages/MergeTree/MergeTreeDataPartChecksum.cpp#L99-L105" rel="noopener noreferrer"&gt;source&lt;/a&gt;), which leaves out a few small metadata files. The column data itself, &lt;code&gt;data.bin&lt;/code&gt;, is 161 bytes. And the filesystem doesn't care about any of these numbers, because on this container every file takes at least one 4 KiB block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;ch &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; /var/lib/clickhouse/store/621/62173928-.../202610_1_1_1/
56  /var/lib/clickhouse/store/621/62173928-.../202610_1_1_1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So one order took 161 bytes of data and 56 KiB of disk, which means a tiny part is almost all overhead.&lt;/p&gt;

&lt;p&gt;A big INSERT turns this around. Into a copy of the same table, I inserted one million orders with a single INSERT. They all fall in October, so they became one part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker exec ch ls -l /var/lib/clickhouse/store/a7a/a7a4bb8f-.../202610_1_1_1/
total 12600
-rw-r----- 1 clickhouse clickhouse 3787369 Oct 13 07:15 amount.bin
-rw-r----- 1 clickhouse clickhouse     410 Oct 13 07:15 amount.cmrk2
...
-rw-r----- 1 clickhouse clickhouse 4004915 Oct 13 07:15 order_id.bin
-rw-r----- 1 clickhouse clickhouse     415 Oct 13 07:15 order_id.cmrk2
...
-rw-r----- 1 clickhouse clickhouse    5120 Oct 13 07:15 status.bin
-rw-r----- 1 clickhouse clickhouse      59 Oct 13 07:15 status.dict.bin
...
-rw-r----- 1 clickhouse clickhouse 5019580 Oct 13 07:15 updated_at.bin
-rw-r----- 1 clickhouse clickhouse     429 Oct 13 07:15 updated_at.cmrk2

$ docker exec ch du -k /var/lib/clickhouse/store/a7a/a7a4bb8f-.../202610_1_1_1/
12604   /var/lib/clickhouse/store/a7a/a7a4bb8f-.../202610_1_1_1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This part is &lt;em&gt;Wide&lt;/em&gt;: each column has its own &lt;code&gt;.bin&lt;/code&gt; file and its own marks file, which is also why a query that reads one column of a wide table never touches the others. The small metadata files are still there, about the same size as in the one-row part.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;One order per INSERT&lt;/th&gt;
&lt;th&gt;One million orders in one INSERT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Part type&lt;/td&gt;
&lt;td&gt;Compact&lt;/td&gt;
&lt;td&gt;Wide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Column data (&lt;code&gt;.bin&lt;/code&gt; files)&lt;/td&gt;
&lt;td&gt;161 bytes&lt;/td&gt;
&lt;td&gt;12.2 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On disk (&lt;code&gt;du&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;56 KiB&lt;/td&gt;
&lt;td&gt;12.3 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share that is column data&lt;/td&gt;
&lt;td&gt;0.3%&lt;/td&gt;
&lt;td&gt;99.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk per order&lt;/td&gt;
&lt;td&gt;56 KiB&lt;/td&gt;
&lt;td&gt;about 13 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The overhead of a part is roughly fixed, so with a million rows behind it you stop noticing it: about 13 bytes per order. (My test data flatters this a bit, since &lt;code&gt;status&lt;/code&gt; cycles through three values and compresses to almost nothing, but the per-part overhead is the same either way.)&lt;/p&gt;

&lt;p&gt;So a part never changes: new data means a new part, and combining parts means writing a bigger one and deleting the old ones later. Each part is sorted by &lt;code&gt;ORDER BY&lt;/code&gt;, but only inside itself, so two parts can both hold the same &lt;code&gt;order_id&lt;/code&gt;, which is exactly how duplicates happen. Each part also belongs to one partition and carries its own index, marks and checksums, and a &lt;code&gt;SELECT&lt;/code&gt; reads every active part its indexes don't rule out.&lt;/p&gt;

&lt;p&gt;The part name &lt;code&gt;202610_1_1_1&lt;/code&gt; encodes its history. The &lt;a href="https://clickhouse.com/docs/operations/system-tables/parts" rel="noopener noreferrer"&gt;official docs for &lt;code&gt;system.parts&lt;/code&gt;&lt;/a&gt; explain the format:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The part naming format is the following:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;partition_id&amp;gt;_&amp;lt;minimum_block_number&amp;gt;_&amp;lt;maximum_block_number&amp;gt;_&amp;lt;level&amp;gt;_&amp;lt;data_version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;partition_id&lt;/code&gt; - identifies the partition key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;minimum_block_number&lt;/code&gt; - identifies the minimum block number in the part. ClickHouse always merges continuous blocks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maximum_block_number&lt;/code&gt; - identifies the maximum block number in the part&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;level&lt;/code&gt; - incremented by one with each additional merge on the part. A level of 0 indicates this is a new part that has not been merged. It is important to remember that all parts in ClickHouse are always immutable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data_version&lt;/code&gt; - optional value, incremented when a part is mutated (again, mutated data is always only written to a new part, since parts are immutable)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;So &lt;code&gt;202610_1_1_1&lt;/code&gt; is partition &lt;code&gt;202610&lt;/code&gt;, blocks 1 to 1, level 1. The level surprised me, since the docs say a new part starts at 0. It turns out &lt;code&gt;ReplacingMergeTree&lt;/code&gt; runs its merge logic on every inserted block before writing it, so its new parts count as merged once already. The block number raised even more questions, because "block" means three different things in ClickHouse.&lt;/p&gt;

&lt;p&gt;I followed both rabbit holes, together with everything else the server does inside one INSERT: splitting rows by partition, sorting, writing marks and the primary index, and the commit. That turned into a post of its own, &lt;a href="https://maksim-gr.github.io/posts/clickhouse-inside-one-insert/" rel="noopener noreferrer"&gt;Inside one INSERT&lt;/a&gt;, which is Part 3 of this series. You don't need it for the rest of this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Too many parts
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why are we getting &lt;code&gt;Too many parts&lt;/code&gt; errors?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every part costs files on disk, metadata in memory, and a future merge. If a partition piles up parts faster than merges can combine them, ClickHouse protects itself: at 1000 active parts in a partition (&lt;code&gt;parts_to_delay_insert&lt;/code&gt;) it starts slowing INSERTs down on purpose, and at 3000 (&lt;code&gt;parts_to_throw_insert&lt;/code&gt;) it rejects them with &lt;code&gt;Too many parts&lt;/code&gt;. Those are the current defaults. A lot of older posts still quote 150 and 300, which were the defaults before 23.6.&lt;/p&gt;

&lt;p&gt;The classic cause is many small INSERTs. To see it, I used a separate test table and sent it 50 single-row INSERTs concurrently, with merges stopped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 50&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;ch clickhouse-client &lt;span class="nt"&gt;--async_insert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"INSERT INTO ins_test (id) VALUES (&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &amp;amp;
&lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;async_insert = 0&lt;/code&gt;, the 50 rows became 50 parts. Running the same loop with &lt;code&gt;async_insert = 1&lt;/code&gt; gave 7 parts for the same 50 rows. With &lt;em&gt;asynchronous inserts&lt;/em&gt;, the server buffers incoming INSERTs and writes one part per flush. A flush happens after 200 ms (&lt;code&gt;async_insert_busy_timeout_max_ms&lt;/code&gt;) or 10 MiB (&lt;code&gt;async_insert_max_data_size&lt;/code&gt;). Since 26.2, &lt;code&gt;async_insert&lt;/code&gt; is on by default.&lt;/p&gt;

&lt;p&gt;Since async inserts are on by default now, I assumed they had already batched my three single-row INSERTs into &lt;code&gt;orders&lt;/code&gt;. They hadn't. With &lt;code&gt;wait_for_async_insert = 1&lt;/code&gt; (the safe default) each INSERT waits until its data is flushed, so a client that sends INSERTs one after another never shares a buffer with anyone. Async inserts batch &lt;em&gt;concurrent&lt;/em&gt; writers, like many app servers or Lambdas; a single sequential loop still has to batch on the client side.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Merges, and why nothing happened for six minutes
&lt;/h2&gt;

&lt;p&gt;Background merges combine adjacent parts within one partition into a bigger part, using a streaming k-way merge of already-sorted data. Let's turn them back on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;START&lt;/span&gt; &lt;span class="n"&gt;MERGES&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expected a merge within seconds. Nothing happened. Thirty seconds later the three parts were still there, and they stayed separate for the next six minutes while I wondered whether I'd forgotten something.&lt;/p&gt;

&lt;p&gt;The reason is in &lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Storages/MergeTree/Compaction/MergeSelectors/SimpleMergeSelector.h#L117" rel="noopener noreferrer"&gt;&lt;code&gt;SimpleMergeSelector.h&lt;/code&gt;&lt;/a&gt;: the selector prefers to merge about five parts of similar size at once (&lt;code&gt;base = 5&lt;/code&gt;), which keeps write amplification low, and relaxes that only gradually as parts get older or more numerous. Three tiny, fresh parts aren't worth it yet. That's the 26.9 behaviour, at least; the selector has its own table settings, and its rules change between releases.&lt;/p&gt;

&lt;p&gt;So I inserted three more orders. ClickHouse records every part event in &lt;code&gt;system.part_log&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;event_time_microseconds&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;merged_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;part_log&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────t─┬─event_type──────┬─part_name────┬─merged_from──────────────────────────────────────────┬─rows─┐
│ 2026-10-10 11:10:22.332555 │ NewPart         │ 202610_1_1_1 │ []                                                   │    1 │
│ 2026-10-10 11:10:22.489426 │ NewPart         │ 202610_2_2_1 │ []                                                   │    1 │
│ 2026-10-10 11:10:22.654394 │ NewPart         │ 202610_3_3_1 │ []                                                   │    1 │
│ 2026-10-10 11:16:09.673250 │ NewPart         │ 202610_4_4_1 │ []                                                   │    1 │
│ 2026-10-10 11:16:09.829828 │ NewPart         │ 202610_5_5_1 │ []                                                   │    1 │
│ 2026-10-10 11:16:09.830053 │ MergePartsStart │ 202610_1_5_2 │ ['202610_1_1_1','202610_2_2_1',...,'202610_5_5_1']   │    0 │
│ 2026-10-10 11:16:09.832240 │ MergeParts      │ 202610_1_5_2 │ ['202610_1_1_1','202610_2_2_1',...,'202610_5_5_1']   │    5 │
│ 2026-10-10 11:16:09.984342 │ NewPart         │ 202610_6_6_1 │ []                                                   │    1 │
└────────────────────────────┴─────────────────┴──────────────┴──────────────────────────────────────────────────────┴──────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three parts waited almost six minutes, but when the fifth part arrived, a merge of exactly five parts started 0.2 ms later. The result, &lt;code&gt;202610_1_5_2&lt;/code&gt;, covers blocks 1–5 at level 2, and the sixth part is left waiting for company.&lt;/p&gt;

&lt;p&gt;Merged-away parts stay on disk as &lt;code&gt;active = 0&lt;/code&gt; for another 8 minutes (&lt;code&gt;old_parts_lifetime&lt;/code&gt;), so queries already reading them can finish.&lt;/p&gt;

&lt;p&gt;What I take from this: merges are eventual, and you can't predict when, or even whether, two given rows end up in the same part. The six minutes and the five parts are just what I saw in one run, not numbers to plan around. And merges never cross partitions, which matters a lot in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Updating the order: duplicates and FINAL
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why do I still see the old value? I'm using &lt;code&gt;ReplicatedReplacingMergeTree&lt;/code&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Order 1 gets paid. In a &lt;code&gt;ReplacingMergeTree&lt;/code&gt;, an update is just another INSERT with a newer version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now64&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─order_id─┬─status──┬─amount─┬──────────────updated_at─┐
│        1 │ created │     10 │ 2026-10-10 11:10:22.330 │
│        1 │ paid    │     10 │ 2026-10-10 11:16:26.581 │
└──────────┴─────────┴────────┴─────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rows, because the old version is in one part and the new one in another, and nothing has merged them yet. You can read it correctly either way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Let ClickHouse apply the merge logic at read time&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;FINAL&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;-- paid&lt;/span&gt;

&lt;span class="c1"&gt;-- Or deduplicate yourself&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;argMax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;-- paid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FINAL&lt;/code&gt; has a reputation for being slow, but much of that advice is old: today it runs in parallel and skips parts that can't contain duplicates (that's where those level-1 parts help). Its cost still depends on the table layout and the query, so for anything performance-sensitive I'd measure it against &lt;code&gt;argMax&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The partition trap
&lt;/h3&gt;

&lt;p&gt;Now the order ships, and the shipment is recorded in November:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-11-02 09:00:00'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;OPTIMIZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;FINAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- force a merge in every partition&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;_partition_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─_partition_id─┬─_part────────┬─order_id─┬─status──┬──────────────updated_at─┐
│ 202610        │ 202610_1_7_3 │        1 │ paid    │ 2026-10-10 11:16:26.581 │
│ 202611        │ 202611_8_8_2 │        1 │ shipped │ 2026-11-02 09:00:00.000 │
└───────────────┴──────────────┴──────────┴─────────┴─────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even after a forced full merge the duplicate is still there, and it will stay there forever, because the two versions are in different partitions and merges never cross partitions. &lt;code&gt;FINAL&lt;/code&gt; still returns the right answer, because by default it deduplicates across partitions at read time, on every query. But turn on the popular optimization for &lt;code&gt;FINAL&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;FINAL&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;SETTINGS&lt;/span&gt; &lt;span class="n"&gt;do_not_merge_across_partitions_select_final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─order_id─┬─status──┬──────────────updated_at─┐
│        1 │ shipped │ 2026-11-02 09:00:00.000 │
│        1 │ paid    │ 2026-10-10 11:16:26.581 │
└──────────┴─────────┴─────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's two rows for one order, with no error and no warning. If I had to pick one thing from this whole post to remember, it would be this: in a &lt;code&gt;ReplacingMergeTree&lt;/code&gt;, every version of a row has to land in the same partition. Partition by something that never changes for a key, such as &lt;code&gt;toYYYYMM(created_at)&lt;/code&gt;, or don't partition at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the coffee break
&lt;/h2&gt;

&lt;p&gt;The short answers are in the TL;DR at the top. If I had to boil the rest down for a colleague: batch your INSERTs on the client, because async inserts only help with many concurrent writers. Don't panic about a few small parts, since merges are lazy on purpose, and &lt;code&gt;system.parts&lt;/code&gt; and &lt;code&gt;system.part_log&lt;/code&gt; will show you what's going on. Pick a partition key that never changes for a row, and read with &lt;code&gt;FINAL&lt;/code&gt; or &lt;code&gt;argMax&lt;/code&gt; instead of scheduling &lt;code&gt;OPTIMIZE ... FINAL&lt;/code&gt; to clean up duplicates. Merges save storage, but your queries have to be correct without them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;If you want to go further, Tom Schreiber's &lt;a href="https://clickhouse.com/blog/supercharge-your-clickhouse-data-loads-part1" rel="noopener noreferrer"&gt;Supercharging your large ClickHouse data loads&lt;/a&gt; is the best thing I found on insert block size and parallelism, and the docs have a page on &lt;a href="https://clickhouse.com/docs/tips-and-tricks/too-many-parts" rel="noopener noreferrer"&gt;the too many parts problem&lt;/a&gt;. For the internals, see &lt;a href="https://maksim-gr.github.io/posts/clickhouse-inside-one-insert/" rel="noopener noreferrer"&gt;Inside one INSERT&lt;/a&gt; (Part 3) and &lt;a href="https://github.com/ClickHouse/ClickHouse/blob/ca8dd5528a09eddfa1b0be2a987c0c8c77c7d5a7/src/Storages/MergeTree/Compaction/MergeSelectors/SimpleMergeSelector.h" rel="noopener noreferrer"&gt;&lt;code&gt;SimpleMergeSelector.h&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All the SQL from these experiments is in &lt;a href="https://maksim-gr.github.io/posts/clickhouse-follow-one-row/follow-one-row.sql" rel="noopener noreferrer"&gt;one script&lt;/a&gt; you can run against a fresh container.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>dataengineering</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
