<?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>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>
