<?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>Disk Is Full, but Your Postgres Tables Are Small (The Replication Slot Trap)</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Thu, 05 Nov 2026 12:36:18 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/disk-is-full-but-your-postgres-tables-are-small-the-replication-slot-trap-p9c</link>
      <guid>https://dev.to/mohhddhassan/disk-is-full-but-your-postgres-tables-are-small-the-replication-slot-trap-p9c</guid>
      <description>&lt;p&gt;Here's a confusing way for a Postgres server to run out of disk. The alert fires, you check your biggest tables, and nothing looks wrong. The database is small. The disk is not.&lt;/p&gt;

&lt;p&gt;The space is going to the &lt;code&gt;pg_wal&lt;/code&gt; directory, and the usual cause is a replication slot that nothing is reading from.&lt;/p&gt;

&lt;p&gt;I reproduced it locally on Postgres 17 so we can watch it happen and then see how to find it and stop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Replication Slot Does
&lt;/h2&gt;

&lt;p&gt;A replication slot is a bookmark. It tells Postgres: "Keep every piece of WAL from this point on until the consumer confirms it has read it."&lt;/p&gt;

&lt;p&gt;The catch is that Postgres keeps the promise even if the consumer never comes back. WAL keeps piling up, and nothing cleans it.&lt;/p&gt;

&lt;p&gt;I used a physical slot for the demo, which is the kind a standby server uses. Logical slots, the kind CDC tools like Debezium use, hold back WAL in the same way, but I didn't test one here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: A Baseline
&lt;/h2&gt;

&lt;p&gt;First I lowered &lt;code&gt;max_wal_size&lt;/code&gt; so the effect would be easy to see, then measured the WAL directory and the database:&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;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;max_wal_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'256MB'&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;pg_reload_conf&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;wal_files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&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="k"&gt;size&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;wal_size&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_ls_waldir&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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_database_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mydb'&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;db_size&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; wal_files | wal_size
-----------+----------
        11 | 176 MB

 db_size
