<?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: Mohamed Hussain S</title>
    <description>The latest articles on DEV Community by Mohamed Hussain S (@mohhddhassan).</description>
    <link>https://dev.to/mohhddhassan</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%2F3228500%2Fc2e87a6e-70e3-4023-a3d3-5adddef072f2.jpeg</url>
      <title>DEV Community: Mohamed Hussain S</title>
      <link>https://dev.to/mohhddhassan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohhddhassan"/>
    <language>en</language>
    <item>
      <title>Understanding the Replication Queue in ClickHouse</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Mon, 07 Sep 2026 18:28:48 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/understanding-the-replication-queue-in-clickhouse-gl6</link>
      <guid>https://dev.to/mohhddhassan/understanding-the-replication-queue-in-clickhouse-gl6</guid>
      <description>&lt;p&gt;I was testing out &lt;a href="https://github.com/Quantrail-Data/CH-Ops" rel="noopener noreferrer"&gt;CH-Ops&lt;/a&gt; - an admin GUI for self-hosted ClickHouse - on a simple setup: 1 shard, 2 replicas. Stumbled onto the replication queue almost by accident.&lt;/p&gt;

&lt;p&gt;Here's what I did: I stopped one of the nodes (let's call it Node B), then inserted some data through the other one (Node A). Just wanted to see what would happen.&lt;/p&gt;

&lt;p&gt;Then, while Node B was still down, I checked it in CH-Ops. It had stuff sitting in its replication queue.&lt;/p&gt;

&lt;p&gt;My first assumption was: &lt;em&gt;okay, this must be showing what's left to replicate across the cluster - the total pending replication work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I switched over and checked Node A, the one that was actually up and had just received the insert.&lt;/p&gt;

&lt;p&gt;Its queue was empty.&lt;/p&gt;

&lt;p&gt;That didn't match what I expected at all. If the queue was a cluster-wide "here's what still needs to replicate" view, Node A should've shown something too - it was the one that had the fresh data now waiting to reach Node B. Instead it was Node B, the down one, sitting there with pending tasks.&lt;/p&gt;

&lt;p&gt;That mismatch is what sent me digging. Turns out the queue isn't cluster-wide at all - it's specific to each ClickHouse instance. Once I brought Node B back up, its queue drained in seconds and the data showed up.&lt;/p&gt;

&lt;p&gt;That whole experiment is basically the entire post in miniature. Here's the mental model I ended up with.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Queue Belongs to a Replica, Not to the Table
&lt;/h2&gt;

&lt;p&gt;This is the first thing to get straight.&lt;/p&gt;

&lt;p&gt;With a &lt;code&gt;ReplicatedMergeTree&lt;/code&gt; table, you can have multiple replicas holding copies of the same data. It's tempting to think of replication as one shared pipe between them.&lt;/p&gt;

&lt;p&gt;It isn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy03vakbayjdukhktaz8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyy03vakbayjdukhktaz8.png" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each replica keeps its &lt;strong&gt;own local replication queue&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So if you see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Replica 1 → queue_size = 0
Replica 2 → queue_size = 25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that doesn't mean 25 operations are waiting somewhere in the middle for both replicas to pick up.&lt;/p&gt;

&lt;p&gt;It means &lt;strong&gt;Replica 2, specifically, has 25 tasks it hasn't finished yet.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once that clicked for me, the rest of the system made a lot more sense.&lt;/p&gt;




&lt;h2&gt;
  
  
  So Where Do These Tasks Come From?
&lt;/h2&gt;

&lt;p&gt;Replication in ClickHouse isn't a direct copy from one replica to another. There's a coordination layer in between.&lt;/p&gt;

&lt;p&gt;ClickHouse Keeper holds the replication log - the record of operations that happened on the table. When something replicated happens (a part gets inserted, a merge runs, a mutation is applied), that operation gets logged in Keeper.&lt;/p&gt;

&lt;p&gt;Each replica watches that log and figures out: &lt;em&gt;what do I need to do to catch up to this?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That "what do I need to do" becomes a task, and the task lands in that replica's queue.&lt;/p&gt;

&lt;p&gt;Depending on what happened, a task might be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fetch a part&lt;/li&gt;
&lt;li&gt;merge parts&lt;/li&gt;
&lt;li&gt;apply a mutation&lt;/li&gt;
&lt;li&gt;drop a range&lt;/li&gt;
&lt;li&gt;run another replicated operation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the queue is really just a &lt;strong&gt;to-do list&lt;/strong&gt;, generated from the shared log, but executed locally.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbnz10xoeq03lrwxqcloh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbnz10xoeq03lrwxqcloh.png" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where You Actually See This: &lt;code&gt;system.replicas&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;CH-Ops was showing me a friendlier view of exactly this, but under the hood, this is the table doing the work. It's usually the first place people look, and for good reason - it gives you a quick health snapshot per replica.&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="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;replica_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_leader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_readonly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queue_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;inserts_in_queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;merges_in_queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;part_mutations_in_queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;absolute_delay&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;replicas&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─replica_name─┬─queue_size─┬─inserts_in_queue─┬─merges_in_queue─┬─absolute_delay─┐
│ replica_1    │ 0          │ 0                │ 0               │ 0              │
│ replica_2    │ 12         │ 3                │ 9               │ 4              │
└──────────────┴────────────┴──────────────────┴─────────────────┴────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;queue_size&lt;/code&gt; gives you the total. &lt;code&gt;inserts_in_queue&lt;/code&gt;, &lt;code&gt;merges_in_queue&lt;/code&gt;, and &lt;code&gt;part_mutations_in_queue&lt;/code&gt; break that total down by task type, which already tells you more than the single number does.&lt;/p&gt;

&lt;p&gt;But it still doesn't tell you &lt;em&gt;why&lt;/em&gt; those 12 tasks are sitting there. For that, you need the next table.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Detail View: &lt;code&gt;system.replication_queue&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;system.replicas&lt;/code&gt; tells you &lt;strong&gt;how much work is waiting&lt;/strong&gt;, &lt;code&gt;system.replication_queue&lt;/code&gt; tells you &lt;strong&gt;what that work actually is&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;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;replica_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;create_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;num_tries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;last_exception&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;replication_queue&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;create_time&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of a single number, you now get the actual list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET_PART
MERGE_PARTS
GET_PART
MUTATE_PART
GET_PART
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see the task type, how long it's been sitting there (&lt;code&gt;create_time&lt;/code&gt;), how many times it's been retried (&lt;code&gt;num_tries&lt;/code&gt;), and - this is the important one - &lt;code&gt;last_exception&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That single column is often the difference between "the queue is 12" and "I know exactly why the queue is 12."&lt;/p&gt;




&lt;h2&gt;
  
  
  Two Tables, Two Different Questions
&lt;/h2&gt;

&lt;p&gt;Once I had both queries in my toolkit, it became clear they answer different questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;system.replicas&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How much work is a replica behind on, right now?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;system.replication_queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What is that work, specifically, and is it stuck?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither one alone gives you the full picture. &lt;code&gt;system.replicas&lt;/code&gt; is a summary. &lt;code&gt;system.replication_queue&lt;/code&gt; is the detail behind that summary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Queue Size Is Not the Same as Replication Lag
&lt;/h2&gt;

&lt;p&gt;This one tripped me up early on, so it's worth calling out explicitly.&lt;/p&gt;

&lt;p&gt;A replication queue measures &lt;strong&gt;pending work&lt;/strong&gt;. Replication lag (&lt;code&gt;absolute_delay&lt;/code&gt;) measures &lt;strong&gt;how far behind a replica is&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;They're related, but they're not interchangeable.&lt;/p&gt;

&lt;p&gt;A replica can have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queue_size = 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and be processing those 50 tasks fast enough that lag barely moves. A few minutes later, the queue is empty and nothing was ever "behind" in any meaningful sense.&lt;/p&gt;

&lt;p&gt;A replica sitting at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queue_size = 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for hours, on the other hand, is a very different story - even though 5 sounds small next to 50.&lt;/p&gt;

&lt;p&gt;The number by itself doesn't tell you which situation you're in. That's the whole point of this post: &lt;strong&gt;the queue is a data source, not a verdict.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mental Model, Put Simply
&lt;/h2&gt;

&lt;p&gt;Here's the picture:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3ja7dxzv8meeskz8okz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3ja7dxzv8meeskz8okz.png" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;replica&lt;/strong&gt; is the participant - it holds data and does work.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;replication queue&lt;/strong&gt; is that participant's pending work, generated from what Keeper says has happened on the table.&lt;/p&gt;

&lt;p&gt;The queue exists &lt;em&gt;because&lt;/em&gt; the replica has catching up to do. That's it. It's not a fault indicator on its own - it's a reflection of activity and progress.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Sets You Up For
&lt;/h2&gt;

&lt;p&gt;Understanding the queue as "a to-do list, not a fault code" is step one.&lt;/p&gt;

&lt;p&gt;Step two - the part where you actually decide &lt;em&gt;when to worry&lt;/em&gt; - comes down to trend, task type, and &lt;code&gt;last_exception&lt;/code&gt;, and that's a big enough topic to deserve its own post.&lt;/p&gt;

&lt;p&gt;For now, the takeaway is simpler:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A non-zero replication queue isn't a problem statement. It's a starting point for one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Know what the queue is before you try to diagnose what it means.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>distributedsystems</category>
      <category>devops</category>
    </item>
    <item>
      <title>ClickHouse 26.8 LTS: 57 Breaking Changes Since 26.3</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Fri, 28 Aug 2026 09:33:52 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/clickhouse-268-lts-57-breaking-changes-since-263-3ba9</link>
      <guid>https://dev.to/mohhddhassan/clickhouse-268-lts-57-breaking-changes-since-263-3ba9</guid>
      <description>&lt;p&gt;If you run ClickHouse in production, you're probably on 26.3 LTS. And now 26.8 LTS has been announced, which means the LTS-to-LTS upgrade conversation starts again.&lt;/p&gt;

&lt;p&gt;Here's the thing most release posts skip: this is not a one-release hop.&lt;/p&gt;

&lt;p&gt;Going from 26.3 LTS to 26.8 LTS means crossing 26.4, 26.5, 26.6 and 26.7 as well. Every breaking change in those four releases applies to you, and some of the ones most likely to ruin your day aren't in 26.8 at all.&lt;/p&gt;

&lt;p&gt;So instead of writing another "here are the 26.8 features" post, I wanted to write the thing I'd actually want before scheduling this upgrade: what breaks, what silently changes, what order to do things in, and what you get for the trouble.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on release timing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;As of writing (27 August 2026), 26.8 has been announced but is not fully released yet.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The release branch is cut and versioned (v26.8.1.1-lts), but the tag and Docker images have not been published yet, and the upstream changelog still marks the 26.8 section as in progress.&lt;/p&gt;