---------
 119 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(A note on &lt;code&gt;max_wal_size&lt;/code&gt;: my &lt;code&gt;SHOW max_wal_size&lt;/code&gt; straight after the reload still printed the old 1GB, and I didn't re-check it. The slot status later in this post reads &lt;code&gt;extended&lt;/code&gt;, which means the WAL had grown past &lt;code&gt;max_wal_size&lt;/code&gt;, so the limit was in effect.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create a Slot Nobody Reads
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_create_physical_replication_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stuck_slot'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&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;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;restart_lsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wal_status&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&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; slot_name  | slot_type | active | restart_lsn | wal_status
------------+-----------+--------+-------------+------------
 stuck_slot | physical  | f      | 0/154F3AB8  | reserved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;true&lt;/code&gt; makes the slot reserve WAL from this point right away. &lt;code&gt;active&lt;/code&gt; is &lt;code&gt;f&lt;/code&gt; because no standby is connected. This is what a dead consumer looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Generate Some Writes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wal_demo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;serial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;wal_demo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="nb"&gt;text&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;generate_series&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;2000000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wal_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CHECKPOINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;CHECKPOINT&lt;/code&gt; matters. Normally that's when Postgres cleans up old WAL, so it's the fair moment to measure.&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;wal_files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&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="k"&gt;size&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;wal_size&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_ls_waldir&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;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wal_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;restart_lsn&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;retained_wal&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&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; wal_files | wal_size
-----------+----------
        61 | 976 MB

 slot_name  | active | wal_status | retained_wal
------------+--------+------------+--------------
 stuck_slot | f      | extended   | 954 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WAL went from 11 files (176 MB) to 61 files (976 MB). The slot alone is holding back 954 MB.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;wal_status&lt;/code&gt; column is worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;reserved&lt;/code&gt;:&lt;/strong&gt; the WAL the slot needs is within &lt;code&gt;max_wal_size&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;extended&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;max_wal_size&lt;/code&gt; has been exceeded, but the files are still kept for the slot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unreserved&lt;/code&gt;:&lt;/strong&gt; the WAL is no longer guaranteed and will be removed at the next checkpoint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lost&lt;/code&gt;:&lt;/strong&gt; WAL the slot needed has already been removed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Make the Tables Small Again
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wal_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CHECKPOINT&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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_database_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mydb'&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;db_size&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;wal_files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&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="k"&gt;size&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;wal_size&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_ls_waldir&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; db_size
---------
 119 MB

 wal_files | wal_size
-----------+----------
        61 | 976 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the symptom from the title. The database is &lt;strong&gt;119 MB&lt;/strong&gt;. The WAL directory is &lt;strong&gt;976 MB&lt;/strong&gt;, about 8x bigger. Dropping the table freed nothing, because the slot still insists on keeping the WAL.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Find It
&lt;/h2&gt;

&lt;p&gt;This is the query I'd keep around:&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;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wal_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;restart_lsn&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;retained_wal&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for a slot with &lt;code&gt;active = f&lt;/code&gt; and a large &lt;code&gt;retained_wal&lt;/code&gt;. That's your suspect. Then check what the slot belongs to before touching it: a standby that's down, or a CDC connector that stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;If the consumer is really gone, drop the slot:&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;pg_drop_replication_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stuck_slot'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CHECKPOINT&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; wal_files | wal_size
-----------+----------
        15 | 240 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WAL fell from 976 MB to 240 MB. It didn't go all the way back to the 176 MB I started with after one checkpoint.&lt;/p&gt;

&lt;p&gt;One warning: dropping a slot means the consumer can no longer resume from where it left off. For a standby, that can mean rebuilding it. For a CDC tool like Debezium, it can mean taking a fresh snapshot. Don't drop a slot just to free disk without checking who owns it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Stop It Happening Again
&lt;/h2&gt;

&lt;p&gt;Postgres has a safety limit, &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;. It caps how much WAL a slot is allowed to hold back. I set it to 300MB and ran the same kind of write load with a fresh slot:&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;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;max_slot_wal_keep_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'300MB'&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;pg_reload_conf&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;pg_create_physical_replication_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'limited_slot'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wal_demo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;serial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;wal_demo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="nb"&gt;text&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;generate_series&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;2000000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CHECKPOINT&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;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wal_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe_wal_size&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&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;  slot_name   | wal_status | safe_wal_size
--------------+------------+---------------
 limited_slot | lost       |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slot went to &lt;code&gt;lost&lt;/code&gt;. Postgres removed WAL the slot still needed, so the disk was protected, but the consumer can no longer catch up.&lt;/p&gt;

&lt;p&gt;That's the trade-off. Without the limit, a dead consumer can fill your disk and take the primary down. With the limit, the primary survives and the consumer has to be rebuilt or re-snapshotted. Most people prefer the second failure, but you should pick it on purpose.&lt;/p&gt;

&lt;p&gt;Also alert on slot lag, so you hear about it long before disk usage does. The query above is a good start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean Up Your Test
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wal_demo&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;pg_drop_replication_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'limited_slot'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;RESET&lt;/span&gt; &lt;span class="n"&gt;max_slot_wal_keep_size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;RESET&lt;/span&gt; &lt;span class="n"&gt;max_wal_size&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;pg_reload_conf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A replication slot makes Postgres keep WAL until the consumer reads it, even if the consumer is dead.&lt;/li&gt;
&lt;li&gt;If the disk is full but your tables are small, check &lt;code&gt;pg_wal&lt;/code&gt; and &lt;code&gt;pg_replication_slots&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;active = f&lt;/code&gt; with a large retained WAL value is the red flag.&lt;/li&gt;
&lt;li&gt;Dropping a slot fixes the disk but can force the consumer to rebuild, so check who owns it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; protects the primary, at the cost of invalidating a slot that falls too far behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These numbers come from a single run in a local Docker container, so your sizes will differ. Run it yourself to see it on your own setup.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Added an Index and Postgres Still Does a Seq Scan (Here's Why)</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:52:35 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/i-added-an-index-and-postgres-still-does-a-seq-scan-heres-why-37cp</link>
      <guid>https://dev.to/mohhddhassan/i-added-an-index-and-postgres-still-does-a-seq-scan-heres-why-37cp</guid>
      <description>&lt;p&gt;You have a slow query. You add an index. You run the query again, check the plan, and there it is: &lt;code&gt;Seq Scan&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The usual reaction is "Postgres is ignoring my index." Sometimes that's true, and something is wrong. But a lot of the time, Postgres made the right call, and the real problem is somewhere else.&lt;/p&gt;

&lt;p&gt;I reproduced five cases locally on Postgres 17 so we can look at real plans instead of guessing.&lt;/p&gt;

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

&lt;p&gt;A table of orders with a million rows. About 95% are &lt;code&gt;completed&lt;/code&gt; and 5% are &lt;code&gt;pending&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;serial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&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;random&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="s1"&gt;'365 days'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&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;1000000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_status&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run every query with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;. That runs the query for real and shows what actually happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Look At in a Plan
&lt;/h2&gt;

&lt;p&gt;Plans look intimidating, but for this post you only need five things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The node type:&lt;/strong&gt; &lt;code&gt;Seq Scan&lt;/code&gt;, &lt;code&gt;Index Scan&lt;/code&gt;, &lt;code&gt;Bitmap Heap Scan&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rows=&lt;/code&gt; in the first parentheses (the estimate) vs the second (the actual).&lt;/strong&gt; A big gap means the planner guessed wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cost=&lt;/code&gt;:&lt;/strong&gt; the planner's estimate in arbitrary units. It picks the plan with the lowest cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Buffers:&lt;/code&gt;&lt;/strong&gt; how many 8 kB pages the query touched. &lt;code&gt;shared hit&lt;/code&gt; means found in cache, &lt;code&gt;read&lt;/code&gt; means fetched from outside shared buffers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Execution Time&lt;/code&gt;&lt;/strong&gt; at the bottom&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Case 1: The Index Exists, and Postgres Is Right to Skip It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&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;Seq Scan on orders  (cost=0.00..19824.00 rows=950933 width=25)
                    (actual time=0.026..134.500 rows=949820 loops=1)
  Filter: (status = 'completed'::text)
  Rows Removed by Filter: 50180
  Buffers: shared hit=7324
Execution Time: 170.498 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an index on &lt;code&gt;status&lt;/code&gt;, and Postgres didn't use it. Why?&lt;/p&gt;

&lt;p&gt;Because this query returns 95% of the table. Going through an index to fetch almost every row is more work than just reading the table from start to finish. Look at the estimate: 950,933 rows predicted, 949,820 actual. The planner knew exactly what it was doing.&lt;/p&gt;

&lt;p&gt;To check, I told Postgres to avoid seq scans and ran it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;enable_seqscan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;RESET&lt;/span&gt; &lt;span class="n"&gt;enable_seqscan&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;Bitmap Heap Scan on orders  (cost=10654.16..29864.82 rows=950933 width=25)
                            (actual time=53.171..159.778 rows=949820 loops=1)
  ...
Execution Time: 207.462 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the index forced, it took &lt;strong&gt;207 ms instead of 170 ms&lt;/strong&gt;. The planner's cost numbers pointed the same way: 19,824 for the seq scan against 29,864 for the bitmap scan.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;enable_seqscan = off&lt;/code&gt; doesn't forbid seq scans. It makes them very expensive in the planner's eyes. It's a diagnostic tool, not something to leave on.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; when a query returns a large share of the table, a Seq Scan is often the fastest plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2: The Same Index, a Selective Query
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;Index Scan using idx_orders_status on orders
  (cost=0.42..6573.73 rows=49067 width=25)
  (actual time=0.127..15.953 rows=50180 loops=1)
  Index Cond: (status = 'pending'::text)
  Buffers: shared hit=7320 read=46
Execution Time: 18.441 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same table, same index, different value. Now Postgres uses the index and finishes in &lt;strong&gt;18 ms&lt;/strong&gt; instead of 170 ms.&lt;/p&gt;

&lt;p&gt;Look at the &lt;code&gt;Buffers&lt;/code&gt; line, though. It touched 7,366 buffers, about the same as the 7,324 the seq scan read. The &lt;code&gt;pending&lt;/code&gt; rows are scattered randomly through the table, so even though only 5% of rows match, the query still visits almost every page.&lt;/p&gt;

&lt;p&gt;My reading is that it's faster mainly because it processes and returns far fewer rows, not because it reads fewer pages. "Few matching rows" doesn't automatically mean "few pages touched."&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 3: The Index Is Fine, the Query Isn't
&lt;/h2&gt;

&lt;p&gt;This is the one that causes real production slowdowns. I added an index on &lt;code&gt;customer_id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now two queries that look like they do the same thing:&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_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;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&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;The first one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bitmap Heap Scan on orders  (cost=5.20..371.40 rows=100 width=25)
                            (actual time=0.140..0.363 rows=104 loops=1)
  Buffers: shared hit=104 read=3
Execution Time: 0.402 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gather  (cost=1000.00..16115.67 rows=5000 width=25)
        (actual time=2.043..53.119 rows=104 loops=1)
  Workers Planned: 2
  -&amp;gt;  Parallel Seq Scan on orders
        Filter: ((customer_id)::text = '42'::text)
        Rows Removed by Filter: 333299
        Buffers: shared hit=7324
Execution Time: 53.164 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same 104 rows. &lt;strong&gt;0.4 ms against 53 ms&lt;/strong&gt;, about 130x slower, and the second query had to bring in parallel workers just to scan the whole table.&lt;/p&gt;

&lt;p&gt;The cast on the column is the reason. The index stores &lt;code&gt;customer_id&lt;/code&gt; values as integers, and &lt;code&gt;customer_id::text&lt;/code&gt; is a different expression that the index knows nothing about, so Postgres can't use it.&lt;/p&gt;

&lt;p&gt;Also look at the estimates. For the second query, the planner expected 5,000 rows and got 104. When you wrap a column in a function, Postgres loses its statistics for it and starts guessing.&lt;/p&gt;

&lt;p&gt;The fix here is the obvious one: compare &lt;code&gt;customer_id = 42&lt;/code&gt; and let the integer index do its job. If you genuinely need to query on an expression, Postgres supports indexes on expressions, but I didn't test that here.&lt;/p&gt;

&lt;p&gt;The same trap shows up with &lt;code&gt;WHERE lower(email) = ...&lt;/code&gt;, &lt;code&gt;WHERE date(created_at) = ...&lt;/code&gt; and similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 4: A Partial Index for the Rare Value
&lt;/h2&gt;

&lt;p&gt;If you only ever query for the rare value, you don't need to index all 1,000,000 rows. You can index only the ones you care about:&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_pending&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'idx_orders_status'&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;full_index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'idx_orders_pending'&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;partial_index&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; full_index | partial_index
------------+---------------
 6904 kB    | 1120 kB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial index is about 6x smaller, because it only contains the 5% of rows where &lt;code&gt;status = 'pending'&lt;/code&gt;. A smaller index means less to store, less to cache, and less to update on every write.&lt;/p&gt;

&lt;h3&gt;
  
  
  What About Indexing &lt;code&gt;created_at&lt;/code&gt; Instead?
&lt;/h3&gt;

&lt;p&gt;A reader suggested a partial index on &lt;code&gt;created_at&lt;/code&gt;, which fits better when you ask for something like the oldest pending orders. I tested it with a query that has an &lt;code&gt;ORDER BY&lt;/code&gt; and a &lt;code&gt;LIMIT&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_pending_created&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;created_at&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the new index, this was the best the planner could do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit  (cost=2952.95..2953.00 rows=20 width=25) (actual time=140.350..140.357 rows=20 loops=1)
  Buffers: shared hit=3 read=7459
  -&amp;gt;  Sort  (...)  Sort Method: top-N heapsort  Memory: 27kB
        -&amp;gt;  Index Scan using idx_orders_pending on orders
              (actual time=1.269..132.395 rows=50180 loops=1)
Execution Time: 140.530 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It read all 50,180 pending rows and sorted them to return 20. After creating the index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit  (cost=0.29..12.33 rows=20 width=25) (actual time=0.112..0.163 rows=20 loops=1)
  Buffers: shared hit=20 read=2
  -&amp;gt;  Index Scan using idx_orders_pending_created on orders
        (cost=0.29..30617.39 rows=50867 width=25)
        (actual time=0.109..0.153 rows=20 loops=1)
Execution Time: 0.200 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No sort, and only 20 rows read. Buffers dropped from about 7,462 to 22. One caveat on timing: the "before" run was a cold-cache run (note &lt;code&gt;read=7459&lt;/code&gt;), so 140 ms exaggerates the gap. The row counts and buffers are the fairer comparison. The new index is the same size as the one on &lt;code&gt;id&lt;/code&gt;, 1120 kB.&lt;/p&gt;

&lt;p&gt;Without the &lt;code&gt;LIMIT&lt;/code&gt;, the planner still picked the &lt;code&gt;id&lt;/code&gt; index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Index Scan using idx_orders_pending on orders  (cost=0.29..1699.30 rows=50867 width=25)
Execution Time: 17.112 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The estimated cost of reading every pending row through the &lt;code&gt;created_at&lt;/code&gt; index was 30,617, against 1,699 for the &lt;code&gt;id&lt;/code&gt; index (see the cost on the Index Scan line in the plan above). So the same partial index is a great fit for "oldest 20 pending orders" and the wrong one for "give me all pending orders." Which index helps depends on the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 5: Does Lowering &lt;code&gt;random_page_cost&lt;/code&gt; Change Anything?
&lt;/h2&gt;

&lt;p&gt;The same reader suggested checking &lt;code&gt;random_page_cost&lt;/code&gt;. The default is 4, and on SSD storage that can make the planner too reluctant to use indexes. I tried 1.1 on the same 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;SHOW&lt;/span&gt; &lt;span class="n"&gt;random_page_cost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- 4&lt;/span&gt;

&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;random_page_cost&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;RESET&lt;/span&gt; &lt;span class="n"&gt;random_page_cost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plans didn't change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;completed&lt;/code&gt;:&lt;/strong&gt; still a &lt;code&gt;Seq Scan&lt;/code&gt;, with the same estimated cost (19824.00) at both settings. A sequential scan doesn't depend on &lt;code&gt;random_page_cost&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pending&lt;/code&gt;:&lt;/strong&gt; still the same &lt;code&gt;Index Scan&lt;/code&gt;. Only its estimated cost moved, from 1699.30 to 1290.40.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The timings need a warning. The &lt;code&gt;completed&lt;/code&gt; query took 191 ms at the default and 145 ms at 1.1, but it ran the identical plan both times, so that gap is run-to-run noise, not the setting. Don't read conclusions from a single run.&lt;/p&gt;

&lt;p&gt;The other caveat is that this table fits entirely in cache (every &lt;code&gt;Buffers&lt;/code&gt; line is &lt;code&gt;hit&lt;/code&gt;), so this test says little about a real SSD system under load. On a production database, change &lt;code&gt;random_page_cost&lt;/code&gt; only after comparing actual query times before and after.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Checklist for the Next Seq Scan You See
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the query returning a big share of the table?&lt;/strong&gt; Then a Seq Scan may be correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare the estimated rows to the actual rows.&lt;/strong&gt; A big gap points to stale statistics (try &lt;code&gt;ANALYZE&lt;/code&gt;) or a function or cast on the column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the column wrapped in a function or cast?&lt;/strong&gt; Rewrite the query, or use an expression index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you only query for a small, specific slice of rows?&lt;/strong&gt; Consider a partial index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not sure the planner chose well?&lt;/strong&gt; Test with &lt;code&gt;SET enable_seqscan = off&lt;/code&gt; in a session, compare the times, then &lt;code&gt;RESET&lt;/code&gt; it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Seq Scan is not automatically a problem. For queries that return most of the table, it's often the best plan.&lt;/li&gt;
&lt;li&gt;The estimated and actual row counts are the fastest way to see if the planner is confused.&lt;/li&gt;
&lt;li&gt;A cast or function on an indexed column can make the index useless, and the damage here was about 130x.&lt;/li&gt;
&lt;li&gt;Few matching rows doesn't always mean few pages read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These numbers come from single runs on my laptop, mostly with a warm cache (the one exception is noted in Case 4), so your timings will differ. The relative differences are what matter, so run the examples yourself.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>backend</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why Your Postgres Table Keeps Growing Even Though You're Deleting Rows</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:35:20 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/why-your-postgres-table-keeps-growing-even-though-youre-deleting-rows-53lh</link>
      <guid>https://dev.to/mohhddhassan/why-your-postgres-table-keeps-growing-even-though-youre-deleting-rows-53lh</guid>
      <description>&lt;p&gt;A while back I noticed something odd. A table with a modest number of rows was taking far more disk space than it should. &lt;code&gt;SELECT count(*)&lt;/code&gt; said one thing, &lt;code&gt;pg_relation_size&lt;/code&gt; said another.&lt;/p&gt;

&lt;p&gt;I had deleted and updated plenty of rows, so where was the space going? The answer is &lt;strong&gt;MVCC&lt;/strong&gt;, and once you see it, a lot of Postgres behavior starts to make sense.&lt;/p&gt;

&lt;p&gt;Let's reproduce it locally and watch it happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Short Version
&lt;/h2&gt;

&lt;p&gt;In Postgres, an &lt;code&gt;UPDATE&lt;/code&gt; does not change a row in place, and a &lt;code&gt;DELETE&lt;/code&gt; does not remove it. Both just leave the old version of the row behind. Those leftover versions are called &lt;strong&gt;dead tuples&lt;/strong&gt;, and they keep taking up space until something cleans them up.&lt;/p&gt;

&lt;p&gt;That "something" is &lt;code&gt;VACUUM&lt;/code&gt;. But even &lt;code&gt;VACUUM&lt;/code&gt; doesn't do exactly what most people expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Postgres Works This Way
&lt;/h2&gt;

&lt;p&gt;Postgres uses MVCC (Multi-Version Concurrency Control) so that readers never block writers and writers never block readers.&lt;/p&gt;

&lt;p&gt;If a transaction is reading a row while another one updates it, the reader needs to keep seeing the old version. So Postgres keeps both versions: the old one for anyone who still needs it, and a new one for everyone else.&lt;/p&gt;

&lt;p&gt;Every row version carries two hidden columns, &lt;code&gt;xmin&lt;/code&gt; (the transaction that created it) and &lt;code&gt;xmax&lt;/code&gt; (the transaction that deleted or replaced it). Postgres uses these to decide which version each transaction is allowed to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Reproduce It
&lt;/h2&gt;

&lt;p&gt;Start with a simple table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;  &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Turn off autovacuum for this table so we can see the raw behavior&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autovacuum_enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&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;generate_series&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;100000&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;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the size:&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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bloat_demo'&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="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;live_rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my machine (running Postgres 17 in docker) this showed 6672 kB for 100,000 rows. Your number may differ slightly by version and settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Watch a row move
&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'changed'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ctid&lt;/code&gt; (the physical location of the row) changes after the update. The row didn't get modified in place. Postgres wrote a brand new version somewhere else and left the old one behind.&lt;/p&gt;

&lt;p&gt;In my run, the &lt;code&gt;ctid&lt;/code&gt; went from &lt;code&gt;(0,1)&lt;/code&gt; to &lt;code&gt;(833,41)&lt;/code&gt;. The table was about 834 pages at that point, so the new version landed on the very last page. Postgres appended it at the end of the file instead of rewriting the row where it was. The &lt;code&gt;xmin&lt;/code&gt; also changed (742 to 754), because a different transaction created this version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Update everything, a few times
&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now check again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bloat_demo'&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="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;live_rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still 100,000 live rows, but the table went from 6672 kB to 26 MB, about 4x bigger. Every pass left a full copy of dead rows behind.&lt;/p&gt;

&lt;p&gt;You can see them in the stats:&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'bloat_demo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(These stats can lag by a moment, so if &lt;code&gt;n_dead_tup&lt;/code&gt; looks low, wait a few seconds and run it again.)&lt;/p&gt;

&lt;p&gt;My run showed &lt;code&gt;n_dead_tup&lt;/code&gt; = 299962 next to 100,000 live rows: roughly three full copies of the table, one per update pass. It isn't exactly 300,000 because these numbers are estimates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What VACUUM Actually Does
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the size query again. Here is the part that surprises most people: &lt;strong&gt;the file usually does not shrink.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VACUUM&lt;/code&gt; marks the space taken by dead tuples as reusable &lt;em&gt;inside the table&lt;/em&gt;. New inserts and updates will fill that space instead of extending the file. But the file on disk stays the same size, because Postgres only gives space back to the OS in one narrow case: when completely empty pages sit at the very end of the table.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;VACUUM&lt;/code&gt; stops the table from growing further. It does not make it smaller.&lt;/p&gt;

&lt;p&gt;Here is the proof from my run. After &lt;code&gt;VACUUM&lt;/code&gt; the size was still 26 MB. Then I updated every row once more:&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;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bloat_demo'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It stayed at 26 MB. The new row versions went into the space vacuum had freed, instead of extending the file. That is what &lt;code&gt;VACUUM&lt;/code&gt; is for: it keeps the table from growing, it doesn't shrink it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting the space back
&lt;/h3&gt;

&lt;p&gt;If you really need to shrink the file, you have two main options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VACUUM FULL&lt;/code&gt;&lt;/strong&gt; rewrites the whole table into a new file. It works, but it takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, which blocks reads and writes for the whole duration. Not something you want to run casually on a busy production table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_repack&lt;/code&gt;&lt;/strong&gt; does a similar rewrite with much shorter locks. It is an extension you have to install, but it is the usual choice for live systems.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran &lt;code&gt;VACUUM FULL&lt;/code&gt; on the demo table and the size dropped from 26 MB back to 6672 kB, exactly where it started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotcha: Long-Running Transactions
&lt;/h2&gt;

&lt;p&gt;Here is the one that causes real production pain. &lt;code&gt;VACUUM&lt;/code&gt; can only remove a dead tuple if &lt;strong&gt;no transaction could still need to see it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If someone leaves a transaction open for hours (a forgotten &lt;code&gt;BEGIN&lt;/code&gt; in a psql session, or an app stuck in &lt;code&gt;idle in transaction&lt;/code&gt;), vacuum has to keep every row version that transaction might still read. Dead tuples pile up and vacuum runs but cleans almost nothing.&lt;/p&gt;

&lt;p&gt;Find the offenders:&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;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&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;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;open_for&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;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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;xact_start&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the oldest transaction on the list has been open for hours, that is probably your bloat problem.&lt;/p&gt;

&lt;p&gt;Here is what this looked like in my test. In a second psql session I opened a transaction and left it open:&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;BEGIN&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;txid_current&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;-- returned 761&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back in the first session I updated every row and ran a verbose vacuum:&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;bloat_demo&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="k"&gt;VERBOSE&lt;/span&gt; &lt;span class="n"&gt;bloat_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant lines of the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tuples: 0 removed, 200000 remain, 100000 are dead but not yet removable
removable cutoff: 761
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vacuum ran fine and removed nothing. The 100,000 old row versions have to stay, because transaction 761 might still need to see them. The cutoff of 761 is the ID of that open transaction. A second &lt;code&gt;VACUUM VERBOSE&lt;/code&gt; gave the same result. Ending the transaction with &lt;code&gt;COMMIT&lt;/code&gt; or &lt;code&gt;ROLLBACK&lt;/code&gt; lifts the block.&lt;/p&gt;

&lt;p&gt;After I ran &lt;code&gt;ROLLBACK&lt;/code&gt; in the second session, the same command gave a different result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tuples: 100000 removed, 100000 remain, 0 are dead but not yet removable
removable cutoff: 763
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same table, same &lt;code&gt;VACUUM&lt;/code&gt;. This time it removed all 100,000 dead versions, and the cutoff moved from 761 to 763 now that the old transaction was gone. The only thing that changed was the open transaction.&lt;/p&gt;

&lt;p&gt;One detail: a bare &lt;code&gt;BEGIN&lt;/code&gt; with nothing inside it doesn't hold vacuum back. What does is the transaction's ID or snapshot. I ran &lt;code&gt;txid_current()&lt;/code&gt; inside mine, which assigns it an ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  It's Not Always a psql Session
&lt;/h3&gt;

&lt;p&gt;In my test the blocker was a plain open transaction, and the cutoff in &lt;code&gt;VACUUM VERBOSE&lt;/code&gt; (761) matched its &lt;code&gt;txid_current()&lt;/code&gt;. But the same "dead but not yet removable" line can come from other places that never show up in your list of connections. I only tested the open-transaction case above. The rest are things to check on a real system.&lt;/p&gt;

&lt;p&gt;Thanks to &lt;a href="https://dev.to/arhancanli"&gt;Arhan Canli&lt;/a&gt; for pointing these out in the comments.&lt;/p&gt;

&lt;p&gt;Take the &lt;code&gt;removable cutoff&lt;/code&gt; number from &lt;code&gt;VACUUM VERBOSE&lt;/code&gt; and look for what is holding that ID:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Open transactions and snapshots&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="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend_xid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend_xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_xid&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;backend_xmin&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backend_xmin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Replication slots that stopped consuming&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="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;catalog_xmin&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An inactive slot with an old &lt;code&gt;xmin&lt;/code&gt; or &lt;code&gt;catalog_xmin&lt;/code&gt; holds cleanup back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Standbys with &lt;code&gt;hot_standby_feedback = on&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With this setting, the replica reports its oldest snapshot to the primary, so a long query on the standby can block vacuum on the primary.&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;application_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend_xmin&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_replication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Forgotten prepared transactions&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="n"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prepared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_prepared_xacts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever one has an ID matching your cutoff is the culprit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stopping the forgotten-session case&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For app connections that get stuck open, set a timeout so Postgres terminates the session instead of letting bloat build up:&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;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5min'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that time Postgres closes the session, so your app needs to handle the dropped connection and retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Check Your Own Database
&lt;/h2&gt;

&lt;p&gt;A quick look at which tables have the most dead tuples:&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dead_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&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;n_dead_tup&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A high &lt;code&gt;dead_pct&lt;/code&gt; together with an old &lt;code&gt;last_autovacuum&lt;/code&gt; means autovacuum is not keeping up (or is being blocked, see above).&lt;/p&gt;

&lt;p&gt;This is an estimate based on statistics, not an exact measurement. For precise numbers, the &lt;code&gt;pgstattuple&lt;/code&gt; extension can scan a table and report real bloat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Took Away From This
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; never free space immediately. They create dead tuples.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VACUUM&lt;/code&gt; makes that space reusable, but it doesn't shrink the file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VACUUM FULL&lt;/code&gt; shrinks it, at the cost of blocking the table.&lt;/li&gt;
&lt;li&gt;Long-running transactions silently stop vacuum from doing its job.&lt;/li&gt;
&lt;li&gt;Tables with heavy update traffic are the ones to watch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next time a table looks bigger than its data should justify, start with &lt;code&gt;n_dead_tup&lt;/code&gt; and &lt;code&gt;pg_stat_activity&lt;/code&gt; before touching anything else.&lt;/p&gt;

&lt;p&gt;Have you run into table bloat in production? I'd like to hear how you found it.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>What Actually Happens When You Start a Goroutine?</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Tue, 06 Oct 2026 07:16:41 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/what-actually-happens-when-you-start-a-goroutine-56a1</link>
      <guid>https://dev.to/mohhddhassan/what-actually-happens-when-you-start-a-goroutine-56a1</guid>
      <description>&lt;p&gt;Here's a program. It starts a million goroutines, parks every one of them, and then asks the kernel how many threads the process has.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitGroup&lt;/span&gt;
&lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="c"&gt;// park here, all million alive at once&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"goroutines:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NumGoroutine&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OS threads:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take a guess before you scroll.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;goroutines: 1000001
OS threads: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three.&lt;/p&gt;

&lt;p&gt;So what did &lt;code&gt;go&lt;/code&gt; actually do a million times, if it wasn't starting threads?&lt;/p&gt;

&lt;p&gt;This post answers that with six small programs. Every number below comes from running them. You can run them too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why any of this exists:&lt;/strong&gt; most languages give you one OS thread per unit of work, and threads are expensive - megabytes of stack, real kernel scheduling overhead. So you either pay that cost per task, or restructure everything around callbacks/async-await to avoid it. Go's pitch is that you keep writing plain, blocking-looking code, and the runtime multiplexes thousands of these onto a handful of real threads for you. That claim is exactly what the experiment above just tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go syntax used below, once:&lt;/strong&gt; &lt;code&gt;go f()&lt;/code&gt; starts a goroutine - a concurrently-running call to &lt;code&gt;f&lt;/code&gt;. &lt;code&gt;ch &amp;lt;- v&lt;/code&gt; sends on a channel, &lt;code&gt;v := &amp;lt;-ch&lt;/code&gt; receives; both block until the other side is ready. &lt;code&gt;wg.Add&lt;/code&gt;/&lt;code&gt;.Done&lt;/code&gt;/&lt;code&gt;.Wait&lt;/code&gt; is a counter that blocks until it hits zero. That's the entire vocabulary this post needs. If none of it is new, skip straight to Experiment 1.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go 1.27.1&lt;/strong&gt;, released September 2026&lt;/li&gt;
&lt;li&gt;Linux amd64, &lt;strong&gt;one vCPU&lt;/strong&gt;, on purpose, so nobody gets to say "it just used more cores"&lt;/li&gt;
&lt;li&gt;Thread counts come from the &lt;code&gt;Threads:&lt;/code&gt; line of &lt;code&gt;/proc/self/status&lt;/code&gt;. That's the kernel's number, not Go's.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`Threads:\s+(\d+)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/proc/self/status"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Atoi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindSubmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran everything on Go 1.22.2 first and then again on 1.27.1. The picture was the same in both. The only visible difference is one extra column in the scheduler trace.&lt;/p&gt;

&lt;p&gt;The baseline thread count wobbles between 3 and 5 from run to run. Compare the change, not the absolute value.&lt;/p&gt;

&lt;p&gt;All six programs, plus a script that runs them, are in one repo: &lt;code&gt;https://github.com/mohhddhassan/go-goroutine-experiments&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Experiment 1: How Many Threads Does a Goroutine Cost?
&lt;/h2&gt;

&lt;p&gt;The program from the top, looped over N and run twice: once with &lt;code&gt;GOMAXPROCS=1&lt;/code&gt;, once with &lt;code&gt;GOMAXPROCS=8&lt;/code&gt;. It also measures heap plus stack before and after, and how long the spawn loop took.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go run ./01-million
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;goroutines alive&lt;/th&gt;
&lt;th&gt;OS threads, &lt;code&gt;GOMAXPROCS=1&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;OS threads, &lt;code&gt;GOMAXPROCS=8&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;memory each&lt;/th&gt;
&lt;th&gt;spawn cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2.9 KB&lt;/td&gt;
&lt;td&gt;1.6 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2.7 KB&lt;/td&gt;
&lt;td&gt;2.3 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2.9 KB&lt;/td&gt;
&lt;td&gt;3.2 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1,000,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.9 KB&lt;/td&gt;
&lt;td&gt;3.4 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The thread count ignored the goroutine count completely. It followed &lt;code&gt;GOMAXPROCS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A goroutine costs about 2.9 KB and a couple of microseconds. The spawn cost creeps up as N grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Meet G, M and P
&lt;/h2&gt;

&lt;p&gt;Three letters explain that table.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;G&lt;/strong&gt;: a goroutine. A small struct plus a stack that starts at 2 KB. The kernel has never heard of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;M&lt;/strong&gt;: an OS thread. The only thing the kernel schedules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;P&lt;/strong&gt;: a scheduling context. There are exactly &lt;code&gt;GOMAXPROCS&lt;/code&gt; of them, and an M needs one to run Go code.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;go process()
  -&amp;gt; a G is created
  -&amp;gt; it goes on a P's run queue
  -&amp;gt; an M holding that P runs it
  -&amp;gt; the OS schedules the M like any other thread
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Goroutines are unbounded. Ps are not. That's the gap the table fell into.&lt;/p&gt;

&lt;p&gt;Now to check that story instead of just believing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Experiment 2: Where Does a New Goroutine Wait?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GODEBUG=schedtrace=N&lt;/code&gt; makes the runtime print its own state every N milliseconds.&lt;/p&gt;

&lt;p&gt;The program starts 200 CPU-bound goroutines, all from &lt;code&gt;main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="n"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wait&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 shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GODEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;schedtrace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 go run ./02-runqueues
&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;SCHED 1ms: gomaxprocs=4 idleprocs=2 threads=5 runqueue=0 [ 0 0 0 74 ]
SCHED 5ms: gomaxprocs=4 idleprocs=0 threads=5 runqueue=0 [ 17 18 31 32 ]
SCHED 9ms: gomaxprocs=4 idleprocs=0 threads=5 runqueue=0 [ 17 18 31 130 ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runqueue&lt;/code&gt; is the global queue. The bracket is one local run queue per P.&lt;/p&gt;

&lt;p&gt;At 1 ms, &lt;code&gt;main&lt;/code&gt; has queued 74 goroutines on the P it's running on. The other three Ps are empty.&lt;/p&gt;

&lt;p&gt;At 5 ms, those three Ps hold 17, 18 and 31 goroutines. The global queue is still 0.&lt;/p&gt;

&lt;p&gt;Nobody put those goroutines there. &lt;code&gt;main&lt;/code&gt; only ever queued onto its own P. The other Ps &lt;strong&gt;took them&lt;/strong&gt; from it. That's work stealing.&lt;/p&gt;

&lt;p&gt;At 9 ms the numbers add up: 17 + 18 + 31 + 130 = 196, plus the 4 currently running, makes 200.&lt;/p&gt;

&lt;p&gt;Now the same program with one P:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GODEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;schedtrace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100 &lt;span class="nv"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 go run ./02-runqueues
&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;SCHED 100ms: gomaxprocs=1 runqueue=5  [ 194 ] schedticks=[ 9 ]
SCHED 201ms: gomaxprocs=1 runqueue=10 [ 189 ] schedticks=[ 14 ]
SCHED 302ms: gomaxprocs=1 runqueue=15 [ 184 ] schedticks=[ 19 ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every 100 ms, five goroutines move from the local queue to the global one. &lt;code&gt;schedticks&lt;/code&gt;, the number of times this P picked something to run, goes up by five.&lt;/p&gt;

&lt;p&gt;Five switches per 100 ms.&lt;/p&gt;

&lt;p&gt;Something is interrupting these goroutines. Who?&lt;/p&gt;




&lt;h2&gt;
  
  
  Experiment 3: Who Decides When a Goroutine Stops?
&lt;/h2&gt;

&lt;p&gt;Two goroutines spin on one P. Each one watches the clock, and whenever it notices it was off the CPU for more than a millisecond, it writes down for how long.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// in each of the two goroutines:&lt;/span&gt;
&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&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;for&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&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;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;gaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// we were descheduled for d&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;last&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;/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;go run ./03-timeslice
&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;98 descheduled gaps
min=15.3ms  median=20.2ms  p90=20.2ms  max=40.3ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each goroutine runs for about 20 ms and then the other one gets the P.&lt;/p&gt;

&lt;p&gt;That matches the trace above. Five switches per 100 ms is 20 ms each. Two different measurements, one number.&lt;/p&gt;

&lt;p&gt;The runtime's sysmon thread checks every ~10 ms and sets a preemption flag on any G running &amp;gt;10 ms - so a goroutine can run almost a full extra tick before sysmon catches it, which is why the observed number lands closer to 20 ms than 10.&lt;/p&gt;

&lt;p&gt;Nothing in those loops asked to be interrupted. So what about a loop with no function calls at all, where the runtime has no natural place to step in?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}()&lt;/span&gt;
&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&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="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"main resumed after %v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&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;go run ./04-tightloop
main resumed after 116ms

GODEBUG=asyncpreemptoff=1 timeout 5 go run ./04-tightloop
(nothing. killed by the timeout after 5 s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Function calls use compiler-inserted stack checks for cooperative preemption; tight loops (for {}) require Go 1.14+ OS signals (SIGURG).&lt;/p&gt;




&lt;h2&gt;
  
  
  Experiment 4: What Happens When a Goroutine Blocks?
&lt;/h2&gt;

&lt;p&gt;I expected "blocked" to cost about the same everywhere.&lt;/p&gt;

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

&lt;p&gt;One program, five ways to get stuck. 200 goroutines each, &lt;code&gt;GOMAXPROCS=1&lt;/code&gt;, sampled while they're all stuck and again 1.5 seconds after they finish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go build &lt;span class="nt"&gt;-o&lt;/span&gt; blocking ./05-blocking
&lt;span class="nv"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 ./blocking rawread 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stuck on&lt;/th&gt;
&lt;th&gt;threads before&lt;/th&gt;
&lt;th&gt;threads during&lt;/th&gt;
&lt;th&gt;1.5 s after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;channel receive&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;time.Sleep&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCP socket &lt;code&gt;Read&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU-bound loop&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;raw &lt;code&gt;read(2)&lt;/code&gt; on a pipe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;203&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;203&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four rows cost nothing. One cost a thread per goroutine.&lt;/p&gt;

&lt;p&gt;The goroutines in the last row do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// blocks in the kernel&lt;/span&gt;
&lt;span class="p"&gt;}(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&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;Scaled up to 1,000 goroutines each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TCP socket read     threads: 5 -&amp;gt; 5
raw read(2)         threads: 4 -&amp;gt; 1003 -&amp;gt; 1004
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's going on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Channels and timers&lt;/strong&gt; are runtime bookkeeping. The goroutine parks, and no thread is involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network reads&lt;/strong&gt; go through the netpoller. The socket is non-blocking, the goroutine parks, and epoll wakes it up. A thousand idle connections cost zero extra threads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A raw syscall&lt;/strong&gt; blocks the thread inside the kernel. That M is stuck, so the runtime hands its P to another M to keep everything else running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That handoff is what stops one blocked goroutine from freezing the rest. The price is one OS thread per stuck goroutine.&lt;/p&gt;

&lt;p&gt;And the threads don't leave. 203 was still 203 after 1.5 seconds. The runtime keeps them around for reuse.&lt;/p&gt;




&lt;h2&gt;
  
  
  Experiment 5: What Does &lt;code&gt;GOMAXPROCS&lt;/code&gt; Control?
&lt;/h2&gt;

&lt;p&gt;Eight CPU-bound goroutines, identical work, one core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in &lt;/span&gt;1 2 8&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nv"&gt;GOMAXPROCS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt; go run ./06-gomaxprocs&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GOMAXPROCS&lt;/th&gt;
&lt;th&gt;wall time&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;1.140 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.139 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1.138 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Identical. &lt;code&gt;GOMAXPROCS&lt;/code&gt; doesn't create CPUs.&lt;/p&gt;

&lt;p&gt;It sets the number of Ps, meaning how many threads may run Go code at the same moment. On a machine with real cores that's your parallelism limit. Here there was one core, so raising the limit changed nothing.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real parallelism.&lt;/strong&gt; One vCPU. I can show what &lt;code&gt;GOMAXPROCS&lt;/code&gt; sets, not the speedup it unlocks. On a multi-core box, run experiment 5 yourself and tell me what you get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Individual steals.&lt;/strong&gt; I inferred stealing from queue lengths. &lt;code&gt;go tool trace&lt;/code&gt; would show each steal directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cgo and disk reads.&lt;/strong&gt; I measured a pipe read. I believe they take the same path, but I didn't test them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other platforms.&lt;/strong&gt; Everything here is Linux. The thread counting reads &lt;code&gt;/proc&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;Goroutines are cheap. They aren't free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A million goroutines is about 2.9 GB.&lt;/strong&gt; 2.9 KB each sounds like nothing until you multiply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thread per stuck goroutine adds up.&lt;/strong&gt; The runtime caps threads at 10,000 by default. 1,000 raw reads already got to 1,003. I didn't push it to the cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Cheap to start" says nothing about what it does next.&lt;/strong&gt; Every thread in this post came from what the goroutines did after they started.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;&lt;code&gt;go process()&lt;/code&gt; doesn't start a thread. It starts a chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;go process()
  -&amp;gt; a G is allocated                     (~2.9 KB, ~2-3 µs)
  -&amp;gt; queued on the P that ran the go statement
  -&amp;gt; idle Ps steal from it                (seen within ~5 ms)
  -&amp;gt; an M holding that P runs it          (threads follow GOMAXPROCS, not goroutines)
  -&amp;gt; interrupted about every 20 ms        (tight loops included)
  -&amp;gt; parks on a channel, timer or socket  (0 extra threads)
  -&amp;gt; or blocks in a raw syscall           (its P moves to another M: 1 thread each)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A goroutine isn't a lightweight thread. It's a job the runtime schedules onto a handful of threads, and it asks the OS for more only when something blocks in the kernel.&lt;/p&gt;

&lt;p&gt;What costs a thread isn't &lt;code&gt;go process()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's what &lt;code&gt;process()&lt;/code&gt; does once it's running.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next in this series: what actually happens when a Go channel blocks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>backend</category>
      <category>concurrency</category>
      <category>programming</category>
    </item>
    <item>
      <title>When Should You Actually Worry About a Growing Replication Queue?</title>
      <dc:creator>Mohamed Hussain S</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:51:04 +0000</pubDate>
      <link>https://dev.to/mohhddhassan/when-should-you-actually-worry-about-a-growing-replication-queue-3ff5</link>
      <guid>https://dev.to/mohhddhassan/when-should-you-actually-worry-about-a-growing-replication-queue-3ff5</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/mohhddhassan/understanding-the-replication-queue-in-clickhouse-gl6"&gt;Last time&lt;/a&gt;, the takeaway was simple: a non-zero replication queue isn't a problem statement, it's a starting point for one. It just means a replica has pending work - fetches, merges, mutations - that Keeper says it needs to do.&lt;/p&gt;

&lt;p&gt;That still leaves the actual question unanswered though:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When does a queue go from "normal" to "something's actually wrong"?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where you stop looking at the queue as a single number and start treating it as a signal with context - trend, task type, retries, and the exception behind it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Refresher
&lt;/h2&gt;

&lt;p&gt;Before diving in, the short version from last time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every replica has its &lt;strong&gt;own&lt;/strong&gt; local replication queue&lt;/li&gt;
&lt;li&gt;The queue is generated from Keeper's replication log&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;system.replicas&lt;/code&gt; gives you the summary (&lt;code&gt;queue_size&lt;/code&gt;, &lt;code&gt;absolute_delay&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;system.replication_queue&lt;/code&gt; gives you the detail (task type, retries, &lt;code&gt;last_exception&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that in place, here's how to actually reason about a growing queue.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule #1: A Snapshot Means Almost Nothing
&lt;/h2&gt;

&lt;p&gt;If you check &lt;code&gt;queue_size&lt;/code&gt; once and see a number, you've learned almost nothing about whether the replica is healthy.&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 = 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;could mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a large batch of inserts just landed, and the replica is about to chew through all 20 tasks in seconds&lt;/li&gt;
&lt;li&gt;the replica has been stuck on the same 20 tasks for the last hour&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same number. Completely different situations.&lt;/p&gt;

&lt;p&gt;The only way to tell the difference is to &lt;strong&gt;sample it more than once&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="n"&gt;queue_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;absolute_delay&lt;/span&gt;&lt;span class="p"&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;AS&lt;/span&gt; &lt;span class="n"&gt;checked_at&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this a few times, a few minutes apart, and look at the shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10:00 → queue_size = 20
10:05 → queue_size = 20
10:10 → queue_size = 20
10:15 → queue_size = 21
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not "a queue of 20." That's &lt;strong&gt;a queue that isn't moving, and is now growing&lt;/strong&gt;. That's the actual signal worth investigating - not the number itself.&lt;/p&gt;

&lt;p&gt;If instead 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;10:00 → queue_size = 20
10:05 → queue_size = 6
10:10 → queue_size = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that's a replica doing exactly what it's supposed to do. Nothing to chase there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule #2: Queue Size and Replication Lag Are Not the Same Metric
&lt;/h2&gt;

&lt;p&gt;It's easy to conflate these two, but 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;Metric&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;queue_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How many tasks are currently pending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;absolute_delay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How far behind (in seconds) the replica actually is&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A replica can have a nonzero queue and &lt;em&gt;low&lt;/em&gt; delay - because it's processing fast enough that it never really falls behind in any meaningful sense.&lt;/p&gt;

&lt;p&gt;A replica can also have a &lt;em&gt;small&lt;/em&gt; queue and still be meaningfully behind, if the pending tasks are old and just haven't moved.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;system.replicas&lt;/code&gt; also exposes:&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;replica_name&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;queue_oldest_time&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table'&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;queue_oldest_time&lt;/code&gt; tells you when the &lt;strong&gt;oldest&lt;/strong&gt; task in the queue was created. If that timestamp is recent, the queue is young - probably fine. If it's from an hour ago and the queue hasn't shrunk, you're looking at a genuinely stuck replica, not just a busy one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule #3: &lt;code&gt;num_tries&lt;/code&gt; Tells You If a Task Is Actually Failing
&lt;/h2&gt;

&lt;p&gt;Every entry in &lt;code&gt;system.replication_queue&lt;/code&gt; carries a &lt;code&gt;num_tries&lt;/code&gt; column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&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_attempt_time&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;num_tries&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A task with &lt;code&gt;num_tries = 0&lt;/code&gt; or &lt;code&gt;1&lt;/code&gt; just hasn't been picked up yet - could be totally normal, especially right after a burst of inserts.&lt;/p&gt;

&lt;p&gt;A task with &lt;code&gt;num_tries = 40&lt;/code&gt; and climbing is a task that keeps &lt;strong&gt;failing and retrying&lt;/strong&gt;. That's a completely different problem than "the replica is just busy." Something is preventing that specific task from completing, and it will keep retrying until whatever's blocking it is resolved.&lt;/p&gt;

&lt;p&gt;This is usually the fastest way to tell "backlog" apart from "broken."&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule #4: &lt;code&gt;last_exception&lt;/code&gt; Is the Real Diagnostic
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;num_tries&lt;/code&gt; tells you &lt;em&gt;that&lt;/em&gt; something is failing, &lt;code&gt;last_exception&lt;/code&gt; tells you &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Common categories you'll actually run into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network / connectivity issues&lt;/strong&gt; - a replica can't reach the source replica to fetch a part&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk space&lt;/strong&gt; - not enough room to write the incoming part&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing or corrupted parts&lt;/strong&gt; - the part a task depends on isn't available where expected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too many parts&lt;/strong&gt; - ClickHouse throttling merges/inserts because part count exceeds configured limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeper session issues&lt;/strong&gt; - the replica lost its session with Keeper and can't coordinate properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these show up in &lt;code&gt;queue_size&lt;/code&gt;. They only show up when you actually read the exception text.&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;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;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;WHERE&lt;/span&gt; &lt;span class="n"&gt;last_exception&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;num_tries&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;p&gt;If this query returns nothing, your queue is backed up but not actively failing - it's just working through volume. If it returns rows, you now know exactly what's blocking progress, and you can go fix that specific thing instead of guessing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rule #5: Don't Restart Blind
&lt;/h2&gt;

&lt;p&gt;It's tempting, when a queue looks bad, to reach straight for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;RESTART&lt;/span&gt; &lt;span class="n"&gt;REPLICA&lt;/span&gt; &lt;span class="n"&gt;your_table&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sometimes that's the right move - for example, if the replica's Keeper session got wedged and a restart lets it re-establish state cleanly.&lt;/p&gt;

&lt;p&gt;But if the actual problem is disk space, a missing part on another replica, or a network issue between nodes, restarting doesn't fix any of that. It just resets the local state and lets the same tasks fail again, possibly after masking the original exception in the process.&lt;/p&gt;

&lt;p&gt;The order that actually works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the queue is genuinely stuck (not just busy) - check the trend&lt;/li&gt;
&lt;li&gt;Look at &lt;code&gt;num_tries&lt;/code&gt; to find which tasks are actually failing, not just pending&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;last_exception&lt;/code&gt; for those tasks&lt;/li&gt;
&lt;li&gt;Fix the underlying cause&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; consider whether a restart or &lt;code&gt;SYSTEM SYNC REPLICA&lt;/code&gt; is needed to help it recover&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skipping straight to step 5 is how you end up chasing the same issue again a few hours later.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;When a queue looks concerning, this is the order I actually go through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is it growing, or just non-zero?&lt;/strong&gt; - sample &lt;code&gt;queue_size&lt;/code&gt; more than once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How old is the oldest task?&lt;/strong&gt; - check &lt;code&gt;queue_oldest_time&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is &lt;code&gt;absolute_delay&lt;/code&gt; moving too?&lt;/strong&gt; - queue size and lag should usually track loosely; if delay is flat while queue climbs, something's off&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are tasks retrying?&lt;/strong&gt; - check &lt;code&gt;num_tries&lt;/code&gt; per task, not just the total count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does &lt;code&gt;last_exception&lt;/code&gt; say?&lt;/strong&gt; - this is almost always where the real answer is&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it one bad task or the whole queue?&lt;/strong&gt; - a single stuck &lt;code&gt;GET_PART&lt;/code&gt; blocking everything behind it looks very different from broad, evenly-spread failures&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six questions, in that order, will get you to the actual root cause faster than staring at a single &lt;code&gt;queue_size&lt;/code&gt; number ever will.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Takeaway
&lt;/h2&gt;

&lt;p&gt;A replication queue growing isn't inherently bad - replicas fall behind and catch up constantly, especially under bursty write patterns. The number alone was never designed to tell you whether that's fine or not.&lt;/p&gt;

&lt;p&gt;What actually tells you is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is the queue &lt;strong&gt;moving&lt;/strong&gt;, and if it isn't, what does &lt;code&gt;last_exception&lt;/code&gt; say about why?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the real question behind "should I worry about this." Everything else - &lt;code&gt;queue_size&lt;/code&gt;, &lt;code&gt;absolute_delay&lt;/code&gt;, &lt;code&gt;num_tries&lt;/code&gt; - is just context that helps you answer it faster.&lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>distributedsystems</category>
      <category>devops</category>
    </item>
    <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 is out, 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;
  
  
  Where 26.8 stands
&lt;/h2&gt;

&lt;p&gt;26.8 LTS was announced on 27 August and first tagged on 30 August (&lt;code&gt;v26.8.1.2041-lts&lt;/code&gt;). As of 29 September it's already at 26.8.14, so the usual wait for the first few patch releases has mostly played out. Target a recent patch, not &lt;code&gt;.1&lt;/code&gt;. 26.3 LTS is still getting patches too (26.3.35 as of 28 September), so the timing is yours to choose.&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;. The table shows raw changelog counts; two other sections carry the same backport duplicates (new features: 199 → 197 unique; performance: 416 → 415), and the prose uses the unique figures. Counted on 29 September 2026 against the finalised changelog.&lt;/p&gt;

&lt;p&gt;On top of all that, roughly 100 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;h3&gt;
  
  
  Response format header overrides &lt;code&gt;FORMAT&lt;/code&gt; (26.8)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;X-ClickHouse-Format&lt;/code&gt; header is now an alias for &lt;code&gt;output_format&lt;/code&gt;, so it overrides both the query's &lt;code&gt;FORMAT&lt;/code&gt; clause and the URL path extension. Before, it only set a default when nothing else specified a format. A client or proxy that sends it alongside a query with its own &lt;code&gt;FORMAT&lt;/code&gt; now gets the header's format. It never changes how an &lt;code&gt;INSERT&lt;/code&gt; body is parsed.&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;Upstream's own guidance also allows setting &lt;code&gt;compatibility&lt;/code&gt; below &lt;code&gt;26.8&lt;/code&gt; instead of the explicit pin, until every replica is upgraded.&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. 415 performance improvements across the five releases, many 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 ~100 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. 415 performance improvements, many of them 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 29 September 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>
  </channel>
</rss>