&lt;p&gt;By the time you read this, the tag has probably landed. Check for yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://raw.githubusercontent.com/ClickHouse/ClickHouse/master/utils/list-versions/version_date.tsv &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'$1 ~ /^v26\.8\./ {print "26.8 is released - newest: " $1 " (" $2 ")"; f=1; exit}
                END {if (!f) print "26.8 not released yet"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;version_date.tsv&lt;/code&gt; is the list ClickHouse maintains of every released version and its date, so this is the most direct answer available - no auth, no rate limit, nothing to download. As of writing it prints &lt;code&gt;26.8 not released yet&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worth knowing: the Docker image will lag whatever that command tells you. The Docker Official Images repo trails the GitHub tags by a few patch versions - &lt;code&gt;clickhouse:lts&lt;/code&gt; currently resolves to 26.3.20.7 even though 26.3.24.4 has already shipped. So don't treat a missing image as evidence the release hasn't happened.&lt;/p&gt;

&lt;p&gt;Either way, the timing works in your favour. Historically ClickHouse LTS releases pick up several patch releases quickly - 26.7 had five within a month, and 26.3 is already at 26.3.24 - so waiting for roughly 26.8.3 before touching production is the normal conservative play. Use the gap to prepare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scale of what you're crossing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Breaking&lt;/th&gt;
&lt;th&gt;New features&lt;/th&gt;
&lt;th&gt;Perf improvements&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;26.4&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26.5&lt;/td&gt;
&lt;td&gt;2026-05-21&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26.6&lt;/td&gt;
&lt;td&gt;2026-06-25&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26.7&lt;/td&gt;
&lt;td&gt;2026-07-22&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26.8 LTS&lt;/td&gt;
&lt;td&gt;2026-08-27&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;127&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;58&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;199&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;416&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A note on that 58.&lt;/strong&gt; The upstream changelog lists 58 backward-incompatible entries across these five releases, but one is recorded twice - the &lt;code&gt;http_max_fields&lt;/code&gt; reduction (PR #103285) appears in both the 26.4 and 26.5 sections, presumably from a backport. That leaves &lt;strong&gt;57 unique breaking changes&lt;/strong&gt;, which is the number in the title.&lt;/p&gt;

&lt;p&gt;Two further caveats, since I'd rather you knew than found out: three of 26.8's 21 entries still carry unresolved &lt;code&gt;TODO&lt;/code&gt; markers upstream, and one of those is flagged "wait for the revert-of-revert," so it may not survive to the final notes. The 26.8 section is also still marked in progress, so this count can move - more likely up than down. Everything here was counted on 27 August 2026.&lt;/p&gt;

&lt;p&gt;On top of all that, roughly 70 settings changed their &lt;strong&gt;default value&lt;/strong&gt; across the five releases. Those don't appear in any "backward incompatible" list, and they're where a lot of the surprises actually live.&lt;/p&gt;

&lt;p&gt;I've grouped everything below by what it does to you, not by which release it landed in. That's the more useful ordering when you're planning an upgrade.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Three things that can stop the upgrade dead
&lt;/h2&gt;

&lt;p&gt;Start here. These aren't "test carefully" items - they're "the        server won't start" or "you can't go back" items.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The x86 build now requires AVX2 (26.6)
&lt;/h3&gt;

&lt;p&gt;The default x86 build moved from x86-64-v2 (SSE4.2) to &lt;strong&gt;x86-64-v3&lt;/strong&gt;, which needs AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE and XSAVE. In practice: Intel Haswell or newer, AMD Excavator or newer.&lt;/p&gt;

&lt;p&gt;Virtually every x86 CPU after 2015 supports this. But if you have older hardware anywhere in the fleet, or you're running on a hypervisor that masks CPU flags, the binary simply won't run.&lt;/p&gt;

&lt;p&gt;Check every node before you plan anything else:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'avx2'&lt;/span&gt; /proc/cpuinfo | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output means you need the &lt;code&gt;amd64compat&lt;/code&gt; build, which targets plain x86-64.&lt;/p&gt;

&lt;p&gt;This one is easy to miss because it doesn't appear in the 26.8 notes at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;insert_deduplication_version&lt;/code&gt; has a mandatory migration order (26.6 → 26.7)
&lt;/h3&gt;

&lt;p&gt;If your config sets &lt;code&gt;insert_deduplication_version&lt;/code&gt; to &lt;code&gt;old_separate_hashes&lt;/code&gt; or &lt;code&gt;compatible_double_hashes&lt;/code&gt;, &lt;strong&gt;a 26.7+ server refuses to start&lt;/strong&gt;. Not a warning - a refusal.&lt;/p&gt;

&lt;p&gt;The migration path has to happen &lt;em&gt;before&lt;/em&gt; you upgrade:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run a release that supports &lt;code&gt;compatible_double_hashes&lt;/code&gt; (it writes both the legacy and unified hashes).&lt;/li&gt;
&lt;li&gt;Keep it running for at least &lt;code&gt;replicated_deduplication_window_seconds&lt;/code&gt; - one hour by default - for replicated tables. For non-replicated tables with &lt;code&gt;non_replicated_deduplication_window &amp;gt; 0&lt;/code&gt;, the window is count-based, so run it for at least that many inserts.&lt;/li&gt;
&lt;li&gt;Remove the setting, or set it to &lt;code&gt;new_unified_hash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Now upgrade.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you never set this setting, you can ignore all of it. Worth a quick &lt;code&gt;SELECT * FROM system.server_settings WHERE name = 'insert_deduplication_version'&lt;/code&gt; to be sure.&lt;/p&gt;

&lt;p&gt;While we're here: 26.6 also changed what dedup &lt;em&gt;means&lt;/em&gt;. It now works on the whole inserted block per insert rather than per part or partition. A retry of the same insert is still deduplicated, but two different inserts that happen to produce an identical part are no longer cross-deduplicated, and reordered inserts of the same rows are no longer deduplicated either. If you were relying on the old behaviour as a correctness guarantee, that's worth thinking about.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. S3 stops using the server's own credentials for user queries (26.7)
&lt;/h3&gt;

&lt;p&gt;This is the one I'd most expect to catch people out, because of &lt;em&gt;how&lt;/em&gt; it fails.&lt;/p&gt;

&lt;p&gt;S3 access originating from user SQL no longer resolves the server's own cloud credentials by default - not environment variables, not IMDS/IRSA, not instance profiles, not AWS config files, not &lt;code&gt;role_arn&lt;/code&gt;-based STS, not GCP OAuth metadata. Such requests now need explicit credentials or &lt;code&gt;NOSIGN&lt;/code&gt;. Named collections default &lt;code&gt;use_environment_credentials&lt;/code&gt; to &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To restore the old behaviour you need &lt;em&gt;both&lt;/em&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;SET&lt;/span&gt; &lt;span class="n"&gt;use_environment_credentials&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;SET&lt;/span&gt; &lt;span class="n"&gt;s3_allow_server_credentials_in_user_queries&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;The nastier part is startup behaviour. A persistent &lt;code&gt;S3&lt;/code&gt; or &lt;code&gt;S3Queue&lt;/code&gt; table, a dynamic S3 disk, or a &lt;code&gt;DataLakeCatalog&lt;/code&gt; whose definition resolves such credentials now &lt;strong&gt;loads anonymously&lt;/strong&gt; rather than aborting server startup. That's controlled by &lt;code&gt;s3_load_table_anonymously_if_credentials_restricted&lt;/code&gt;, which is on by default.&lt;/p&gt;

&lt;p&gt;So the server comes up clean, everything looks healthy, and then queries against those tables fail at read time. Test every S3 access path before this hop, not after.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Changes that alter results without throwing an error
&lt;/h2&gt;

&lt;p&gt;These are the ones that won't show up in your logs. Your queries keep running; the answers change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loose date strings now parse (26.5)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;date_time_input_format&lt;/code&gt; and &lt;code&gt;cast_string_to_date_time_mode&lt;/code&gt; both moved from &lt;code&gt;basic&lt;/code&gt; to &lt;code&gt;best_effort&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Strings like &lt;code&gt;2024 April 4&lt;/code&gt; or &lt;code&gt;Apr 15, 2020 10:30:00&lt;/code&gt; used to be rejected. Now they parse. If you were relying on strict parsing to reject malformed input at the door, that door is now open - bad data lands and gets interpreted rather than bouncing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;CAST&lt;/code&gt; preserves the source time zone (26.5)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CAST&lt;/code&gt; to &lt;code&gt;DateTime&lt;/code&gt; or &lt;code&gt;DateTime64&lt;/code&gt; without an explicit time zone now preserves the time zone of its source argument, matching what &lt;code&gt;toDateTime&lt;/code&gt; and &lt;code&gt;toDateTime64&lt;/code&gt; already did.&lt;/p&gt;

&lt;p&gt;More correct, but it changes displayed timestamps in existing queries. If you have dashboards where times shifted after an upgrade, this is a likely culprit.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;FINAL&lt;/code&gt; on a JOIN stopped leaking to other tables (26.6)
&lt;/h3&gt;

&lt;p&gt;This was a bug fix, but it changes results.&lt;/p&gt;

&lt;p&gt;Previously, &lt;code&gt;FINAL&lt;/code&gt; on the left-most table of a JOIN was also applied to the other joined tables. That was never intended. Now it isn't.&lt;/p&gt;

&lt;p&gt;If you have queries that were quietly depending on the old behaviour, they'll start returning pre-merge duplicates from the right-hand side. There's a compatibility setting:&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;SET&lt;/span&gt; &lt;span class="n"&gt;analyzer_compatibility_apply_final_to_all_joined_tables&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;It's registered in the settings history, so &lt;code&gt;compatibility&lt;/code&gt; set below 26.6 restores it automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unquoted JSON numbers become timestamps (26.8)
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;JSONEachRow&lt;/code&gt; and similar formats, an unquoted number for a &lt;code&gt;DateTime&lt;/code&gt;/&lt;code&gt;DateTime64&lt;/code&gt; column is now read as a Unix timestamp with optional sub-second precision - consistent with &lt;code&gt;Values&lt;/code&gt;, &lt;code&gt;CAST&lt;/code&gt; and &lt;code&gt;toDateTime64&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Concretely, before this change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;1703363853.035&lt;/code&gt; was &lt;strong&gt;rejected outright&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A bare &lt;code&gt;1703363853&lt;/code&gt; was read as the raw scaled tick value, producing a &lt;code&gt;1970-...&lt;/code&gt; timestamp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now both do what you'd expect. Quoted strings and ClickHouse's own JSON output are unaffected.&lt;/p&gt;

&lt;p&gt;For most people this silently fixes things. But if your pipeline was compensating for the old raw-ticks behaviour - pre-scaling values, or writing quoted strings specifically to avoid it - you'll now get a double correction.&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;SET&lt;/span&gt; &lt;span class="n"&gt;input_format_read_datetime_number_as_raw_value&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;h3&gt;
  
  
  &lt;code&gt;toDate32&lt;/code&gt; reinterprets a whole numeric range (26.8)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Date32&lt;/code&gt; extended its range from &lt;code&gt;[1900-01-01, 2299-12-31]&lt;/code&gt; to &lt;code&gt;[0000-01-01, 9999-12-31]&lt;/code&gt;, matching &lt;code&gt;DateTime64&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The subtle consequence: in &lt;code&gt;toDate32(N)&lt;/code&gt;, values in &lt;code&gt;[120530, 2932896]&lt;/code&gt; are now interpreted as &lt;strong&gt;day numbers&lt;/strong&gt; - dates from 2300-01-01 to 9999-12-31 - rather than Unix timestamps in early 1970. If you feed epoch seconds into &lt;code&gt;toDate32&lt;/code&gt;, you get wildly different results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Array function semantics fixed (26.8)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;arrayIntersect&lt;/code&gt; and &lt;code&gt;arraySymmetricDifference&lt;/code&gt; no longer count a value repeated &lt;em&gt;within a single argument&lt;/em&gt; as if it appeared in several arguments:&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;arrayIntersect&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="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="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;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;       &lt;span class="c1"&gt;-- [] (was [1])&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;arrayIntersect&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;2&lt;/span&gt;&lt;span class="p"&gt;],&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="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;1&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="c1"&gt;-- [2] (was [1, 2])&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;arraySymmetricDifference&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="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;2&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;  &lt;span class="c1"&gt;-- [2, 1] (was [1])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A value now counts for an argument only if it was present in every argument before it, so the result contains exactly the values present in all arguments. &lt;code&gt;arrayUnion&lt;/code&gt; is unaffected.&lt;/p&gt;

&lt;p&gt;There's a nice side effect: &lt;code&gt;arrayIntersect&lt;/code&gt; now builds its hash table from the smallest argument rather than all of them - up to 1.85x faster and about a third less memory when argument sizes differ a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trivial views over &lt;code&gt;Distributed&lt;/code&gt; tables push down (26.8)
&lt;/h3&gt;

&lt;p&gt;For a view whose body is a plain &lt;code&gt;SELECT&lt;/code&gt; over a single &lt;code&gt;Distributed&lt;/code&gt; table, the whole outer query now goes to the shards. This is &lt;code&gt;optimize_trivial_view_pushdown_to_distributed&lt;/code&gt;, on by default.&lt;/p&gt;

&lt;p&gt;Two observable changes: &lt;code&gt;FINAL&lt;/code&gt; and &lt;code&gt;SAMPLE&lt;/code&gt; written on the view reference now &lt;strong&gt;propagate to the shard-local table&lt;/strong&gt; instead of being silently ignored, and &lt;code&gt;extremes&lt;/code&gt; isn't reported on single-shard clusters.&lt;/p&gt;

&lt;p&gt;If you had views where &lt;code&gt;FINAL&lt;/code&gt; was quietly a no-op, your results change - more correct, probably slower.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: Operational behaviour changes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;max_insert_threads&lt;/code&gt; now defaults to auto (26.8)
&lt;/h3&gt;

&lt;p&gt;Probably the biggest operational change in 26.8. &lt;code&gt;INSERT SELECT&lt;/code&gt; is now parallelised across available CPU cores by default, and &lt;code&gt;max_insert_threads&lt;/code&gt; can also fan out the write side of a plain &lt;code&gt;INSERT&lt;/code&gt; where the destination path can safely do so.&lt;/p&gt;

&lt;p&gt;Faster ingestion, but with consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More data parts&lt;/strong&gt; from the same query, so more merge pressure and a real risk of &lt;code&gt;too_many_parts&lt;/code&gt; on tables with tight &lt;code&gt;parts_to_throw_insert&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different row order within an insert&lt;/strong&gt;, which matters for anything depending on insertion order - a non-deterministic &lt;code&gt;ORDER BY&lt;/code&gt; tie-break, &lt;code&gt;_part&lt;/code&gt; / &lt;code&gt;_block_number&lt;/code&gt; assumptions, a ReplacingMergeTree without a proper version column&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail worth knowing if you diff configs: &lt;strong&gt;the declared default is literally &lt;code&gt;0&lt;/code&gt; in both 26.7 and 26.8.&lt;/strong&gt; What changed is the setting's type, from &lt;code&gt;UInt64&lt;/code&gt; to &lt;code&gt;MaxThreads&lt;/code&gt;. Under &lt;code&gt;UInt64&lt;/code&gt;, both &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt; meant single-threaded. Under &lt;code&gt;MaxThreads&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt; resolves to auto. So &lt;code&gt;system.settings&lt;/code&gt; reads &lt;code&gt;0&lt;/code&gt; before and after while the behaviour flips underneath you.&lt;/p&gt;

&lt;p&gt;Upstream records the change as &lt;code&gt;1 → 0&lt;/code&gt; in its compatibility mapping, which looks contradictory until you realise &lt;code&gt;1&lt;/code&gt; is what you now &lt;em&gt;set&lt;/em&gt; to get the old behaviour - not what the declaration used to say.&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;SET&lt;/span&gt; &lt;span class="n"&gt;max_insert_threads&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;-- restores the old behaviour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lightweight update patch parts get a v2 format (26.8)
&lt;/h3&gt;

&lt;p&gt;Patch parts produced by lightweight &lt;code&gt;UPDATE&lt;/code&gt; now use a v2 on-disk format, sorted by &lt;code&gt;(sorting_key..., _block_number, _block_offset)&lt;/code&gt; with a new merge algorithm. Peak memory during apply is bounded by the largest equal-sort-key run rather than the whole patch, and updates crossing merge boundaries no longer fall back to an in-memory Join apply. Old-format patch parts stay readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For replicated clusters, this needs a rolling-upgrade pin.&lt;/strong&gt; Note that &lt;code&gt;patch_parts_version&lt;/code&gt; is a MergeTree setting, not a session setting, so &lt;code&gt;SET patch_parts_version = 'v1'&lt;/code&gt; will throw. Use the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;merge_tree&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;patch_parts_version&amp;gt;&lt;/span&gt;v1&lt;span class="nt"&gt;&amp;lt;/patch_parts_version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/merge_tree&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or per 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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;my_table&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="n"&gt;SETTING&lt;/span&gt; &lt;span class="n"&gt;patch_parts_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'v1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hold that until every replica is on 26.8, then remove it.&lt;/p&gt;

&lt;p&gt;The same logic applies to text indexes: &lt;code&gt;text_index_serialization_version&lt;/code&gt; defaults to &lt;code&gt;v2_with_positions&lt;/code&gt; in 26.8, which persists token positions for phrase search. Older servers can't read that format, so pin it to &lt;code&gt;v1_with_codec&lt;/code&gt; during the rollout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hash joins now spill by default (26.5)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;max_bytes_ratio_before_external_join&lt;/code&gt; moved from &lt;code&gt;0&lt;/code&gt; to &lt;code&gt;0.5&lt;/code&gt;. Hash joins now spill to disk at 50% of the memory limit.&lt;/p&gt;

&lt;p&gt;Generally a good thing - fewer OOM kills. But if you had joins comfortably fitting in memory, some of them will now start writing to disk and get slower. Watch join-heavy query latency after the upgrade.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;REPLACE PARTITION&lt;/code&gt; from an empty source is now rejected (26.6)
&lt;/h3&gt;

&lt;p&gt;Previously, &lt;code&gt;ALTER TABLE ... REPLACE PARTITION ... FROM ...&lt;/code&gt; where the source table had no parts in that partition would &lt;strong&gt;silently drop the destination partition and write nothing&lt;/strong&gt;. A genuine data-loss footgun.&lt;/p&gt;

&lt;p&gt;It's now rejected with &lt;code&gt;BAD_ARGUMENTS&lt;/code&gt;. If you have automation that relied on the old behaviour as a clearing mechanism, it will start failing - which is the correct outcome, but it will fail. Use &lt;code&gt;DROP PARTITION&lt;/code&gt; explicitly, or set &lt;code&gt;allow_replace_partition_from_empty_source = 1&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;AggregatingMergeTree&lt;/code&gt; validates schemas at creation (26.7)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;AggregatingMergeTree&lt;/code&gt; now rejects, at table creation time, schemas where a column is neither part of the sorting key nor an aggregate-state measure (&lt;code&gt;AggregateFunction&lt;/code&gt; / &lt;code&gt;SimpleAggregateFunction&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Those columns were silently collapsed to an arbitrary value during background merges, producing wrong results for anything grouping or filtering on them. Now you find out up front.&lt;/p&gt;

&lt;p&gt;Existing tables keep working, but any automation that recreates such tables will start failing. &lt;code&gt;allow_dimensions_outside_sorting_key = 1&lt;/code&gt; restores the old permissiveness.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP request limits tightened (26.4)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;http_max_fields&lt;/code&gt; dropped from 1,000,000 to 1,000, and &lt;code&gt;http_max_field_name_size&lt;/code&gt; from 128 KB to 4 KB, to limit pre-authentication memory usage. New &lt;code&gt;http_max_request_header_size&lt;/code&gt; and &lt;code&gt;http_headers_read_timeout&lt;/code&gt; settings arrived alongside.&lt;/p&gt;

&lt;p&gt;If any client sends a lot of headers or URL parameters - some role-based auth proxies do - it'll start getting rejected.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: The tooling around ClickHouse
&lt;/h2&gt;

&lt;p&gt;This is the part upgrade checklists usually miss. ClickHouse itself starts fine; the dashboards, exporters and scripts built around it don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Asynchronous metrics became maps (26.8)
&lt;/h3&gt;

&lt;p&gt;Per-CPU-core and per-device metrics were consolidated into single key-value metrics. &lt;code&gt;OSUserTimeCPU0&lt;/code&gt;, &lt;code&gt;OSUserTimeCPU1&lt;/code&gt;, &lt;code&gt;OSUserTimeCPU2&lt;/code&gt; became a single &lt;code&gt;OSUserTimeCPU&lt;/code&gt; metric holding a map from core number to value.&lt;/p&gt;

&lt;p&gt;The same applies to the other &lt;code&gt;OS*TimeCPU*&lt;/code&gt; metrics, plus &lt;code&gt;CPUFrequencyMHz_*&lt;/code&gt;, &lt;code&gt;Temperature*&lt;/code&gt;, &lt;code&gt;EDAC*&lt;/code&gt;, &lt;code&gt;Block*_*&lt;/code&gt;, &lt;code&gt;Network(Receive|Send)*_*&lt;/code&gt;, &lt;code&gt;Disk*_*&lt;/code&gt;, &lt;code&gt;*BlobsQueueEstimate&lt;/code&gt; and &lt;code&gt;AsyncLogging*QueueSize&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Downstream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;system.asynchronous_metrics&lt;/code&gt; gains &lt;code&gt;key_values Map(LowCardinality(String), Float64)&lt;/code&gt;. The plain &lt;code&gt;value&lt;/code&gt; column is &lt;code&gt;NaN&lt;/code&gt; for those metrics&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;system.asynchronous_metric_log&lt;/code&gt; logs one row per key via a new &lt;code&gt;key&lt;/code&gt; column&lt;/li&gt;
&lt;li&gt;Prometheus exports them as labels: &lt;code&gt;ClickHouseAsyncMetrics_BlockReadBytes{device="sda"}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Graphite &lt;code&gt;MetricsTransmitter&lt;/code&gt; sends &lt;code&gt;&amp;lt;prefix&amp;gt;.&amp;lt;Metric&amp;gt;.&amp;lt;key&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any Grafana panel keyed on the old metric names goes blank. Not errors - just empty charts, which is worse because nobody notices for a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;EXPLAIN PLAN&lt;/code&gt; output format changed (26.7)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;explain_query_plan_default&lt;/code&gt; moved from &lt;code&gt;legacy&lt;/code&gt; to &lt;code&gt;pretty&lt;/code&gt;, so &lt;code&gt;EXPLAIN PLAN&lt;/code&gt; now defaults to &lt;code&gt;actions=1, compact=1, pretty=1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have anything that parses EXPLAIN output - query validators, cost estimators, index advisors, any "explain this query" feature in an internal tool - it breaks. This one is in 26.7, so a 26.8-only reading of the changelog misses it entirely.&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;SET&lt;/span&gt; &lt;span class="n"&gt;explain_query_plan_default&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'legacy'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;EXPLAIN SYNTAX&lt;/code&gt; returns one row (26.8)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN SYNTAX&lt;/code&gt; now returns the reformatted query as a single &lt;code&gt;String&lt;/code&gt; record with embedded newlines, instead of one record per line. So &lt;code&gt;SELECT count() FROM (EXPLAIN SYNTAX ...)&lt;/code&gt; returns &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two ways to restore the old shape - the per-statement option and the session setting:&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;EXPLAIN&lt;/span&gt; &lt;span class="n"&gt;SYNTAX&lt;/span&gt; &lt;span class="n"&gt;single_record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;SELECT&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;-- or session-wide:&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;explain_syntax_single_record&lt;/span&gt; &lt;span class="o"&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;Other &lt;code&gt;EXPLAIN&lt;/code&gt; kinds (&lt;code&gt;PLAN&lt;/code&gt;, &lt;code&gt;PIPELINE&lt;/code&gt;, &lt;code&gt;AST&lt;/code&gt;) keep their per-line tree output.&lt;/p&gt;

&lt;h3&gt;
  
  
  System table changes
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;system.users.valid_until&lt;/code&gt; is now &lt;code&gt;Array(DateTime64(0))&lt;/code&gt;, was &lt;code&gt;Array(DateTime)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;26.8&lt;/td&gt;
&lt;td&gt;Deadlines past 2106 are exact. Tooling reading this column needs to handle the new type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;show_data_lake_catalogs_in_system_tables&lt;/code&gt; renamed to &lt;code&gt;show_remote_databases_in_system_tables&lt;/code&gt; and broadened&lt;/td&gt;
&lt;td&gt;26.6&lt;/td&gt;
&lt;td&gt;At the default &lt;code&gt;0&lt;/code&gt;, &lt;strong&gt;MySQL and PostgreSQL databases are now hidden&lt;/strong&gt; from &lt;code&gt;system.tables&lt;/code&gt;, &lt;code&gt;system.columns&lt;/code&gt; and &lt;code&gt;system.completions&lt;/code&gt; - not just data lake catalogs. Schema browsers will show fewer databases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;system.instrumentation.parameters&lt;/code&gt; renamed to &lt;code&gt;arguments&lt;/code&gt;; &lt;code&gt;HANDLER PARAMETERS&lt;/code&gt; syntax renamed to &lt;code&gt;HANDLER ARGUMENTS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;26.6&lt;/td&gt;
&lt;td&gt;Old names are gone, not aliased&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;system.histogram_metric_log&lt;/code&gt; deprecated&lt;/td&gt;
&lt;td&gt;26.5&lt;/td&gt;
&lt;td&gt;Histograms are now a nested column on &lt;code&gt;system.metric_log&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;allow_feature_tier&lt;/code&gt; numbers shifted (26.8)
&lt;/h3&gt;

&lt;p&gt;A new &lt;code&gt;private preview&lt;/code&gt; tier sits between &lt;code&gt;experimental&lt;/code&gt; and &lt;code&gt;beta&lt;/code&gt;, so the ordering is now &lt;code&gt;experimental &amp;lt; private preview &amp;lt; beta &amp;lt; production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The numeric levels moved with it: &lt;code&gt;0&lt;/code&gt; allows all tiers, &lt;code&gt;1&lt;/code&gt; excludes experimental, &lt;code&gt;2&lt;/code&gt; additionally excludes private preview, &lt;code&gt;3&lt;/code&gt; allows production only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you pinned &lt;code&gt;allow_feature_tier = 2&lt;/code&gt; to mean "production settings only", you now need &lt;code&gt;3&lt;/code&gt;.&lt;/strong&gt; This is filed under New Feature in the changelog, but it's a silent behaviour change to an existing config value.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: Removals - the grep list
&lt;/h2&gt;

&lt;p&gt;Things that no longer exist. Each of these is a cheap search through your codebase and configs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Removed&lt;/th&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;library&lt;/code&gt; dictionary source (&lt;code&gt;SOURCE(LIBRARY(...))&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;26.8&lt;/td&gt;
&lt;td&gt;Fails with &lt;code&gt;UNKNOWN_ELEMENT_IN_CONFIG&lt;/code&gt;. &lt;code&gt;dictionaries_lib_path&lt;/code&gt; is inert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;include_from&lt;/code&gt; defaulting to &lt;code&gt;/etc/metrika.xml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;26.8&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;&amp;lt;include_from&amp;gt;/etc/metrika.xml&amp;lt;/include_from&amp;gt;&lt;/code&gt; explicitly. Separately-loaded &lt;code&gt;users.xml&lt;/code&gt; and XML dictionary configs each need their own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL source &lt;code&gt;ssl_ca&lt;/code&gt; / &lt;code&gt;ssl_cert&lt;/code&gt; / &lt;code&gt;ssl_key&lt;/code&gt; as &lt;strong&gt;file paths&lt;/strong&gt; from SQL&lt;/td&gt;
&lt;td&gt;26.8&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;ssl_ca_pem&lt;/code&gt; / &lt;code&gt;ssl_cert_pem&lt;/code&gt; / &lt;code&gt;ssl_key_pem&lt;/code&gt; with inline contents. Paths still work in the server config file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NATS &lt;code&gt;nats_credential_file&lt;/code&gt; from SQL&lt;/td&gt;
&lt;td&gt;26.8&lt;/td&gt;
&lt;td&gt;Use inline &lt;code&gt;nats_credentials&lt;/code&gt;, or a named collection in the server config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache Arrow-based &lt;code&gt;Arrow&lt;/code&gt; / &lt;code&gt;ArrowStream&lt;/code&gt; reader and writer&lt;/td&gt;
&lt;td&gt;26.8&lt;/td&gt;
&lt;td&gt;Native implementation only. The &lt;code&gt;*_use_native_reader&lt;/code&gt; / &lt;code&gt;_writer&lt;/code&gt; settings are accepted but ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config-based workload scheduling (&lt;code&gt;resources&lt;/code&gt;, &lt;code&gt;workload_classifiers&lt;/code&gt; sections)&lt;/td&gt;
&lt;td&gt;26.7&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CREATE RESOURCE&lt;/code&gt; / &lt;code&gt;CREATE WORKLOAD&lt;/code&gt;. Old sections ignored with a warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;snowflakeToDateTime&lt;/code&gt;, &lt;code&gt;snowflakeToDateTime64&lt;/code&gt;, &lt;code&gt;dateTimeToSnowflake&lt;/code&gt;, &lt;code&gt;dateTime64ToSnowflake&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;26.7&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;snowflakeID*&lt;/code&gt; variants. Deprecated since 24.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hasColumnInTable&lt;/code&gt; remote form (hostname/username/password args)&lt;/td&gt;
&lt;td&gt;26.7&lt;/td&gt;
&lt;td&gt;Only &lt;code&gt;hasColumnInTable(database, table, column)&lt;/code&gt;. The remote mode allowed arbitrary outbound connections and leaked credentials into query logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;zip&lt;/code&gt; / &lt;code&gt;zipx&lt;/code&gt; backup archives on object storage&lt;/td&gt;
&lt;td&gt;26.7&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;tar.gz&lt;/code&gt;. Zip needs seeking to read its central directory, which is very slow over object storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Naive Bayes models as XML config + &lt;code&gt;.bin&lt;/code&gt; files&lt;/td&gt;
&lt;td&gt;26.7&lt;/td&gt;
&lt;td&gt;Recreate as dictionaries with the &lt;code&gt;NAIVE_BAYES&lt;/code&gt; layout. Upside: roughly 49x less memory and 11x faster on the cited model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;kql&lt;/code&gt; table function&lt;/td&gt;
&lt;td&gt;26.5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SET dialect = 'kusto'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrow-based Parquet reader and writer&lt;/td&gt;
&lt;td&gt;26.5&lt;/td&gt;
&lt;td&gt;Native implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KQL &lt;code&gt;array_sort_asc&lt;/code&gt; / &lt;code&gt;array_sort_desc&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;26.6&lt;/td&gt;
&lt;td&gt;Now &lt;code&gt;UNKNOWN_FUNCTION&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allow_experimental_query_deduplication&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;26.6&lt;/td&gt;
&lt;td&gt;Removed entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arguments to &lt;code&gt;RANK&lt;/code&gt; / &lt;code&gt;DENSE_RANK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;26.5&lt;/td&gt;
&lt;td&gt;Per SQL standard these take zero arguments. &lt;code&gt;allow_rank_dense_rank_arguments = 1&lt;/code&gt; restores leniency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also worth checking: &lt;code&gt;icebergHash&lt;/code&gt; and &lt;code&gt;icebergBucket&lt;/code&gt; now reject 128/256-bit integers and &lt;code&gt;Decimal256&lt;/code&gt; (26.6) instead of silently truncating them into colliding hashes, and nested &lt;code&gt;Dynamic&lt;/code&gt;/&lt;code&gt;Variant&lt;/code&gt; are rejected in &lt;code&gt;min&lt;/code&gt;/&lt;code&gt;max&lt;/code&gt; aggregates and &lt;code&gt;minmax&lt;/code&gt; indexes (26.6).&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 6: What you actually get for it
&lt;/h2&gt;

&lt;p&gt;It's worth saying: this is a lot of upgrade work, and the payoff is real. 416 performance improvements across the five releases, most of them enabled by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregation and grouping.&lt;/strong&gt; A new adaptive parallel &lt;code&gt;GROUP BY&lt;/code&gt; algorithm where each thread aggregates into its own cache-resident hash table until it hits a threshold, then freezes it. Bounded-heap pruning for &lt;code&gt;GROUP BY ... ORDER BY ... LIMIT&lt;/code&gt;. Smaller hash-table cells for single-&lt;code&gt;String&lt;/code&gt; keys. Parallelised final merge of single-level tables. Aggregations without aggregate functions now use &lt;code&gt;HashSet&lt;/code&gt; instead of &lt;code&gt;HashMap&lt;/code&gt;, up to 1.8x faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reads.&lt;/strong&gt; Lazy materialization for Parquet on object storage - on a 200 MB S3 file, an &lt;code&gt;ORDER BY ... LIMIT 10&lt;/code&gt; read 3.3 MB instead of 171 MB. That's 51x less I/O and 8x faster. &lt;code&gt;read_in_order_use_virtual_row&lt;/code&gt; on by default cuts peak memory when reading in primary key order over tables with many parts. Dictionary-page-based row group skipping in the Parquet V3 reader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Joins.&lt;/strong&gt; &lt;code&gt;IEJoin&lt;/code&gt;, a sort-based algorithm for &lt;code&gt;ON&lt;/code&gt; clauses with two inequality comparisons - previously those ran as a CROSS JOIN with a filter, INNER only. &lt;code&gt;parallel_full_sorting_merge&lt;/code&gt;, which shards a merge join by join-key hash across threads, benchmarked around 2.4x faster and 3.3x less memory than &lt;code&gt;parallel_hash&lt;/code&gt;. Constant-false JOIN conditions no longer read the non-contributing side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-partition processing.&lt;/strong&gt; &lt;code&gt;DISTINCT&lt;/code&gt;, window functions and &lt;code&gt;IN (subquery)&lt;/code&gt; set building can now keep each partition's rows in a single stream when the partition expression is a deterministic function of the relevant columns, skipping the hash scatter entirely.&lt;/p&gt;

&lt;p&gt;On the feature side, the ones I'd actually reach for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pipe operators&lt;/strong&gt; - &lt;code&gt;FROM t |&amp;gt; WHERE ... |&amp;gt; AGGREGATE ... |&amp;gt; ORDER BY ...&lt;/code&gt;, GoogleSQL-style. In a query starting with &lt;code&gt;FROM&lt;/code&gt;, &lt;code&gt;SELECT&lt;/code&gt; is optional and defaults to &lt;code&gt;SELECT *&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;run_query_in_background&lt;/code&gt;&lt;/strong&gt; - the server accepts the query, returns immediately, and runs it to completion regardless of what happens to the connection. Built for long &lt;code&gt;INSERT SELECT&lt;/code&gt;, &lt;code&gt;CTAS&lt;/code&gt; and &lt;code&gt;POPULATE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CREATE HANDLER&lt;/code&gt; / &lt;code&gt;ALTER HANDLER&lt;/code&gt; / &lt;code&gt;DROP HANDLER&lt;/code&gt;&lt;/strong&gt; - custom HTTP handlers defined in SQL, persisted locally or in Keeper, with &lt;code&gt;currentHandler()&lt;/code&gt;, &lt;code&gt;currentRequestURL()&lt;/code&gt; and &lt;code&gt;system.handlers&lt;/code&gt;. This could genuinely replace some small API services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic &lt;code&gt;POPULATE&lt;/code&gt;&lt;/strong&gt; - &lt;code&gt;CREATE MATERIALIZED VIEW ... POPULATE&lt;/code&gt; is now locally atomic, so rows inserted through the same server during population aren't missed or duplicated. Local insert path only, and it needs a snapshot-capable source. &lt;code&gt;POPULATE&lt;/code&gt; also works with &lt;code&gt;TO&lt;/code&gt; now&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;system.user_query_log&lt;/code&gt;&lt;/strong&gt; - every user sees their own query log rows without needing access to &lt;code&gt;system.query_log&lt;/code&gt;. Useful if you're building multi-tenant tooling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;create_union_system_log_tables&lt;/code&gt;&lt;/strong&gt; - auto-maintained &lt;code&gt;all_...&lt;/code&gt; tables like &lt;code&gt;system.all_query_log&lt;/code&gt;, unioning a log table, its rotated versions, and the same table across cluster replicas&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeper on-disk storage&lt;/strong&gt; via a custom LSM tree, with similar performance to the in-memory storage, and a Raft topology view in the Keeper dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GROUPS&lt;/code&gt; window frames&lt;/strong&gt; (SQL:2011), where boundaries count peer groups rather than physical rows or value distances&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New text index tokenizers&lt;/strong&gt; - Japanese (MeCab), Chinese (jieba-style), ICU locale-aware segmentation, and &lt;code&gt;splitByRegexp&lt;/code&gt;, which keeps tokens like &lt;code&gt;C++&lt;/code&gt; and &lt;code&gt;C#&lt;/code&gt; intact&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data lake expansion&lt;/strong&gt; - BigQuery table function and engine, Snowflake Horizon (read &lt;em&gt;and&lt;/em&gt; write Iceberg), S3 Tables catalog with working &lt;code&gt;INSERT&lt;/code&gt;, and &lt;code&gt;Puffin&lt;/code&gt; format support&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The upgrade plan
&lt;/h2&gt;

&lt;p&gt;Here's the order I'd actually do this in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 0: Audit before you touch anything
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Dictionaries using the removed library source&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="k"&gt;source&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;dictionaries&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%library%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- AggregatingMergeTree schemas that 26.7 would now reject&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&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;type&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;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&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;tables&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%AggregatingMergeTree%'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_in_sorting_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%AggregateFunction%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Non-default settings you're carrying&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;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;default&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;settings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;changed&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;value&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;server_settings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Removed functions still in use&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;query&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;query_log&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'QueryFinish'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%snowflakeToDateTime%'&lt;/span&gt;
       &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%dateTimeToSnowflake%'&lt;/span&gt;
       &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%array_sort_asc%'&lt;/span&gt;
       &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%array_sort_desc%'&lt;/span&gt;
       &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%kql(%'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a manual pass over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/proc/cpuinfo&lt;/code&gt; on every node for AVX2&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;insert_deduplication_version&lt;/code&gt; in your config&lt;/li&gt;
&lt;li&gt;Every S3 access path - tables, disks, catalogs, named collections&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/etc/metrika.xml&lt;/code&gt; reliance&lt;/li&gt;
&lt;li&gt;MySQL &lt;code&gt;ssl_*&lt;/code&gt; paths and NATS &lt;code&gt;nats_credential_file&lt;/code&gt; in named collections and dictionaries&lt;/li&gt;
&lt;li&gt;Backup scripts using zip to object storage&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resources&lt;/code&gt; / &lt;code&gt;workload_classifiers&lt;/code&gt; config sections&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allow_feature_tier = 2&lt;/code&gt; in config&lt;/li&gt;
&lt;li&gt;Ingestion clients writing unquoted epoch numbers into &lt;code&gt;DateTime64&lt;/code&gt; via &lt;code&gt;JSONEachRow&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1: Use &lt;code&gt;compatibility&lt;/code&gt; as a shock absorber
&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;SET&lt;/span&gt; &lt;span class="n"&gt;compatibility&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'26.3'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set this globally, upgrade the binaries, confirm the cluster is healthy, &lt;em&gt;then&lt;/em&gt; peel it back one release at a time - &lt;code&gt;26.4&lt;/code&gt;, &lt;code&gt;26.5&lt;/code&gt;, &lt;code&gt;26.6&lt;/code&gt;, &lt;code&gt;26.7&lt;/code&gt; - with a validation pass between each.&lt;/p&gt;

&lt;p&gt;This restores the great majority of the ~70 default flips in one move, which turns "57 breaking changes at once" into something you can bisect. It does &lt;strong&gt;not&lt;/strong&gt; cover removals, renames or type changes. Those need actual code fixes, which is what Step 0 finds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Pin the on-disk formats during the rollout
&lt;/h3&gt;

&lt;p&gt;While a mixed-version cluster exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;patch_parts_version = 'v1'&lt;/code&gt; (MergeTree setting, config or &lt;code&gt;ALTER TABLE&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text_index_serialization_version = 'v1_with_codec'&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remove both once every replica is on 26.8.&lt;/p&gt;

&lt;p&gt;Two other on-disk changes are safe by design and don't need pinning: &lt;code&gt;packed_skip_index_max_bytes&lt;/code&gt; (defaults to 1 MiB, affects only new parts, still readable by older servers) and &lt;code&gt;compute_exact_num_defaults_for_sparse_columns&lt;/code&gt; (the flag in &lt;code&gt;serialization.json&lt;/code&gt; is ignored by older versions, so parts survive a downgrade).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Watch these after the upgrade
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part counts and merge queue depth&lt;/strong&gt; in &lt;code&gt;system.parts&lt;/code&gt; and &lt;code&gt;system.merges&lt;/code&gt; - the &lt;code&gt;max_insert_threads&lt;/code&gt; effect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;system.errors&lt;/code&gt;&lt;/strong&gt; for &lt;code&gt;UNACCEPTABLE_URL&lt;/code&gt;, &lt;code&gt;ILLEGAL_COLUMN&lt;/code&gt;, &lt;code&gt;UNKNOWN_ELEMENT_IN_CONFIG&lt;/code&gt; and &lt;code&gt;BAD_ARGUMENTS&lt;/code&gt;, which cover most of the new rejections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring gaps&lt;/strong&gt; where per-CPU and per-device async metrics used to be&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join memory profiles&lt;/strong&gt; - &lt;code&gt;max_bytes_ratio_before_external_join = 0.5&lt;/code&gt; means spilling starts where it previously didn't&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Anything that parses EXPLAIN output&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The pattern I'd take away from this: the changes most likely to hurt you aren't in the release you're upgrading &lt;em&gt;to&lt;/em&gt;. AVX2, the S3 credential change, the dedup migration ordering and the EXPLAIN output format all landed in 26.6 and 26.7. If you read only the 26.8 notes - which is what most people do for an LTS upgrade - you miss all four.&lt;/p&gt;

&lt;p&gt;The second pattern: a meaningful share of the risk sits &lt;em&gt;around&lt;/em&gt; ClickHouse rather than inside it. Dashboards keyed on metric names, scripts parsing EXPLAIN output, schema browsers reading &lt;code&gt;system.tables&lt;/code&gt;, tooling reading &lt;code&gt;system.users&lt;/code&gt;. The server will start fine and those will quietly break.&lt;/p&gt;

&lt;p&gt;None of which is an argument against upgrading. 416 performance improvements, mostly on by default, is a genuinely good deal - and 26.3 won't be the current LTS forever.&lt;/p&gt;

&lt;p&gt;Just budget for it as a five-release jump, because that's what it is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Verified against the upstream &lt;code&gt;CHANGELOG.md&lt;/code&gt;, &lt;code&gt;SettingsChangesHistory.cpp&lt;/code&gt; and the 26.8 release branch source as of 27 August 2026. If you spot something that's drifted since, let me know in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>devops</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Understanding the ORC File Format: Why Is It So Fast</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Fri, 21 Aug 2026 10:15:32 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/understanding-the-orc-file-format-why-is-it-so-fast-3pa6</link>
      <guid>https://dev.to/mohhddhassan/understanding-the-orc-file-format-why-is-it-so-fast-3pa6</guid>
      <description>&lt;p&gt;Every article about ORC says the same thing.&lt;/p&gt;

&lt;p&gt;"Columnar storage, built-in indexes, predicate pushdown - so it's fast."&lt;/p&gt;

&lt;p&gt;That's not an explanation. That's a slogan.&lt;/p&gt;

&lt;p&gt;So I wrote a small ORC parser from scratch, generated 5 million rows, and opened the file with my hands.&lt;/p&gt;

&lt;p&gt;The question I actually wanted answered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens inside an ORC file when a query asks for 2 columns out of 100?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Columnar File Actually Does
&lt;/h2&gt;

&lt;p&gt;Strip away the marketing, and it comes down to three things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store each column's values together, so you can read one column without touching the others.&lt;/li&gt;
&lt;li&gt;Encode those values in ways that only work when neighbours are similar.&lt;/li&gt;
&lt;li&gt;Keep a small map of what's inside, so a query can skip most of the file without opening it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else - compression codecs, bloom filters, ACID support - is built around that core job.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;orders&lt;/code&gt; table. Deliberately boring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order_id     int64    sequential
customer_id  int64    250k distinct
country      string   10 distinct
product      string   2,000 distinct
quantity     int32    1-19
price        double   40k distinct
order_date   date     730 days
status       string   5 distinct
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5,000,000 rows, written with PyArrow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;orc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tbl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders.orc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;compression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zlib&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stripe_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# small on purpose
&lt;/span&gt;    &lt;span class="n"&gt;row_index_stride&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dictionary_key_size_threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production ORC uses 64-256 MB stripes. My whole file is 53 MB, so a default stripe would swallow the entire dataset and there'd be nothing to skip. Shrinking it is how you see multi-stripe behaviour on a laptop.&lt;/p&gt;

&lt;p&gt;Same data also went out as a 310 MB CSV, for scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  ORC Is Read Backwards
&lt;/h2&gt;

&lt;p&gt;The last byte of the file is a single number: the length of the PostScript.&lt;/p&gt;

&lt;p&gt;Here are the final 26 bytes of my file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;08 c0 03 10 01 18 80 80 04 22 02 00 0c 28 97 05 30 06 82 f4 03 03 4f 52 43 19
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a Protocol Buffers message. Decoded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;field&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;448&lt;/td&gt;
&lt;td&gt;footer length&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;codec = ZLIB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;65536&lt;/td&gt;
&lt;td&gt;compression block size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;663&lt;/td&gt;
&lt;td&gt;metadata length&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a query engine does three reads: last byte, PostScript, then footer + metadata. A few hundred bytes on a 53 MB file.&lt;/p&gt;

&lt;p&gt;After that it knows the schema, the row count, every stripe's byte offset, and min/max stats for every column.&lt;/p&gt;

&lt;p&gt;It hasn't touched a single row of data yet.&lt;/p&gt;

&lt;p&gt;Note the ordering: the codec lives in the PostScript, and the PostScript is never compressed. It can't be - you'd need the codec to decompress the thing that tells you the codec.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stripes
&lt;/h2&gt;

&lt;p&gt;A stripe is a self-contained slab of rows. Its own index, its own data, its own footer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; #       offset     index         data  footer       rows
 0            3     8,412    8,544,070     159    687,104
 1    8,552,644     8,487    8,556,939     158    688,128
 ...
 7    59,917,580     2,796    2,315,836    152    186,176
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;8,412 bytes of index for 8.5 MB of data. &lt;strong&gt;0.1% overhead for the ability to skip.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That ratio is the entire trade.&lt;/p&gt;

&lt;p&gt;Because each stripe carries its own footer, you can hand a Spark executor one byte range and it can decode that stripe with zero knowledge of the rest of the file. That's the basis of parallel scans.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where a Column Physically Lives
&lt;/h2&gt;

&lt;p&gt;Inside a stripe, a column isn't "a column." It's a set of &lt;strong&gt;streams&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;col name       stream                   bytes
  3 country    DICTIONARY_DATA             23
  3 country    LENGTH                       5
  3 country    DATA                   310,404
  6 price      DATA                 2,374,052
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All streams for one column sit contiguously. "Read &lt;code&gt;country&lt;/code&gt; from stripe 3" is one range read, not 687,104 scattered seeks.&lt;/p&gt;

&lt;p&gt;There's also a &lt;code&gt;PRESENT&lt;/code&gt; stream for nulls - and ORC omits it entirely when a column has none. My columns are all non-null, so null tracking cost zero bytes. A row format pays for nullability on every row whether you use it or not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Encodings Do More Than Compression Does
&lt;/h2&gt;

&lt;p&gt;People credit ORC's size to compression. Mostly wrong.&lt;/p&gt;

&lt;p&gt;Bytes per column, whole file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;column&lt;/th&gt;
&lt;th&gt;bytes&lt;/th&gt;
&lt;th&gt;bytes/row&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;order_id&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11,541&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.002&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;price&lt;/td&gt;
&lt;td&gt;17,022,257&lt;/td&gt;
&lt;td&gt;3.404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;customer_id&lt;/td&gt;
&lt;td&gt;13,293,357&lt;/td&gt;
&lt;td&gt;2.659&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five million 64-bit integers in 11 KB.&lt;/p&gt;

&lt;p&gt;That's ORC's RLE v2 picking its DELTA sub-encoding: for a monotonic sequence it stores a base, a delta width, and a run length. zlib never saw 40 MB of integers, because the encoder never produced them.&lt;/p&gt;

&lt;p&gt;Same effect on sorted dates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order_date, 5M values:
  random order : 7,390,184 bytes
  sorted       :     4,898 bytes    &amp;lt;- 1,500x smaller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The PyArrow gotcha
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;dictionary_key_size_threshold&lt;/code&gt; defaults to &lt;code&gt;0.0&lt;/code&gt;, which &lt;strong&gt;disables dictionary encoding entirely&lt;/strong&gt;.&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;DIRECT (default)&lt;/th&gt;
&lt;th&gt;DICTIONARY&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;status&lt;/td&gt;
&lt;td&gt;796,196 B/stripe&lt;/td&gt;
&lt;td&gt;271,047 B/stripe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whole file&lt;/td&gt;
&lt;td&gt;62.24 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53.32 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;14% of the file, from one keyword argument.&lt;/p&gt;




&lt;h2&gt;
  
  
  Compression Sits On Top
&lt;/h2&gt;

&lt;p&gt;Only after encoding does the codec run - in independent 64 KB chunks, each with a 3-byte header. Bit 0 says "stored raw", because if compressing a chunk made it bigger, ORC just doesn't.&lt;/p&gt;

&lt;p&gt;Chunking is what makes seeking possible. One big compressed blob would mean inflating 8 MB to read the last value.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;codec&lt;/th&gt;
&lt;th&gt;file MB&lt;/th&gt;
&lt;th&gt;vs CSV&lt;/th&gt;
&lt;th&gt;write s&lt;/th&gt;
&lt;th&gt;full scan s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;uncompressed&lt;/td&gt;
&lt;td&gt;86.14&lt;/td&gt;
&lt;td&gt;3.6x&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;td&gt;1.73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;snappy&lt;/td&gt;
&lt;td&gt;69.49&lt;/td&gt;
&lt;td&gt;4.5x&lt;/td&gt;
&lt;td&gt;2.35&lt;/td&gt;
&lt;td&gt;0.51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zlib&lt;/td&gt;
&lt;td&gt;53.32&lt;/td&gt;
&lt;td&gt;5.8x&lt;/td&gt;
&lt;td&gt;4.45&lt;/td&gt;
&lt;td&gt;1.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;zstd&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51.72&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.0x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.34&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.53&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;zstd wins on all three axes. zlib is only still the default because ORC is old.&lt;/p&gt;

&lt;p&gt;Also worth noting: &lt;strong&gt;uncompressed ORC is still 3.6x smaller than CSV.&lt;/strong&gt; That gap is pure layout and encoding.&lt;/p&gt;




&lt;h2&gt;
  
  
  I Actually Measured the Skipping
&lt;/h2&gt;

&lt;p&gt;ORC keeps min/max statistics at three zoom levels: file (448 B), stripe (663 B), and row group of 10,000 rows (8.4 KB per stripe).&lt;/p&gt;

&lt;p&gt;Query:&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;price&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_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-30'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One month out of 24. About 4% of rows. Same query, same data, two files - one in insertion order, one sorted by &lt;code&gt;order_date&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unsorted:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stripe 0   2023-01-01 .. 2024-12-30   READ
stripe 1   2023-01-01 .. 2024-12-30   READ
...
stripes to read: 8/8
bytes read: 24,430,256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stripe spans the whole date range, so every min/max says "maybe". Zero pruning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sorted by &lt;code&gt;order_date&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stripe 0   2023-01-01 .. 2023-04-10   SKIP
stripe 4   2024-02-02 .. 2024-05-11   SKIP
stripe 5   2024-05-11 .. 2024-08-19   READ
stripe 6   2024-08-19 .. 2024-11-26   SKIP
...
stripes to read: 1/8
bytes read: 2,335,435          &amp;lt;- 13.7% of the unsorted read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And inside that one surviving stripe, the row-group index eliminated another 68%: 22 of 69 row groups matched. The engine ends up decoding roughly 220,000 of 5,000,000 rows.&lt;/p&gt;

&lt;p&gt;The uncomfortable conclusion:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ORC's statistics are a summary, not an index. Their usefulness is entirely determined by how you wrote the data.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sorting by your dominant filter column isn't a tuning detail. It's the difference between skipping 87% of the file and skipping nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  A predicate stats can't help with
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;country = 'IN'
stripe ranges: ('AU','US'), ('AU','US'), ('AU','US'), ...
stripes that could contain 'IN': 8/8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scattered values mean every range straddles the target. That's the gap bloom filters fill - they cost 1.4% of file size on &lt;code&gt;product&lt;/code&gt;, and are a waste on &lt;code&gt;country&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does Column Projection Actually Pay Off?
&lt;/h2&gt;

&lt;p&gt;I wrapped the file handle in a counter so I could see every byte the reader actually pulled off disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8 columns, 53 MB:&lt;/strong&gt;&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;bytes read&lt;/th&gt;
&lt;th&gt;% of file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;53,279,130&lt;/td&gt;
&lt;td&gt;99.93%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT country, price&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;19,265,356&lt;/td&gt;
&lt;td&gt;36.13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT order_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;29,356&lt;/td&gt;
&lt;td&gt;0.06%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;100 columns, 1M rows, 153 MB - the original question:&lt;/strong&gt;&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;bytes read&lt;/th&gt;
&lt;th&gt;% of file&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;153,188,271&lt;/td&gt;
&lt;td&gt;99.92%&lt;/td&gt;
&lt;td&gt;3.03s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT order_id, price&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,424,697&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.23%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.04s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;2 columns out of 100 = &lt;strong&gt;2.23% of the bytes&lt;/strong&gt;. Not "less I/O" in a hand-wavy sense. 45x less, measured.&lt;/p&gt;

&lt;p&gt;Reading 2 columns from the 310 MB CSV still requires pulling all 310 MB off disk and parsing every field to find the commas.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Big Should a Stripe Be?
&lt;/h2&gt;

&lt;p&gt;Same sorted data, same June predicate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stripe target&lt;/th&gt;
&lt;th&gt;stripes&lt;/th&gt;
&lt;th&gt;rows scanned&lt;/th&gt;
&lt;th&gt;% rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2 MB&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;350,208&lt;/td&gt;
&lt;td&gt;7.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 MB&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;680,960&lt;/td&gt;
&lt;td&gt;13.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32 MB&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2,294,592&lt;/td&gt;
&lt;td&gt;45.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64 MB&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5,000,000&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Halve the stripe, halve the rows you're forced to scan - until pruning bottoms out at the real selectivity. Total file size moved 0.3% across that whole range, because more stripes cost more &lt;em&gt;metadata&lt;/em&gt;, not more data.&lt;/p&gt;

&lt;p&gt;So why not 2 MB stripes everywhere? Because a stripe is also the unit of parallelism, and on object storage each one is a separate GET. &lt;strong&gt;The row-group index already gives you 10,000-row granularity inside a big stripe&lt;/strong&gt; - that's the layer meant to do fine pruning. Keep stripes big.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Couldn't Test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A real cluster.&lt;/strong&gt; Everything here is one machine, local NVMe. On S3, request count matters more than byte count, and my numbers say nothing about that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lz4 in this build.&lt;/strong&gt; It produced 86.02 MB - essentially identical to uncompressed. Either the PyArrow build isn't wiring it up or something else is wrong. I'm reporting it rather than quietly dropping the row, but don't trust that number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bloom filter effectiveness.&lt;/strong&gt; I measured what they &lt;em&gt;cost&lt;/em&gt; (1.4% of file size), not what they save, because PyArrow's reader doesn't expose filter pushdown for ORC. You'd need Hive or Trino for that.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where This Doesn't Help
&lt;/h2&gt;

&lt;p&gt;Columnar layout is a bet, and it loses on some workloads.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not for OLTP.&lt;/strong&gt; Inserting one row means rewriting a stripe. Fetching one full record means touching every column's streams separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small files kill it.&lt;/strong&gt; All this metadata machinery amortizes over large files. A thousand 2 MB ORC files is the classic way to make a fast format slow.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  One Thing a Different Columnar Format Does Differently
&lt;/h2&gt;

&lt;p&gt;ORC isn't the only format built on these ideas - Parquet uses the same stripe/row-group/statistics playbook. The clearest place they diverge is encoding scope.&lt;/p&gt;

&lt;p&gt;Same data, same codec, same row grouping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order_id (sequential)    ORC 7.8 KB    vs   Parquet 7.2 MB     (912x)
price (40k distinct)     ORC 16.3 MB   vs   Parquet 10.2 MB    (0.62x)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ORC's dictionary encoding applies only to string columns - a &lt;code&gt;DOUBLE&lt;/code&gt; always goes out &lt;code&gt;DIRECT&lt;/code&gt;, full width, and only the compressor gets a shot at it. Parquet dictionary-encodes any physical type, so a double with modest cardinality compresses to narrow indices where ORC can't.&lt;/p&gt;

&lt;p&gt;That's a real, measured trade-off, not a verdict. It's also its own rabbit hole - I measured it properly in a follow-up post.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, Why Is It Fast?
&lt;/h2&gt;

&lt;p&gt;Not one reason. A chain, where each link multiplies the next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;columnar layout      -&amp;gt; read 2 columns, not 100          (45x fewer bytes)
  x encodings        -&amp;gt; delta/dictionary before codec    (up to 3,467x on one column)
  x compression      -&amp;gt; chunked, seekable                (~2x on top)
  x statistics       -&amp;gt; skip stripes that can't match    (8x when sorted)
  x row-group index  -&amp;gt; skip 10k-row blocks inside those (3x more)
  ---------------------------------------------------------------
  = read 2.2% of the file, decode 4% of the rows you touched
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every layer is doing the same thing from a different angle: &lt;strong&gt;making it possible to not read something.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ORC isn't fast because it's clever with the bytes it reads. It's fast because it spends 0.1% of the file on a map detailed enough to avoid reading almost all of them.&lt;/p&gt;

&lt;p&gt;And the part that's actually in your control: sorting by your filter column moved my query from 24 MB to 2.3 MB. No format setting in this entire post came close to that.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>bigdata</category>
      <category>database</category>
      <category>python</category>
    </item>
    <item>
      <title>Why My ClickHouse Server Ran Out of Disk (and It Wasn't the Data I Expected)</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:57:03 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/why-my-clickhouse-server-ran-out-of-disk-and-it-wasnt-the-data-i-expected-mlg</link>
      <guid>https://dev.to/mohhddhassan/why-my-clickhouse-server-ran-out-of-disk-and-it-wasnt-the-data-i-expected-mlg</guid>
      <description>&lt;p&gt;A deployment failed with an error that had nothing to do with ClickHouse on the surface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;usermod: /etc/passwd... No space left on device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first, it looked like a Linux problem. Maybe a Docker problem.&lt;/p&gt;

&lt;p&gt;It wasn't either.&lt;/p&gt;

&lt;p&gt;The root filesystem was completely full.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checking Disk Usage
&lt;/h2&gt;

&lt;p&gt;The first step was the obvious one.&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="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&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;/dev/sda1   96G   100% Used
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100% used. No surprise there, given the error. But that's a symptom, not a cause.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ruling Out Inode Exhaustion
&lt;/h2&gt;

&lt;p&gt;Before assuming it was actual data filling the disk, I checked whether it was inode exhaustion instead - a different problem that looks similar from the outside.&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="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-i&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;3% inode usage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not an inode problem. Something was genuinely consuming space.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding What Was Actually Consuming Disk
&lt;/h2&gt;

&lt;p&gt;Instead of guessing, I started at the root and walked down.&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="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-hr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/var&lt;/code&gt; was consuming almost everything.&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="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-hr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/var/lib&lt;/code&gt;.&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="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-hr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/var/lib/clickhouse&lt;/code&gt;.&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="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/clickhouse/&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-hr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;store&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Five commands, one directory deeper each time. No assumptions, just narrowing the search space until there was nowhere left to hide.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding Which Table Actually Owned the Space
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;store&lt;/code&gt; is where ClickHouse keeps its actual data parts, so this didn't tell me &lt;em&gt;what&lt;/em&gt; was large, only &lt;em&gt;where&lt;/em&gt;. I connected to ClickHouse to ask it directly.&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="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;formatReadableSize&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;bytes_on_disk&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;size&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;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table&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;sum&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="k"&gt;DESC&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;system.text_log   76 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not application data. Not the tables I actually cared about.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;system.text_log&lt;/code&gt; - ClickHouse's own internal server log, stored as a table - had quietly grown to 76 gigabytes.&lt;/p&gt;

&lt;p&gt;Nobody had configured a retention policy on it. It had just been logging, forever, since the server started.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deciding Not to Preserve It
&lt;/h2&gt;

&lt;p&gt;This was a development server, so I didn't need to preserve anything. Stop the service, clear the data, move on.&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="nb"&gt;sudo &lt;/span&gt;systemctl stop clickhouse-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/clickhouse/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checked the disk.&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="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still full.&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="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/clickhouse
&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;87G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wildcard delete hadn't actually cleared everything. I listed the directory directly instead of trusting the glob.&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="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-lah&lt;/span&gt; /var/lib/clickhouse
&lt;span class="nb"&gt;sudo &lt;/span&gt;bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'du -sh /var/lib/clickhouse/* | sort -hr'&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;87G   store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;store&lt;/code&gt; was still there. I never fully pinned down why the first wildcard delete skipped it - possibly the server hadn't finished releasing file handles the moment &lt;code&gt;systemctl stop&lt;/code&gt; returned. Either way, trusting the first command's exit code without re-checking the actual directory size cost me a step.&lt;/p&gt;

&lt;p&gt;Removed it directly.&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="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/clickhouse/store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&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;84G free
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Problem solved, for now. But "for now" wasn't good enough, because the same 96GB disk was still the only place ClickHouse could write to.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Fix: Get ClickHouse Off the Root Disk Entirely
&lt;/h2&gt;

&lt;p&gt;The server had a second, 256GB disk. Unmounted. Unused. The whole time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsblk &lt;span class="nt"&gt;-f&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;sda   root filesystem
sdb   ext4   UUID=b16098f7-bd6a-496d-a2f5-86ad19913c7c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mounted it temporarily to check what was on it.&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="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/chdisk
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/sdb /mnt/chdisk
&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /mnt/chdisk
&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;lost+found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty. Unmounted it again and made the arrangement permanent instead of temporary.&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="nb"&gt;sudo &lt;/span&gt;umount /mnt/chdisk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Configuring the Permanent Mount
&lt;/h2&gt;

&lt;p&gt;Edited &lt;code&gt;/etc/fstab&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;UUID=b16098f7-bd6a-496d-a2f5-86ad19913c7c  /var/lib/clickhouse  ext4  defaults  0  2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Moved the old (now-empty) directory out of the way, created a fresh mount point, and mounted it.&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="nb"&gt;sudo mv&lt;/span&gt; /var/lib/clickhouse /var/lib/clickhouse.old
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; /var/lib/clickhouse
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified the mount actually took:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;findmnt /var/lib/clickhouse
&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;TARGET                SOURCE
/var/lib/clickhouse   /dev/sdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every read and write to &lt;code&gt;/var/lib/clickhouse&lt;/code&gt; now transparently goes to &lt;code&gt;/dev/sdb&lt;/code&gt; instead of the root disk. ClickHouse doesn't need to know or care - as far as its own config is concerned, it's still just writing to &lt;code&gt;/var/lib/clickhouse&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fixed ownership and permissions, since a fresh mount point doesn't inherit them:&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="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; clickhouse:clickhouse /var/lib/clickhouse
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;750 /var/lib/clickhouse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarted and confirmed:&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="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&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;/dev/sda1   84G free
/dev/sdb    mounted on /var/lib/clickhouse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I'm Changing Going Forward
&lt;/h2&gt;

&lt;p&gt;Clearing &lt;code&gt;text_log&lt;/code&gt; fixed the immediate problem. It didn't fix the actual cause - nothing stops it from growing back to 76GB again.&lt;/p&gt;

&lt;p&gt;The real fix is giving ClickHouse's system logs a retention policy, the same way you'd expect any log to expire eventually.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;clickhouse&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;text_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/text_log&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;trace_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/trace_log&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;metric_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/metric_log&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;part_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/part_log&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;query_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/query_log&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;query_thread_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/query_thread_log&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;processors_profile_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/processors_profile_log&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;asynchronous_metric_log&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;ttl&amp;gt;&lt;/span&gt;event_date + INTERVAL 7 DAY DELETE&lt;span class="nt"&gt;&amp;lt;/ttl&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/asynchronous_metric_log&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/clickhouse&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropped in as &lt;code&gt;/etc/clickhouse-server/config.d/system_log_retention.xml&lt;/code&gt;, this keeps every system log table capped at 7 days of history, deleted automatically, no manual cleanup required again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Before and After
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1yqw1mzsw2i2y2926ms.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1yqw1mzsw2i2y2926ms.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bigger Lesson
&lt;/h2&gt;

&lt;p&gt;I went in assuming a full disk meant the actual data had grown too fast.&lt;/p&gt;

&lt;p&gt;It hadn't. The application data was fine. What had grown out of control was ClickHouse's own internal logging - a system table nobody had put a limit on, quietly consuming more space than the data it was supposed to be observing.&lt;/p&gt;

&lt;p&gt;And underneath that, a second problem had been sitting there the whole time: a 256GB disk that was never mounted, on a server that only ever had 96GB to work with.&lt;/p&gt;

&lt;p&gt;Neither problem was visible from &lt;code&gt;df -h&lt;/code&gt; alone. Getting to the real cause meant walking the filesystem one directory at a time, then asking ClickHouse directly which table actually owned the space.&lt;/p&gt;

&lt;p&gt;Isolating database storage onto its own disk, separate from the OS, isn't just a performance habit. It's what stops a logging table from being able to take your entire root filesystem down with it.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>linux</category>
      <category>devops</category>
      <category>database</category>
    </item>
    <item>
      <title>Can ClickHouse Replace a Vector Database?</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Tue, 21 Jul 2026 12:07:17 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/can-clickhouse-replace-a-vector-database-4h45</link>
      <guid>https://dev.to/mohhddhassan/can-clickhouse-replace-a-vector-database-4h45</guid>
      <description>&lt;p&gt;Vector databases like Pinecone, Weaviate, and Milvus exist for one job: store embeddings and find the nearest ones to a query vector, fast.&lt;/p&gt;

&lt;p&gt;ClickHouse wasn't built for that.&lt;/p&gt;

&lt;p&gt;It was built for analytics - scanning huge tables of structured data quickly.&lt;/p&gt;

&lt;p&gt;But ClickHouse can also store vectors and search them, using plain SQL.&lt;/p&gt;

&lt;p&gt;So the real question isn't "does ClickHouse support vector search."&lt;/p&gt;

&lt;p&gt;It's "is that support good enough to skip a dedicated vector database."&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Vector Database Actually Does
&lt;/h2&gt;

&lt;p&gt;Strip away the marketing, and it comes down to three things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store embeddings (arrays of floats).&lt;/li&gt;
&lt;li&gt;Index them so similarity search doesn't require scanning everything.&lt;/li&gt;
&lt;li&gt;Return the nearest neighbors to a query vector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else - hybrid search, metadata filtering, embedding generation - is built around that core job.&lt;/p&gt;




&lt;h2&gt;
  
  
  Storing Vectors in ClickHouse
&lt;/h2&gt;

&lt;p&gt;Vectors are just arrays.&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;articles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&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;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;embedding&lt;/span&gt; &lt;span class="n"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float32&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;MergeTree&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;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No extension needed. No plugin. This is a normal column type.&lt;/p&gt;




&lt;h2&gt;
  
  
  Brute Force Search
&lt;/h2&gt;

&lt;p&gt;The simplest way to find similar vectors is a distance function, ordered and limited.&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;L2Distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_vector&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;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&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;score&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works correctly out of the box, and gives exact results.&lt;/p&gt;

&lt;p&gt;The catch: every query scans every row's vector. Fine at a few thousand rows, painfully slow at a few million.&lt;/p&gt;

&lt;p&gt;This is exactly the problem specialized vector databases were built to avoid.&lt;/p&gt;




&lt;h2&gt;
  
  
  I Actually Benchmarked This
&lt;/h2&gt;

&lt;p&gt;Instead of guessing, I ran the brute-force query above against real ClickHouse (via &lt;code&gt;chdb&lt;/code&gt;, the embeddable version of the same engine), on synthetic normalized embeddings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How search time scales with row count&lt;/strong&gt; (128-dim vectors):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Avg query time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;0.025s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;0.046s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;250,000&lt;/td&gt;
&lt;td&gt;0.155s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;0.220s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;0.413s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It's close to linear, which is exactly what you'd expect from brute force: no shortcuts, every row gets a distance calculation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How search time scales with vector dimension&lt;/strong&gt; (200,000 rows):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Avg query time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;0.086s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;0.251s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;0.449s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dimension hurts roughly as much as row count. A 768-dim embedding (common for larger sentence-transformer models) costs about 5x what a 128-dim one does, at the same row count. If you're using something like OpenAI's 1536-dim embeddings, expect that cost to roughly double again - I couldn't get a clean number at 1536 dims in my test environment (it ran out of memory before finishing), but the trend makes the direction obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does SQL-native filtering actually help?&lt;/strong&gt; I tested &lt;code&gt;WHERE category = 'engineering'&lt;/code&gt; against an unfiltered query, on the same 500k-row table, filtering out about 75% of rows:&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;Avg time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unfiltered&lt;/td&gt;
&lt;td&gt;0.245s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filtered (~25% of rows match)&lt;/td&gt;
&lt;td&gt;0.225s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Honestly, smaller improvement than I expected - about 8%, not a proportional 75% drop. The vector column still gets read before the filter meaningfully prunes work, because the table isn't ordered by &lt;code&gt;category&lt;/code&gt;. If you want filtering to actually pay off, you'd need to design your &lt;code&gt;ORDER BY&lt;/code&gt; / partitioning around your common filter columns, the same way you'd tune any ClickHouse table - vectors don't get a free pass on data modeling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I couldn't test:&lt;/strong&gt; ClickHouse's HNSW-based &lt;code&gt;vector_similarity&lt;/code&gt; index. The &lt;code&gt;chdb&lt;/code&gt; build I used doesn't ship with it compiled in, so I can't give you a real indexed-vs-brute-force number here - only the brute-force baseline above. If you want that comparison, you'll need to run it against a real ClickHouse server (Docker or ClickHouse Cloud) with &lt;code&gt;allow_experimental_vector_similarity_index&lt;/code&gt; enabled. Better to tell you that than make up a "10x faster" number I didn't actually measure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Indexed (Approximate) Search
&lt;/h2&gt;

&lt;p&gt;ClickHouse supports an &lt;code&gt;HNSW&lt;/code&gt;-based vector similarity index - the same graph-based approach most dedicated vector databases use internally.&lt;/p&gt;

&lt;p&gt;The idea: once an index is built on the vector column, queries should run faster than brute force, at the cost of exact accuracy - trading a bit of recall for a lot of speed. That tradeoff is the entire premise behind nearest-neighbor search in every vector database, not just ClickHouse. I just can't hand you a verified number for it from this test run - see above.&lt;/p&gt;




&lt;h2&gt;
  
  
  What ClickHouse Adds That Vector Databases Usually Don't
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't the distance calculation. It's what you can do around it in the same query.&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;L2Distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_vector&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;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'engineering'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;DAY&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;score&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filtering by metadata, joining against other tables, aggregating - all native SQL, all in the same pass as the similarity search.&lt;/p&gt;

&lt;p&gt;In many dedicated vector databases, metadata filtering is a secondary feature bolted onto the vector index. In ClickHouse, it's the same engine that's been handling filters, joins, and aggregations for years - the advantage is in query flexibility and not having a second system to keep in sync, not necessarily raw speed (my benchmark below shows filtering alone isn't a big speed win unless your table is actually modeled around it).&lt;/p&gt;




&lt;h2&gt;
  
  
  Where ClickHouse Falls Short
&lt;/h2&gt;

&lt;p&gt;It isn't a drop-in replacement for every vector database use case.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's not built for high-frequency, single-row upserts the way an OLTP-oriented vector store is.&lt;/li&gt;
&lt;li&gt;It doesn't generate embeddings for you - you bring your own vectors, computed elsewhere.&lt;/li&gt;
&lt;li&gt;Its ANN indexing is newer than systems that have done nothing but similarity search for years, so tooling and edge-case handling are less mature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the workload is millions of small writes per second with constant re-indexing, a purpose-built vector database is still the safer choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Actual Answer
&lt;/h2&gt;

&lt;p&gt;ClickHouse is an analytical database that added vector search.&lt;/p&gt;

&lt;p&gt;A vector database is a system built only around vector search.&lt;/p&gt;

&lt;p&gt;If vectors are one part of a larger analytical dataset - filtered, joined, aggregated alongside structured columns - ClickHouse can genuinely replace a dedicated vector database and remove a piece of infrastructure.&lt;/p&gt;

&lt;p&gt;If vectors are the whole workload, and writes are constant and high-frequency, a dedicated vector database still earns its place.&lt;/p&gt;

&lt;p&gt;There isn't a single right answer. There's a right answer for a given workload.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>vectordatabase</category>
      <category>ai</category>
      <category>backend</category>
    </item>
    <item>
      <title>Why Localhost Worked but the Application Couldn't Connect</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Wed, 08 Jul 2026 14:48:10 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/why-localhost-worked-but-the-application-couldnt-connect-2pl0</link>
      <guid>https://dev.to/mohhddhassan/why-localhost-worked-but-the-application-couldnt-connect-2pl0</guid>
      <description>&lt;p&gt;One of the most frustrating debugging sessions I've had wasn't caused by a broken application.&lt;/p&gt;

&lt;p&gt;It wasn't caused by a firewall.&lt;/p&gt;

&lt;p&gt;It wasn't even caused by the network.&lt;/p&gt;

&lt;p&gt;The application worked perfectly.&lt;/p&gt;

&lt;p&gt;At least, that's what I thought.&lt;/p&gt;

&lt;p&gt;From the server itself, everything looked healthy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application responded immediately.&lt;/p&gt;

&lt;p&gt;The API worked.&lt;/p&gt;

&lt;p&gt;Health checks passed.&lt;/p&gt;

&lt;p&gt;Logs looked normal.&lt;/p&gt;

&lt;p&gt;Yet every request coming from outside the server failed.&lt;/p&gt;

&lt;p&gt;At first, it felt like the application was refusing connections.&lt;/p&gt;

&lt;p&gt;The real problem was much simpler.&lt;/p&gt;

&lt;p&gt;I misunderstood what &lt;strong&gt;localhost&lt;/strong&gt; actually meant.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Initial Assumption
&lt;/h2&gt;

&lt;p&gt;When an application starts successfully, it's easy to assume it's ready for everyone to access.&lt;/p&gt;

&lt;p&gt;That was my assumption too.&lt;/p&gt;

&lt;p&gt;The service was running.&lt;/p&gt;

&lt;p&gt;The process was alive.&lt;/p&gt;

&lt;p&gt;The port existed.&lt;/p&gt;

&lt;p&gt;So naturally, I expected clients on the network to connect without any issues.&lt;/p&gt;

&lt;p&gt;Instead, every external request timed out.&lt;/p&gt;




&lt;h2&gt;
  
  
  Everything Looked Healthy
&lt;/h2&gt;

&lt;p&gt;The first thing I checked was whether the application was actually running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;sidecar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The process was there.&lt;/p&gt;

&lt;p&gt;Next, I checked whether it was listening.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again, everything seemed fine.&lt;/p&gt;

&lt;p&gt;The application was listening on port &lt;code&gt;8080&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So why couldn't another machine connect?&lt;/p&gt;




&lt;h2&gt;
  
  
  The Important Detail I Missed
&lt;/h2&gt;

&lt;p&gt;The answer was hidden in a single column of the output.&lt;/p&gt;

&lt;p&gt;Instead of listening on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0.0.0:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the application was listening on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, they both look like valid addresses.&lt;/p&gt;

&lt;p&gt;Operationally, they mean completely different things.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Localhost Really Means
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;127.0.0.1&lt;/code&gt; is the loopback interface.&lt;/p&gt;

&lt;p&gt;Traffic sent to this address never leaves the machine.&lt;/p&gt;

&lt;p&gt;It doesn't travel through the network.&lt;/p&gt;

&lt;p&gt;It never reaches another computer.&lt;/p&gt;

&lt;p&gt;Only processes running on the same host can connect to it.&lt;/p&gt;

&lt;p&gt;So this works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But from another machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://server-ip:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the connection simply fails.&lt;/p&gt;

&lt;p&gt;Nothing was wrong with the application.&lt;/p&gt;

&lt;p&gt;It was doing exactly what it had been told to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why 0.0.0.0 Is Different
&lt;/h2&gt;

&lt;p&gt;When an application binds to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it listens on all available network interfaces.&lt;/p&gt;

&lt;p&gt;That means the service becomes reachable through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the server's IP address&lt;/li&gt;
&lt;li&gt;internal network interfaces&lt;/li&gt;
&lt;li&gt;external interfaces (assuming routing and firewall rules allow it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application hasn't changed.&lt;/p&gt;

&lt;p&gt;Only the interface it listens on has.&lt;/p&gt;

&lt;p&gt;That tiny configuration difference completely changes who can communicate with it.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Wrong Mental Model
&lt;/h2&gt;

&lt;p&gt;This debugging session taught me an important lesson.&lt;/p&gt;

&lt;p&gt;I had been thinking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the application starts successfully, networking must also be working.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are two completely different things.&lt;/p&gt;

&lt;p&gt;An application can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;start correctly&lt;/li&gt;
&lt;li&gt;bind successfully&lt;/li&gt;
&lt;li&gt;respond to localhost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and still be completely unreachable from anywhere else.&lt;/p&gt;

&lt;p&gt;Application health and network accessibility are separate problems.&lt;/p&gt;




&lt;h2&gt;
  
  
  Debugging It Systematically
&lt;/h2&gt;

&lt;p&gt;Instead of assuming the application was broken, I started validating each layer independently.&lt;/p&gt;

&lt;p&gt;First, verify the process exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;sidecar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, verify what interface it's actually listening on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then test locally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, test remotely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://server-ip:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That simple progression immediately tells you where communication stops.&lt;/p&gt;

&lt;p&gt;Instead of debugging everything at once, you're narrowing the search space one layer at a time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Happens So Often
&lt;/h2&gt;

&lt;p&gt;This isn't unique to one application.&lt;/p&gt;

&lt;p&gt;Many frameworks default to binding only to localhost during development.&lt;/p&gt;

&lt;p&gt;That's perfectly reasonable.&lt;/p&gt;

&lt;p&gt;It prevents accidentally exposing services to an entire network.&lt;/p&gt;

&lt;p&gt;The problem appears when that same configuration moves into production.&lt;/p&gt;

&lt;p&gt;The application still starts.&lt;/p&gt;

&lt;p&gt;Health checks still succeed.&lt;/p&gt;

&lt;p&gt;Logs still look clean.&lt;/p&gt;

&lt;p&gt;Only remote clients fail.&lt;/p&gt;

&lt;p&gt;Without checking the listening interface, it's easy to spend hours investigating the wrong thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bigger Lesson
&lt;/h2&gt;

&lt;p&gt;Looking back, nothing in the deployment was actually broken.&lt;/p&gt;

&lt;p&gt;The operating system behaved exactly as expected.&lt;/p&gt;

&lt;p&gt;The application behaved exactly as expected.&lt;/p&gt;

&lt;p&gt;The network behaved exactly as expected.&lt;/p&gt;

&lt;p&gt;The only thing that was wrong was my assumption.&lt;/p&gt;

&lt;p&gt;I treated "the application is running" as proof that "the application is reachable."&lt;/p&gt;

&lt;p&gt;Those aren't the same statement.&lt;/p&gt;

&lt;p&gt;One describes a running process.&lt;/p&gt;

&lt;p&gt;The other describes network accessibility.&lt;/p&gt;

&lt;p&gt;Confusing the two can send you down completely the wrong debugging path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Production debugging often isn't about finding a broken component.&lt;/p&gt;

&lt;p&gt;It's about understanding how different layers interact.&lt;/p&gt;

&lt;p&gt;An application can be healthy while remaining inaccessible.&lt;/p&gt;

&lt;p&gt;A network can be perfectly functional while a service listens on the wrong interface.&lt;/p&gt;

&lt;p&gt;The more I debug distributed systems, the more I realize that successful deployments depend less on memorizing commands and more on building accurate mental models of how systems communicate.&lt;/p&gt;

&lt;p&gt;This experience reinforced one lesson I'll carry into every future deployment:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Just because localhost works doesn't mean your application is actually reachable.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>backend</category>
    </item>
    <item>
      <title>Understanding ACME: What I Learned While Debugging HTTPS Certificate Failures</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Tue, 07 Jul 2026 14:26:48 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/understanding-acme-what-i-learned-while-debugging-https-certificate-failures-12l6</link>
      <guid>https://dev.to/mohhddhassan/understanding-acme-what-i-learned-while-debugging-https-certificate-failures-12l6</guid>
      <description>&lt;p&gt;Deploying an application with HTTPS feels straightforward.&lt;/p&gt;

&lt;p&gt;Point your domain to the server, configure a reverse proxy like Caddy, and let Let's Encrypt automatically issue a certificate.&lt;/p&gt;

&lt;p&gt;That was exactly what I expected.&lt;/p&gt;

&lt;p&gt;Instead, certificate issuance kept failing, even though everything looked correct.&lt;/p&gt;

&lt;p&gt;At first, I assumed something was wrong with Caddy.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;The real lesson wasn't about Caddy at all-it was about understanding how ACME actually validates a domain.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I had a server running behind Caddy, with DNS records pointing to the correct IP address.&lt;/p&gt;

&lt;p&gt;The application was reachable, the firewall allowed HTTP and HTTPS traffic, and Caddy was listening on the expected ports.&lt;/p&gt;

&lt;p&gt;Yet every attempt to obtain a certificate failed.&lt;/p&gt;

&lt;p&gt;Initially, I started checking the obvious things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was DNS configured correctly?&lt;/li&gt;
&lt;li&gt;Were ports 80 and 443 open?&lt;/li&gt;
&lt;li&gt;Was Caddy configured correctly?&lt;/li&gt;
&lt;li&gt;Was the firewall blocking traffic?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything appeared to be fine.&lt;/p&gt;

&lt;p&gt;So why wasn't Let's Encrypt issuing a certificate?&lt;/p&gt;




&lt;h2&gt;
  
  
  My Wrong Assumption
&lt;/h2&gt;

&lt;p&gt;At first, I thought HTTPS certificate issuance was simply a conversation between my server and Let's Encrypt.&lt;/p&gt;

&lt;p&gt;If my server could reach Let's Encrypt, surely the certificate should be issued.&lt;/p&gt;

&lt;p&gt;That assumption was completely wrong.&lt;/p&gt;

&lt;p&gt;The important connection isn't just &lt;strong&gt;your server reaching Let's Encrypt&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's &lt;strong&gt;Let's Encrypt successfully reaching your server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That single realization changed how I approached the entire problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding ACME
&lt;/h2&gt;

&lt;p&gt;Let's Encrypt uses the ACME (Automatic Certificate Management Environment) protocol to verify that you actually own the domain you're requesting a certificate for.&lt;/p&gt;

&lt;p&gt;The process is roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your ACME client requests a certificate.&lt;/li&gt;
&lt;li&gt;Let's Encrypt sends a validation challenge.&lt;/li&gt;
&lt;li&gt;Your server proves ownership by responding correctly.&lt;/li&gt;
&lt;li&gt;Only after successful validation is the certificate issued.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In other words, certificate issuance isn't automatic trust.&lt;/p&gt;

&lt;p&gt;It's a verification process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Looking at the Logs
&lt;/h2&gt;

&lt;p&gt;Once I stopped guessing and started reading the logs, things became much clearer.&lt;/p&gt;

&lt;p&gt;Instead of generic "certificate failed" messages, the ACME logs explained &lt;strong&gt;which validation step was failing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That immediately narrowed the investigation.&lt;/p&gt;

&lt;p&gt;Rather than assuming TLS was broken, I started asking more useful questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can Let's Encrypt reach my server?&lt;/li&gt;
&lt;li&gt;Is the expected challenge actually being served?&lt;/li&gt;
&lt;li&gt;Is DNS resolving to the correct address?&lt;/li&gt;
&lt;li&gt;Is traffic arriving where I think it is?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions were far more valuable than repeatedly changing configuration files.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bigger Lesson
&lt;/h2&gt;

&lt;p&gt;One thing I learned during this debugging session is that HTTPS failures are often not TLS failures at all.&lt;/p&gt;

&lt;p&gt;They can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DNS issues&lt;/li&gt;
&lt;li&gt;Routing problems&lt;/li&gt;
&lt;li&gt;Network interface problems&lt;/li&gt;
&lt;li&gt;Reverse proxy configuration mistakes&lt;/li&gt;
&lt;li&gt;Port forwarding issues&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The certificate request simply exposes those underlying networking problems.&lt;/p&gt;

&lt;p&gt;In my case, the root cause wasn't ACME itself-it was a networking issue preventing successful validation.&lt;/p&gt;

&lt;p&gt;Understanding how ACME worked helped me stop treating the symptoms and start investigating the actual cause.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Better Way to Debug ACME Failures
&lt;/h2&gt;

&lt;p&gt;Instead of randomly changing configuration files, I found it much more effective to work through a simple checklist.&lt;/p&gt;

&lt;p&gt;First, verify that DNS resolves to the expected IP address.&lt;/p&gt;

&lt;p&gt;Next, confirm that ports 80 and 443 are reachable from outside the network.&lt;/p&gt;

&lt;p&gt;Then inspect the ACME client logs to determine which validation step is failing.&lt;/p&gt;

&lt;p&gt;Finally, validate that your server is actually serving the expected challenge response.&lt;/p&gt;

&lt;p&gt;Each step eliminates an entire class of possible problems.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Changed My Thinking
&lt;/h2&gt;

&lt;p&gt;Before this experience, I viewed HTTPS certificate issuance as a feature provided by the reverse proxy.&lt;/p&gt;

&lt;p&gt;Now I see it differently.&lt;/p&gt;

&lt;p&gt;ACME is fundamentally a validation protocol.&lt;/p&gt;

&lt;p&gt;TLS certificates are simply the end result of successfully proving domain ownership.&lt;/p&gt;

&lt;p&gt;Once I understood that, the debugging process became much more systematic.&lt;/p&gt;

&lt;p&gt;Instead of asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Why won't Let's Encrypt issue my certificate?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I started asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What is the ACME server trying to verify, and why is that verification failing?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That shift in thinking made all the difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Debugging production systems often comes down to replacing assumptions with understanding.&lt;/p&gt;

&lt;p&gt;At first, I thought the problem was Caddy.&lt;/p&gt;

&lt;p&gt;Then I suspected DNS.&lt;/p&gt;

&lt;p&gt;Then I questioned my firewall.&lt;/p&gt;

&lt;p&gt;In reality, none of those were the underlying issue.&lt;/p&gt;

&lt;p&gt;The real lesson was understanding how ACME validates a deployment before a certificate is ever issued.&lt;/p&gt;

&lt;p&gt;Once that mental model clicked, the logs became easier to interpret, the investigation became more focused, and the actual root cause was much easier to identify.&lt;/p&gt;

&lt;p&gt;Sometimes the hardest part of debugging isn't fixing the system.&lt;/p&gt;

&lt;p&gt;It's understanding how the system is supposed to work in the first place.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>networking</category>
      <category>security</category>
    </item>
    <item>
      <title>When Logs Aren't Enough: Using tcpdump to Debug Real Network Problems</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Mon, 22 Jun 2026 16:13:56 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/when-logs-arent-enough-using-tcpdump-to-debug-real-network-problems-2d39</link>
      <guid>https://dev.to/mohhddhassan/when-logs-arent-enough-using-tcpdump-to-debug-real-network-problems-2d39</guid>
      <description>&lt;p&gt;In my &lt;a href="https://dev.to/mohhddhassan/the-hidden-linux-routing-issue-that-broke-my-deployment-5813"&gt;previous post&lt;/a&gt;, I wrote about a Linux routing issue that broke a deployment and caused repeated validation failures.&lt;/p&gt;

&lt;p&gt;What ultimately led me to the root cause wasn't a configuration change or a log entry.&lt;/p&gt;

&lt;p&gt;It was a packet capture.&lt;/p&gt;

&lt;p&gt;This article isn't about a routing issue. It's about the tool that helped uncover it and the lesson I took away from the entire investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything Looked Healthy
&lt;/h2&gt;

&lt;p&gt;The first step was verifying the basics.&lt;/p&gt;

&lt;p&gt;I checked whether the service was listening on the expected ports:&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="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;':80|:443'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything looked normal.&lt;/p&gt;

&lt;p&gt;Next, I verified the application itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application responded immediately.&lt;/p&gt;

&lt;p&gt;I also verified DNS resolution and confirmed the domain was pointing to the correct public IP.&lt;/p&gt;

&lt;p&gt;At this point, nothing looked obviously wrong. The application was healthy, the reverse proxy was healthy, and the network configuration appeared healthy. Yet validation attempts continued to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Logs Weren't Helping
&lt;/h2&gt;

&lt;p&gt;The logs consistently showed variations of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authorization failed
timeout during connect
likely firewall problem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem was that the logs only described the symptom.&lt;/p&gt;

&lt;p&gt;They didn't explain why it was happening.&lt;/p&gt;

&lt;p&gt;So I started investigating the usual suspects: DNS, firewall rules, reverse proxy configuration, and listening ports.&lt;/p&gt;

&lt;p&gt;Everything continued to look fine.&lt;/p&gt;

&lt;p&gt;The more I investigated, the less sense the issue made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking Beyond The Logs
&lt;/h2&gt;

&lt;p&gt;At some point I realized I was only looking at what the software was reporting.&lt;/p&gt;

&lt;p&gt;I wasn't looking at what the network was actually doing.&lt;/p&gt;

&lt;p&gt;So I decided to capture the traffic directly:&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="nb"&gt;sudo &lt;/span&gt;tcpdump &lt;span class="nt"&gt;-ni&lt;/span&gt; ens3 tcp port 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I triggered another validation attempt and watched the packets arrive.&lt;/p&gt;

&lt;p&gt;Almost immediately, I saw something interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IP 124.x.x.x &amp;gt; 51.x.x.x.80: Flags [S]
IP 124.x.x.x &amp;gt; 51.x.x.x.80: Flags [S]
IP 124.x.x.x &amp;gt; 51.x.x.x.80: Flags [S]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The requests were reaching the server.&lt;/p&gt;

&lt;p&gt;That single observation completely changed the direction of the investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Real Clue
&lt;/h2&gt;

&lt;p&gt;Up until that moment, I had been operating under the assumption that external systems couldn't reach the server.&lt;/p&gt;

&lt;p&gt;The packet capture proved otherwise.&lt;/p&gt;

&lt;p&gt;Traffic was arriving.&lt;/p&gt;

&lt;p&gt;The server was receiving connection attempts.&lt;/p&gt;

&lt;p&gt;The problem wasn't inbound connectivity.&lt;/p&gt;

&lt;p&gt;The problem was somewhere after that.&lt;/p&gt;

&lt;p&gt;Instead of asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why can't external systems reach my server?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I started asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If traffic is reaching the server, why isn't the connection completing?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That shift in thinking changed the entire investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tcpdump Revealed
&lt;/h2&gt;

&lt;p&gt;To understand why the packet capture was so important, it helps to understand what a normal TCP connection looks like.&lt;/p&gt;

&lt;p&gt;A healthy connection follows a three-way handshake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client  -&amp;gt; SYN      -&amp;gt; Server
Client &amp;lt;- SYN-ACK   &amp;lt;- Server
Client  -&amp;gt; ACK      -&amp;gt; Server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I was actually seeing looked more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client  -&amp;gt; SYN      -&amp;gt; Server
Client  -&amp;gt; SYN      -&amp;gt; Server (Retry)
Client  -&amp;gt; SYN      -&amp;gt; Server (Retry)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The incoming connection attempts were reaching the server, but the connection was never being established successfully.&lt;/p&gt;

&lt;p&gt;That immediately ruled out several possibilities.&lt;/p&gt;

&lt;p&gt;DNS wasn't the problem because requests were arriving.&lt;/p&gt;

&lt;p&gt;The reverse proxy wasn't the problem because it was listening correctly.&lt;/p&gt;

&lt;p&gt;The application wasn't the problem because it responded locally.&lt;/p&gt;

&lt;p&gt;Within a few minutes, the packet capture had eliminated entire categories of potential root causes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following The Evidence
&lt;/h2&gt;

&lt;p&gt;Once I knew inbound traffic was reaching the server, I shifted my attention toward the network path itself.&lt;/p&gt;

&lt;p&gt;I started examining interfaces, routes, and outbound traffic behaviour using commands like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route get 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those commands eventually exposed the real issue.&lt;/p&gt;

&lt;p&gt;The server had multiple network interfaces, and outbound traffic was being routed through an unexpected path. That routing behaviour was causing validation attempts to fail even though the service itself was perfectly healthy.&lt;/p&gt;

&lt;p&gt;The actual root cause turned out to be a Linux routing issue.&lt;/p&gt;

&lt;p&gt;But I might never have found it if I hadn't first verified what was happening on the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tcpdump Was The Turning Point
&lt;/h2&gt;

&lt;p&gt;Before running tcpdump, I was relying entirely on logs and assumptions.&lt;/p&gt;

&lt;p&gt;The logs suggested a firewall issue.&lt;/p&gt;

&lt;p&gt;The packet capture showed requests reaching the server.&lt;/p&gt;

&lt;p&gt;Those two observations pointed in completely different directions.&lt;/p&gt;

&lt;p&gt;Without the packet capture, I probably would have continued tweaking firewall rules, reverse proxy settings, and application configuration.&lt;/p&gt;

&lt;p&gt;Instead, the investigation moved toward routing almost immediately.&lt;/p&gt;

&lt;p&gt;That's what made tcpdump so valuable.&lt;/p&gt;

&lt;p&gt;It wasn't the tool that solved the problem.&lt;/p&gt;

&lt;p&gt;It was the tool that revealed reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Logs Don't Tell The Entire Story
&lt;/h3&gt;

&lt;p&gt;Logs are useful, but they're generated by software. They only describe what the application believes is happening.&lt;/p&gt;

&lt;p&gt;Sometimes that's not enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Verify Assumptions Early
&lt;/h3&gt;

&lt;p&gt;I spent time investigating DNS, firewall rules, and reverse proxy configuration because they seemed like the most likely causes.&lt;/p&gt;

&lt;p&gt;The packet capture disproved those assumptions within minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Packet Captures Can Change Everything
&lt;/h3&gt;

&lt;p&gt;You don't need advanced networking knowledge to get value from tcpdump.&lt;/p&gt;

&lt;p&gt;Even a simple capture can tell you whether traffic is arriving, leaving, or disappearing somewhere in between.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Eliminate Entire Categories Of Problems
&lt;/h3&gt;

&lt;p&gt;One of the biggest advantages of packet captures is that they quickly rule things out.&lt;/p&gt;

&lt;p&gt;Sometimes that's more valuable than finding the answer immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;This incident reminded me that assumptions can be surprisingly expensive.&lt;/p&gt;

&lt;p&gt;The logs pointed toward a firewall issue. The services looked healthy. Everything seemed to suggest a particular problem.&lt;/p&gt;

&lt;p&gt;But the moment I looked at the packets, the entire investigation changed direction.&lt;/p&gt;

&lt;p&gt;Since then, whenever a network issue doesn't make sense, I try to reach for &lt;code&gt;tcpdump&lt;/code&gt; much earlier.&lt;/p&gt;

&lt;p&gt;Because logs tell you what software thinks happened.&lt;/p&gt;

&lt;p&gt;Packets show you what actually happened.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>tcpdump</category>
    </item>
    <item>
      <title>The Hidden Linux Routing Issue That Broke My Deployment</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Wed, 17 Jun 2026 03:41:37 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/the-hidden-linux-routing-issue-that-broke-my-deployment-5813</link>
      <guid>https://dev.to/mohhddhassan/the-hidden-linux-routing-issue-that-broke-my-deployment-5813</guid>
      <description>&lt;p&gt;The deployment should have taken a few minutes.&lt;/p&gt;

&lt;p&gt;The application was running, DNS was configured correctly, and the domain was already pointing to the server's public IP. Caddy was configured as a reverse proxy and was listening on ports 80 and 443. Every item on my deployment checklist appeared healthy.&lt;/p&gt;

&lt;p&gt;Yet every Let's Encrypt validation attempt kept failing.&lt;/p&gt;

&lt;p&gt;The error looked simple enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authorization failed
timeout during connect
likely firewall problem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first, I believed it.&lt;/p&gt;

&lt;p&gt;I checked DNS resolution, verified firewall rules, confirmed that Caddy was listening on the expected ports, and made sure the application itself was reachable. Every check came back clean.&lt;/p&gt;

&lt;p&gt;That was the first clue that the problem might not be where the logs were pointing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Obvious Things
&lt;/h2&gt;

&lt;p&gt;The first assumption was DNS.&lt;/p&gt;

&lt;p&gt;I verified that the domain resolved to the correct public IP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short my-domain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything looked correct.&lt;/p&gt;

&lt;p&gt;Next came the firewall.&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="nb"&gt;sudo &lt;/span&gt;ufw status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ports 80 and 443 were open. There were no unexpected deny rules, and nothing suggested inbound traffic was being blocked.&lt;/p&gt;

&lt;p&gt;Then I checked whether Caddy was actually listening.&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="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;':80|:443'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again, everything looked normal.&lt;/p&gt;

&lt;p&gt;The application itself was healthy too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:3001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returned a valid response.&lt;/p&gt;

&lt;p&gt;At this point I had checked most of the things engineers typically check when certificate validation fails. DNS looked good, the firewall looked good, the reverse proxy was healthy, and the application was running.&lt;/p&gt;

&lt;p&gt;Yet the validation errors continued.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part That Sent Me In The Wrong Direction
&lt;/h2&gt;

&lt;p&gt;The error messages kept mentioning connectivity problems and possible firewall issues.&lt;/p&gt;

&lt;p&gt;That wording influenced my thinking more than it should have.&lt;/p&gt;

&lt;p&gt;I spent time investigating firewall rules, reverse proxy configuration, TLS settings, and domain configuration. Every new hypothesis felt reasonable, but none of them explained why local tests consistently succeeded while external validation continued to fail.&lt;/p&gt;

&lt;p&gt;The contradiction kept bothering me.&lt;/p&gt;

&lt;p&gt;If the service was truly unreachable, why did everything work from inside the server?&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I Hit The Rate Limit
&lt;/h2&gt;

&lt;p&gt;This was the point where I realized I was no longer troubleshooting.&lt;/p&gt;

&lt;p&gt;I was guessing.&lt;/p&gt;

&lt;p&gt;After several failed validation attempts, Let's Encrypt stopped accepting new authorization requests and returned a rate-limit error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;too many failed authorizations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had burned through multiple validation attempts without actually understanding the root cause.&lt;/p&gt;

&lt;p&gt;Looking back, this was probably the most useful lesson from the entire incident.&lt;/p&gt;

&lt;p&gt;Repeatedly retrying a failing system is not the same thing as debugging it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking At The Network Instead Of The Logs
&lt;/h2&gt;

&lt;p&gt;At this point I stopped changing configurations and started gathering evidence.&lt;/p&gt;

&lt;p&gt;The first useful clue came from tcpdump.&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="nb"&gt;sudo &lt;/span&gt;tcpdump &lt;span class="nt"&gt;-ni&lt;/span&gt; ens3 tcp port 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While monitoring traffic, I triggered requests from outside the server.&lt;/p&gt;

&lt;p&gt;The packet capture immediately showed incoming connection attempts reaching the machine.&lt;/p&gt;

&lt;p&gt;That was important.&lt;/p&gt;

&lt;p&gt;It meant DNS was working.&lt;/p&gt;

&lt;p&gt;It meant external traffic was reaching the public interface.&lt;/p&gt;

&lt;p&gt;It meant the firewall was not silently dropping inbound requests.&lt;/p&gt;

&lt;p&gt;The requests were arriving exactly where they were supposed to.&lt;/p&gt;

&lt;p&gt;So why was validation timing out?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Routing Table Finally Revealed The Problem
&lt;/h2&gt;

&lt;p&gt;The next step was checking the routing table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default via 10.2.0.1 dev ens4 metric 100
default via 51.x.x.x dev ens3 metric 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server had two network interfaces.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ens3 connected to the public network&lt;/li&gt;
&lt;li&gt;ens4 connected to a private network&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Initially, I didn't think much of it. Multi-interface servers are fairly common.&lt;/p&gt;

&lt;p&gt;Then I started checking where outbound traffic was actually leaving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route get 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result surprised me.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.8.8.8 via 10.2.0.1 dev ens4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested several additional destinations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route get 1.1.1.1
ip route get 8.8.4.4
ip route get &amp;lt;validator-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single lookup showed outbound traffic leaving through the private interface.&lt;/p&gt;

&lt;p&gt;That was the breakthrough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding What Was Actually Happening
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Quick Note About Asymmetric Routing
&lt;/h3&gt;

&lt;p&gt;The issue I was dealing with has a name: asymmetric routing.&lt;/p&gt;

&lt;p&gt;Traffic was entering the server through the public interface (&lt;code&gt;ens3&lt;/code&gt;), but Linux was attempting to send replies through the private interface (&lt;code&gt;ens4&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;From the application's perspective everything looked healthy.&lt;/p&gt;

&lt;p&gt;From Let's Encrypt's perspective the connection never completed successfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Can Cause Timeouts
&lt;/h3&gt;

&lt;p&gt;While investigating the issue, I came across Linux's Reverse Path Filtering (&lt;code&gt;rp_filter&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;When a packet arrives on one interface but Linux believes the reply should leave through another, the kernel may treat the traffic as suspicious and drop it.&lt;/p&gt;

&lt;p&gt;Whether the packet was being dropped by &lt;code&gt;rp_filter&lt;/code&gt;, upstream networking, or another layer wasn't something I conclusively proved.&lt;/p&gt;

&lt;p&gt;But understanding this interaction finally explained why inbound requests were visible while validation attempts still timed out.&lt;/p&gt;

&lt;p&gt;Let's Encrypt validators were connecting to my public IP.&lt;/p&gt;

&lt;p&gt;Those packets arrived through the public interface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Let's Encrypt
      |
      v
Public Interface (ens3)
      |
      v
    Server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So far, everything was fine.&lt;/p&gt;

&lt;p&gt;The problem appeared when Linux generated a response.&lt;/p&gt;

&lt;p&gt;Instead of sending the response back through the same public interface, the routing table was selecting the private interface as the preferred outbound path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Let's Encrypt
      |
      v
Public Interface (ens3)
      |
      v
    Server
      |
      v
Private Interface (ens4)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a classic networking issue known as &lt;strong&gt;asymmetric routing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Traffic enters through one interface and attempts to leave through another.&lt;/p&gt;

&lt;p&gt;From the application's perspective, everything appears healthy.&lt;/p&gt;

&lt;p&gt;From the remote system's perspective, the connection never completes correctly.&lt;/p&gt;

&lt;p&gt;The result is timeouts.&lt;/p&gt;

&lt;p&gt;Exactly what Let's Encrypt was reporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Was So Difficult To Find
&lt;/h2&gt;

&lt;p&gt;The issue hid behind several misleading signals.&lt;/p&gt;

&lt;p&gt;The application was healthy.&lt;/p&gt;

&lt;p&gt;The reverse proxy was healthy.&lt;/p&gt;

&lt;p&gt;DNS was correct.&lt;/p&gt;

&lt;p&gt;Ports were open.&lt;/p&gt;

&lt;p&gt;The firewall was configured properly.&lt;/p&gt;

&lt;p&gt;Every layer looked healthy when viewed independently.&lt;/p&gt;

&lt;p&gt;The actual failure existed underneath all of them.&lt;/p&gt;

&lt;p&gt;Most deployment troubleshooting guides focus on application configuration, reverse proxies, certificates, and firewall rules. Very few immediately point you toward route selection.&lt;/p&gt;

&lt;p&gt;Especially when the server appears to be functioning normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Once the routing issue was identified, the fix itself was straightforward.&lt;/p&gt;

&lt;p&gt;The server needed to use the public interface for internet-bound traffic instead of attempting to route those responses through the private network.&lt;/p&gt;

&lt;p&gt;After correcting the routing configuration, I verified the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route get 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output now showed traffic leaving through the public interface.&lt;/p&gt;

&lt;p&gt;Exactly what I wanted.&lt;/p&gt;

&lt;p&gt;I restarted Caddy and triggered another validation attempt.&lt;/p&gt;

&lt;p&gt;This time the validators connected successfully, the challenge completed, and the certificate was issued within seconds.&lt;/p&gt;

&lt;p&gt;Hours of troubleshooting ultimately came down to a routing decision that Linux was making automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;A few takeaways from this incident stood out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Error messages often describe symptoms, not causes
&lt;/h3&gt;

&lt;p&gt;The logs repeatedly suggested firewall issues.&lt;/p&gt;

&lt;p&gt;The firewall was never the problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stop retrying and start investigating
&lt;/h3&gt;

&lt;p&gt;I hit Let's Encrypt's authorization limits because I kept retrying before understanding the failure.&lt;/p&gt;

&lt;p&gt;That was entirely avoidable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Packet captures reveal reality
&lt;/h3&gt;

&lt;p&gt;When logs become confusing, tcpdump often provides a much clearer picture of what is actually happening on the network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-interface servers deserve extra scrutiny
&lt;/h3&gt;

&lt;p&gt;If a server has both public and private interfaces, route selection should be one of the first things you verify.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two commands can save hours
&lt;/h3&gt;

&lt;p&gt;If you're debugging unexplained connectivity issues, run these early:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route

ip route get 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two commands exposed the real problem faster than everything else I tried.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;I started this investigation convinced I had a TLS problem.&lt;/p&gt;

&lt;p&gt;Then I thought it was DNS.&lt;/p&gt;

&lt;p&gt;Then I suspected the firewall.&lt;/p&gt;

&lt;p&gt;Then I questioned my reverse proxy configuration.&lt;/p&gt;

&lt;p&gt;In the end, none of those were responsible.&lt;/p&gt;

&lt;p&gt;The real issue was a routing decision happening at the operating system level long before the request ever reached my application.&lt;/p&gt;

&lt;p&gt;And like most memorable debugging sessions, the hardest part wasn't fixing the problem.&lt;/p&gt;

&lt;p&gt;It was figuring out where the problem actually lived.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>networking</category>
      <category>debugging</category>
    </item>
    <item>
      <title>ClickHouse Duplicates: Clean Your Results vs. Clean Your Storage</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Sat, 13 Jun 2026 12:36:42 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/select-final-and-optimize-final-are-not-the-same-thing-7ak</link>
      <guid>https://dev.to/mohhddhassan/select-final-and-optimize-final-are-not-the-same-thing-7ak</guid>
      <description>&lt;p&gt;The word &lt;code&gt;FINAL&lt;/code&gt; appears in multiple places in ClickHouse.&lt;/p&gt;

&lt;p&gt;Two of the most commonly confused examples are:&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;events&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;p&gt;and:&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;OPTIMIZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&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;p&gt;At first glance, they sound like they should do roughly the same thing.&lt;/p&gt;

&lt;p&gt;After all, both contain the word &lt;code&gt;FINAL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But they actually solve two completely different problems.&lt;/p&gt;

&lt;p&gt;One affects query results.&lt;/p&gt;

&lt;p&gt;The other affects how data is physically stored.&lt;/p&gt;

&lt;p&gt;Understanding this distinction can save a lot of confusion when working with MergeTree tables.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why This Confusion Happens
&lt;/h1&gt;

&lt;p&gt;Most people encounter &lt;code&gt;FINAL&lt;/code&gt; while working with engines like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ReplacingMergeTree&lt;/li&gt;
&lt;li&gt;SummingMergeTree&lt;/li&gt;
&lt;li&gt;AggregatingMergeTree&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sooner or later they notice something like:&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;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returns duplicate versions of rows.&lt;/p&gt;

&lt;p&gt;Then they discover:&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;users&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;p&gt;and suddenly the results look correct.&lt;/p&gt;

&lt;p&gt;Naturally, many people assume:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;FINAL merges the table.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But that's not exactly what is happening.&lt;/p&gt;




&lt;h1&gt;
  
  
  What SELECT FINAL Actually Does
&lt;/h1&gt;

&lt;p&gt;When you run:&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;users&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;p&gt;ClickHouse applies merge logic during query execution.&lt;/p&gt;

&lt;p&gt;Think of it as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Show me what the table would look like if all relevant merges had already happened."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The important part:&lt;/p&gt;

&lt;p&gt;It only affects the query result.&lt;/p&gt;

&lt;p&gt;After the query finishes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;parts remain unchanged&lt;/li&gt;
&lt;li&gt;storage remains unchanged&lt;/li&gt;
&lt;li&gt;nothing is rewritten on disk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The merge logic happens temporarily while the query is running.&lt;/p&gt;

&lt;p&gt;Once the query completes, the table is exactly as it was before.&lt;/p&gt;




&lt;h1&gt;
  
  
  What OPTIMIZE FINAL Actually Does
&lt;/h1&gt;

&lt;p&gt;Now let's look at:&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;OPTIMIZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&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;p&gt;This is a completely different operation.&lt;/p&gt;

&lt;p&gt;Instead of modifying query results, ClickHouse physically merges parts on disk.&lt;/p&gt;

&lt;p&gt;The operation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rewrites data&lt;/li&gt;
&lt;li&gt;merges eligible parts&lt;/li&gt;
&lt;li&gt;removes obsolete versions&lt;/li&gt;
&lt;li&gt;creates larger merged parts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unlike &lt;code&gt;SELECT FINAL&lt;/code&gt;, the effects remain after the command completes.&lt;/p&gt;

&lt;p&gt;This is a storage operation, not a query operation.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Simplest Way to Remember It
&lt;/h1&gt;

&lt;p&gt;Whenever I think about these commands, I use a very simple mental model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SELECT FINAL&lt;/td&gt;
&lt;td&gt;Clean the result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OPTIMIZE FINAL&lt;/td&gt;
&lt;td&gt;Clean the storage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's really the core difference.&lt;/p&gt;

&lt;p&gt;One affects what you see.&lt;/p&gt;

&lt;p&gt;The other affects how the data is stored.&lt;/p&gt;




&lt;h1&gt;
  
  
  Does OPTIMIZE FINAL Create One Giant Part?
&lt;/h1&gt;

&lt;p&gt;This is another common misconception.&lt;/p&gt;

&lt;p&gt;Suppose your table is partitioned like 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="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;event_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2025-01
2025-02
2025-03
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Many people assume:&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;OPTIMIZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&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;p&gt;will merge the entire table into one huge part.&lt;/p&gt;

&lt;p&gt;It won't.&lt;/p&gt;

&lt;p&gt;Merge operations do not cross partition boundaries.&lt;/p&gt;

&lt;p&gt;What you are more likely to end up with is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2025-01 -&amp;gt; one large part
2025-02 -&amp;gt; one large part
2025-03 -&amp;gt; one large part
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each partition is optimized independently.&lt;/p&gt;

&lt;p&gt;This distinction becomes important when working with large datasets.&lt;/p&gt;




&lt;h1&gt;
  
  
  Should You Use SELECT FINAL Everywhere?
&lt;/h1&gt;

&lt;p&gt;Not really.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FINAL&lt;/code&gt; is incredibly useful when correctness matters.&lt;/p&gt;

&lt;p&gt;For example:&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;users&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;p&gt;may be exactly what you need when querying a ReplacingMergeTree table and you want the latest state immediately.&lt;/p&gt;

&lt;p&gt;But it still introduces additional work during query execution.&lt;/p&gt;

&lt;p&gt;So while modern ClickHouse versions have significantly improved FINAL performance, it shouldn't automatically become your default query pattern.&lt;/p&gt;

&lt;p&gt;Use it when you need the merge logic.&lt;/p&gt;

&lt;p&gt;Not because it's available.&lt;/p&gt;




&lt;h1&gt;
  
  
  Should You Run OPTIMIZE FINAL Regularly?
&lt;/h1&gt;

&lt;p&gt;Also no.&lt;/p&gt;

&lt;p&gt;This is another mistake people sometimes make.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;OPTIMIZE FINAL&lt;/code&gt; is a heavy operation.&lt;/p&gt;

&lt;p&gt;It forces merges that ClickHouse would normally schedule on its own.&lt;/p&gt;

&lt;p&gt;In many cases, background merges already do a good job of maintaining healthy storage.&lt;/p&gt;

&lt;p&gt;Running:&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;OPTIMIZE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&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;p&gt;every time you insert data is usually unnecessary.&lt;/p&gt;

&lt;p&gt;Think of it as an operational tool.&lt;/p&gt;

&lt;p&gt;Not a routine query optimization technique.&lt;/p&gt;




&lt;h1&gt;
  
  
  When Would You Use Each?
&lt;/h1&gt;

&lt;h3&gt;
  
  
  SELECT FINAL
&lt;/h3&gt;

&lt;p&gt;Useful when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;querying ReplacingMergeTree tables&lt;/li&gt;
&lt;li&gt;validating latest state&lt;/li&gt;
&lt;li&gt;merge results are needed immediately&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  OPTIMIZE FINAL
&lt;/h3&gt;

&lt;p&gt;Useful when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;forcing merges intentionally&lt;/li&gt;
&lt;li&gt;maintenance operations&lt;/li&gt;
&lt;li&gt;testing storage behavior&lt;/li&gt;
&lt;li&gt;special operational situations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both have valid use cases.&lt;/p&gt;

&lt;p&gt;They simply solve different problems.&lt;/p&gt;




&lt;h1&gt;
  
  
  Final Thoughts
&lt;/h1&gt;

&lt;p&gt;The word &lt;code&gt;FINAL&lt;/code&gt; appears in both commands, which makes them easy to confuse.&lt;/p&gt;

&lt;p&gt;But once you understand the difference, many ClickHouse behaviors start making a lot more sense.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT FINAL&lt;/code&gt; does not physically merge your table.&lt;/p&gt;

&lt;p&gt;It only applies merge logic while reading data.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;OPTIMIZE FINAL&lt;/code&gt; actually rewrites and merges parts on disk.&lt;/p&gt;

&lt;p&gt;Or put another way:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SELECT FINAL cleans what you see.&lt;/p&gt;

&lt;p&gt;OPTIMIZE FINAL cleans how the data is stored.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And that's a distinction every ClickHouse engineer should understand.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>dataengineering</category>
      <category>sql</category>
    </item>
    <item>
      <title>Why ClickHouse Loves Append-Heavy Workloads</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Wed, 27 May 2026 09:24:45 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/why-clickhouse-loves-append-heavy-workloads-3m4h</link>
      <guid>https://dev.to/mohhddhassan/why-clickhouse-loves-append-heavy-workloads-3m4h</guid>
      <description>&lt;p&gt;One thing that makes ClickHouse feel very different from traditional OLTP databases is how much it prefers append-heavy workloads.&lt;/p&gt;

&lt;p&gt;And once you understand why, many ClickHouse behaviors suddenly start making sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;immutable parts&lt;/li&gt;
&lt;li&gt;background merges&lt;/li&gt;
&lt;li&gt;ingestion batching&lt;/li&gt;
&lt;li&gt;merge pressure&lt;/li&gt;
&lt;li&gt;even why &lt;code&gt;FINAL&lt;/code&gt; exists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At first, this can feel strange if you are coming from databases like PostgreSQL or MySQL where updates and row modifications are extremely normal.&lt;/p&gt;

&lt;p&gt;But analytical databases think very differently internally.&lt;/p&gt;




&lt;h1&gt;
  
  
  Traditional OLTP Systems Think in Terms of Updates
&lt;/h1&gt;

&lt;p&gt;In most transactional databases, modifying rows constantly is completely normal.&lt;/p&gt;

&lt;p&gt;For example:&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;inventory&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&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;users&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;last_login&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These systems are heavily optimized for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transactional correctness&lt;/li&gt;
&lt;li&gt;row-level updates&lt;/li&gt;
&lt;li&gt;operational consistency&lt;/li&gt;
&lt;li&gt;frequent modifications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because that is exactly what OLTP workloads need.&lt;/p&gt;

&lt;p&gt;And honestly, PostgreSQL is incredibly good at this.&lt;/p&gt;




&lt;h1&gt;
  
  
  ClickHouse Thinks Very Differently
&lt;/h1&gt;

&lt;p&gt;ClickHouse is not primarily designed around transactional row updates.&lt;/p&gt;

&lt;p&gt;It is designed around analytical workloads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;metrics&lt;/li&gt;
&lt;li&gt;logs&lt;/li&gt;
&lt;li&gt;observability&lt;/li&gt;
&lt;li&gt;event streams&lt;/li&gt;
&lt;li&gt;historical analytics&lt;/li&gt;
&lt;li&gt;large aggregations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And these workloads are naturally append-heavy.&lt;/p&gt;

&lt;p&gt;For example:&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;events&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&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;metrics&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New events continuously arrive.&lt;/p&gt;

&lt;p&gt;Old data is rarely modified frequently.&lt;/p&gt;

&lt;p&gt;That changes the entire storage philosophy underneath.&lt;/p&gt;




&lt;h1&gt;
  
  
  ClickHouse Stores Data as Immutable Parts
&lt;/h1&gt;

&lt;p&gt;This is one of the most important concepts to understand.&lt;/p&gt;

&lt;p&gt;In MergeTree engines, ClickHouse stores inserts as immutable parts on disk.&lt;/p&gt;

&lt;p&gt;Meaning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;inserts create new parts instead of constantly rewriting existing rows directly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And honestly, this is one of the biggest reasons ClickHouse scales analytical ingestion so well.&lt;/p&gt;

&lt;p&gt;Because append-heavy writes are operationally much cheaper than constantly rewriting data in place.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Immutable Storage Works So Well
&lt;/h1&gt;

&lt;p&gt;Immutable storage gives ClickHouse several advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;efficient sequential writes&lt;/li&gt;
&lt;li&gt;better compression&lt;/li&gt;
&lt;li&gt;reduced locking pressure&lt;/li&gt;
&lt;li&gt;faster analytical scans&lt;/li&gt;
&lt;li&gt;simpler background merging&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of constantly modifying rows directly, ClickHouse can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;append data quickly&lt;/li&gt;
&lt;li&gt;merge parts later&lt;/li&gt;
&lt;li&gt;optimize storage asynchronously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This model fits analytical systems extremely well.&lt;/p&gt;

&lt;p&gt;Especially when ingesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;logs&lt;/li&gt;
&lt;li&gt;metrics&lt;/li&gt;
&lt;li&gt;telemetry&lt;/li&gt;
&lt;li&gt;clickstream data&lt;/li&gt;
&lt;li&gt;observability events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;at very large scale.&lt;/p&gt;




&lt;h1&gt;
  
  
  Columnar Storage Makes This Even More Powerful
&lt;/h1&gt;

&lt;p&gt;Another reason append-heavy storage works so well in ClickHouse is because data is stored by column instead of by row.&lt;/p&gt;

&lt;p&gt;This matters a lot for analytical workloads.&lt;/p&gt;

&lt;p&gt;Because queries often need only a few columns from massive datasets.&lt;/p&gt;

&lt;p&gt;For example:&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="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_time_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does not need to read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;user_agent&lt;/li&gt;
&lt;li&gt;request_headers&lt;/li&gt;
&lt;li&gt;payload columns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;at all.&lt;/p&gt;

&lt;p&gt;And because parts are immutable, ClickHouse can compress these columns extremely efficiently using specialized compression algorithms.&lt;/p&gt;

&lt;p&gt;This is one of the reasons analytical scans in ClickHouse can remain surprisingly fast even at very large scale.&lt;/p&gt;




&lt;h1&gt;
  
  
  This Is Why Background Merges Exist
&lt;/h1&gt;

&lt;p&gt;One thing that confused me initially was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;why ClickHouse relies so heavily on merges.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But once you understand immutable parts, merges make perfect sense.&lt;/p&gt;

&lt;p&gt;Because inserts continuously create smaller parts.&lt;/p&gt;

&lt;p&gt;And background merges later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;combine them&lt;/li&gt;
&lt;li&gt;reduce fragmentation&lt;/li&gt;
&lt;li&gt;improve compression&lt;/li&gt;
&lt;li&gt;optimize query performance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tiny inserts become dangerous&lt;/li&gt;
&lt;li&gt;too many parts create pressure&lt;/li&gt;
&lt;li&gt;unhealthy fragmentation slows systems down&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many ClickHouse operational behaviors trace back to this append-heavy storage philosophy underneath.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Updates Feel Different in ClickHouse
&lt;/h1&gt;

&lt;p&gt;This does &lt;em&gt;not&lt;/em&gt; mean ClickHouse cannot handle updates.&lt;/p&gt;

&lt;p&gt;It absolutely can.&lt;/p&gt;

&lt;p&gt;But updates behave differently because the storage engine is optimized differently.&lt;/p&gt;

&lt;p&gt;In many cases, updates are internally handled through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mutations&lt;/li&gt;
&lt;li&gt;part rewrites&lt;/li&gt;
&lt;li&gt;asynchronous merge operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;instead of lightweight in-place row modifications like traditional OLTP systems.&lt;/p&gt;

&lt;p&gt;And this is why large-scale frequent updates can feel operationally heavier in ClickHouse.&lt;/p&gt;

&lt;p&gt;Because the system is optimizing for analytical scale first.&lt;/p&gt;

&lt;p&gt;Not transactional mutation-heavy workloads.&lt;/p&gt;




&lt;h1&gt;
  
  
  Many Systems Handle Updates as New Inserts Instead
&lt;/h1&gt;

&lt;p&gt;One thing I found interesting is that many ClickHouse workloads avoid frequent in-place updates entirely.&lt;/p&gt;

&lt;p&gt;Instead, systems often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;insert newer versions of rows&lt;/li&gt;
&lt;li&gt;append updated events&lt;/li&gt;
&lt;li&gt;track timestamps or versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and later use things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;argMax()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;merge logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;to retrieve the latest state.&lt;/p&gt;

&lt;p&gt;For example:&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="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;FROM&lt;/span&gt; &lt;span class="n"&gt;user_status&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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fits naturally into ClickHouse’s append-heavy design philosophy.&lt;/p&gt;

&lt;p&gt;Instead of constantly rewriting rows directly, systems continuously append newer versions while merges and analytical queries reconcile state later.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Event Streams Fit ClickHouse So Naturally
&lt;/h1&gt;

&lt;p&gt;This is honestly where ClickHouse feels extremely powerful.&lt;/p&gt;

&lt;p&gt;Modern systems continuously generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;logs&lt;/li&gt;
&lt;li&gt;metrics&lt;/li&gt;
&lt;li&gt;traces&lt;/li&gt;
&lt;li&gt;user events&lt;/li&gt;
&lt;li&gt;telemetry streams&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And these workloads naturally behave like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;append-heavy event streams.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;New records continuously arrive.&lt;/p&gt;

&lt;p&gt;Historical records mostly remain unchanged.&lt;/p&gt;

&lt;p&gt;That is exactly the kind of workload ClickHouse loves.&lt;/p&gt;

&lt;p&gt;Which is why architectures like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Applications
      ↓
Kafka / Streaming
      ↓
ClickHouse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;feel so natural operationally.&lt;/p&gt;

&lt;p&gt;The storage model aligns perfectly with the workload behavior.&lt;/p&gt;




&lt;h1&gt;
  
  
  This Also Explains Why FINAL Exists
&lt;/h1&gt;

&lt;p&gt;A lot of ClickHouse behavior becomes easier to understand once you think in terms of append-heavy storage.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;code&gt;ReplacingMergeTree&lt;/code&gt; may temporarily contain multiple versions of rows until merges eventually reconcile them.&lt;/p&gt;

&lt;p&gt;That is why queries sometimes use:&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;events&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;p&gt;to apply merge logic during query execution.&lt;/p&gt;

&lt;p&gt;Again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;immutable parts&lt;/li&gt;
&lt;li&gt;append-heavy ingestion&lt;/li&gt;
&lt;li&gt;asynchronous merging&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;all connect back together underneath.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Important Lesson
&lt;/h1&gt;

&lt;p&gt;One thing I’ve started realizing with ClickHouse is that many operational behaviors make much more sense once you stop thinking in terms of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"traditional transactional databases."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;ClickHouse is optimizing for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;analytical ingestion&lt;/li&gt;
&lt;li&gt;historical querying&lt;/li&gt;
&lt;li&gt;append-heavy workloads&lt;/li&gt;
&lt;li&gt;large-scale scans &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And once you understand that design philosophy, many of its storage behaviors stop feeling strange.&lt;/p&gt;




&lt;h1&gt;
  
  
  Final Thought
&lt;/h1&gt;

&lt;p&gt;ClickHouse is not trying to behave like a traditional OLTP database.&lt;/p&gt;

&lt;p&gt;It is optimizing for analytical scale.&lt;/p&gt;

&lt;p&gt;And append-heavy design is one of the biggest reasons it performs so well for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;observability&lt;/li&gt;
&lt;li&gt;metrics&lt;/li&gt;
&lt;li&gt;event streams&lt;/li&gt;
&lt;li&gt;analytical systems&lt;/li&gt;
&lt;li&gt;real-time analytics workloads&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>clickhouse</category>
      <category>olap</category>
      <category>databasearchitecture</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Why Too Many Parts Hurt ClickHouse Performance</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Mon, 25 May 2026 14:00:25 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/why-too-many-parts-hurt-clickhouse-performance-4c7n</link>
      <guid>https://dev.to/mohhddhassan/why-too-many-parts-hurt-clickhouse-performance-4c7n</guid>
      <description>&lt;p&gt;A lot of people initially think ClickHouse performance problems come from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;large queries&lt;/li&gt;
&lt;li&gt;bad joins&lt;/li&gt;
&lt;li&gt;massive datasets&lt;/li&gt;
&lt;li&gt;missing indexes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And honestly, those things &lt;em&gt;can&lt;/em&gt; matter.&lt;/p&gt;

&lt;p&gt;But one of the most common operational problems in ClickHouse often starts much earlier:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;too many tiny parts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is one of those issues that usually stays invisible at first.&lt;/p&gt;

&lt;p&gt;Then suddenly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;merges fall behind&lt;/li&gt;
&lt;li&gt;queries slow down&lt;/li&gt;
&lt;li&gt;memory usage increases&lt;/li&gt;
&lt;li&gt;inserts become unstable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the cluster starts behaving strangely.&lt;/p&gt;




&lt;h1&gt;
  
  
  Every Insert Creates Parts
&lt;/h1&gt;

&lt;p&gt;This is the first thing that’s important to understand.&lt;/p&gt;

&lt;p&gt;In MergeTree-based engines, ClickHouse stores data as immutable parts.&lt;/p&gt;

&lt;p&gt;Something as simple as:&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;events&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;creates new parts on disk.&lt;/p&gt;

&lt;p&gt;And this is completely normal.&lt;/p&gt;

&lt;p&gt;ClickHouse is designed around this storage model.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;parts themselves are not the problem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The real issue starts when parts begin accumulating faster than merges can stabilize them.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Tiny Inserts Become Dangerous
&lt;/h1&gt;

&lt;p&gt;At smaller scale, tiny inserts may seem harmless.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;inserting row-by-row&lt;/li&gt;
&lt;li&gt;extremely frequent micro-batches&lt;/li&gt;
&lt;li&gt;tiny streaming flush intervals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Initially:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;everything still works.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But over time, the number of parts starts growing aggressively.&lt;/p&gt;

&lt;p&gt;Now ClickHouse has to manage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more metadata&lt;/li&gt;
&lt;li&gt;more merges&lt;/li&gt;
&lt;li&gt;more scheduling&lt;/li&gt;
&lt;li&gt;more file operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates operational overhead.&lt;/p&gt;

&lt;p&gt;Meaning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the system starts spending increasing resources managing fragmentation itself.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Why Merges Matter So Much
&lt;/h1&gt;

&lt;p&gt;ClickHouse relies heavily on background merges.&lt;/p&gt;

&lt;p&gt;These merges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;combine smaller parts&lt;/li&gt;
&lt;li&gt;reduce fragmentation&lt;/li&gt;
&lt;li&gt;improve compression&lt;/li&gt;
&lt;li&gt;optimize query performance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under healthy ingestion patterns, merges naturally keep the system stable over time.&lt;/p&gt;

&lt;p&gt;That is the ideal state.&lt;/p&gt;

&lt;p&gt;But problems start when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parts created per second
        &amp;gt;
parts merged per second
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now fragmented parts begin accumulating faster than ClickHouse can compact them.&lt;/p&gt;

&lt;p&gt;And this is usually where instability slowly starts building.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Dangerous Part Is That It Builds Slowly
&lt;/h1&gt;

&lt;p&gt;This is what makes the issue tricky operationally.&lt;/p&gt;

&lt;p&gt;You usually do not notice the problem immediately.&lt;/p&gt;

&lt;p&gt;The cluster may look perfectly healthy initially.&lt;/p&gt;

&lt;p&gt;Then gradually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;insert latency increases&lt;/li&gt;
&lt;li&gt;merges lag behind&lt;/li&gt;
&lt;li&gt;CPU usage becomes unstable&lt;/li&gt;
&lt;li&gt;queries become heavier&lt;/li&gt;
&lt;li&gt;replication slows down&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And eventually ClickHouse may start throwing errors like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Too many parts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At that point, the merge system is already under serious pressure.&lt;/p&gt;




&lt;h1&gt;
  
  
  Queries Also Become More Expensive
&lt;/h1&gt;

&lt;p&gt;A lot of people think parts only affect inserts.&lt;/p&gt;

&lt;p&gt;But queries suffer too.&lt;/p&gt;

&lt;p&gt;Because queries now need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;open more parts&lt;/li&gt;
&lt;li&gt;scan more metadata&lt;/li&gt;
&lt;li&gt;coordinate more files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even when the actual dataset itself is not massive.&lt;/p&gt;

&lt;p&gt;So sometimes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;performance degradation comes more from fragmentation than raw data volume.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a very important operational insight.&lt;/p&gt;




&lt;h1&gt;
  
  
  FINAL Does Not Really Solve This
&lt;/h1&gt;

&lt;p&gt;One thing that’s important to understand:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FINAL&lt;/code&gt; is not really a solution for too many parts.&lt;/p&gt;

&lt;p&gt;For example:&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;events&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;p&gt;&lt;code&gt;FINAL&lt;/code&gt; applies merge logic during query execution.&lt;/p&gt;

&lt;p&gt;But the fragmented parts still physically exist underneath.&lt;/p&gt;

&lt;p&gt;So if the system already has excessive fragmentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;queries still scan many parts&lt;/li&gt;
&lt;li&gt;merge pressure still exists&lt;/li&gt;
&lt;li&gt;query execution can become heavier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;FINAL can actually become more expensive when fragmentation becomes unhealthy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The real fix is usually improving ingestion and merge behavior itself.&lt;/p&gt;




&lt;h1&gt;
  
  
  Over-Partitioning Can Quietly Make This Worse
&lt;/h1&gt;

&lt;p&gt;Another thing that often accelerates part explosion is overly granular partitioning.&lt;/p&gt;

&lt;p&gt;For example:&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;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;toYYYYMMDDhh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of something broader like:&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;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="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now even small inserts may create parts across many partitions simultaneously.&lt;/p&gt;

&lt;p&gt;Which means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;a single insert can end up creating multiple fragmented parts underneath.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And over time, merge pressure increases much faster than expected.&lt;/p&gt;




&lt;h1&gt;
  
  
  ClickHouse Also Has Ways to Help
&lt;/h1&gt;

&lt;p&gt;Modern ClickHouse versions also support features like async inserts to help reduce excessive tiny-part creation.&lt;/p&gt;

&lt;p&gt;Instead of immediately flushing every small insert into separate parts, ClickHouse can buffer inserts internally before writing larger parts to disk.&lt;/p&gt;

&lt;p&gt;This helps reduce fragmentation and merge pressure in workloads that naturally produce smaller inserts.&lt;/p&gt;

&lt;p&gt;But async inserts are not a replacement for healthy ingestion patterns themselves.&lt;/p&gt;

&lt;p&gt;Stable batching still matters a lot.&lt;/p&gt;




&lt;h1&gt;
  
  
  Why Batch Size Matters So Much
&lt;/h1&gt;

&lt;p&gt;ClickHouse generally performs much better with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;larger batches&lt;/li&gt;
&lt;li&gt;fewer inserts&lt;/li&gt;
&lt;li&gt;healthier merge behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because fewer parts means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fewer merges&lt;/li&gt;
&lt;li&gt;lower metadata overhead&lt;/li&gt;
&lt;li&gt;better compression&lt;/li&gt;
&lt;li&gt;more efficient scans&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is one of the reasons ClickHouse ingestion patterns often look very different from traditional OLTP systems.&lt;/p&gt;




&lt;h1&gt;
  
  
  Too Many Parts Also Affects Startup and Recovery
&lt;/h1&gt;

&lt;p&gt;Another thing people often discover late:&lt;/p&gt;

&lt;p&gt;Large numbers of parts also affect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;startup time&lt;/li&gt;
&lt;li&gt;replication recovery&lt;/li&gt;
&lt;li&gt;metadata loading&lt;/li&gt;
&lt;li&gt;server restarts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because ClickHouse now has to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scan part metadata&lt;/li&gt;
&lt;li&gt;validate parts&lt;/li&gt;
&lt;li&gt;rebuild internal state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;before the server becomes fully operational again.&lt;/p&gt;

&lt;p&gt;So the issue is not just query performance.&lt;/p&gt;

&lt;p&gt;It becomes an overall operational stability problem.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Important Lesson
&lt;/h1&gt;

&lt;p&gt;One thing I’ve noticed with ClickHouse is that many performance problems are actually merge-management problems underneath.&lt;/p&gt;

&lt;p&gt;And too many parts is one of the clearest examples of that.&lt;/p&gt;

&lt;p&gt;Because the issue usually is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“ClickHouse cannot handle large data.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The issue is more often:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;fragmentation and merge pressure slowly became unhealthy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a very different operational problem.&lt;/p&gt;




&lt;h1&gt;
  
  
  Final Thought
&lt;/h1&gt;

&lt;p&gt;ClickHouse is extremely good at handling massive analytical workloads.&lt;/p&gt;

&lt;p&gt;But it performs best when the storage engine is allowed to merge parts efficiently.&lt;/p&gt;

&lt;p&gt;And sometimes the biggest performance problem is not the query itself.&lt;/p&gt;

&lt;p&gt;It is the thousands of tiny fragmented parts quietly building underneath the system over time.&lt;/p&gt;

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