<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>boringSQL | Supercharge your SQL &amp; PostgreSQL powers - internals</title>
    <subtitle>Learn practical SQL &amp; PostgreSQL techniques. Build rock-solid data systems with &#39;boring&#39; database solutions that deliver reliability without the drama.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://boringsql.com/tags/internals/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://boringsql.com"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-10-11T20:30:00+00:00</updated>
    <id>https://boringsql.com/tags/internals/atom.xml</id>
    <entry xml:lang="en">
        <title>How TABLESAMPLE picks rows</title>
        <published>2026-10-11T20:30:00+00:00</published>
        <updated>2026-10-11T20:30:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/tablesample/"/>
        <id>https://boringsql.com/posts/tablesample/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/tablesample/">&lt;p&gt;Most exploratory questions against a large table only need a rough answer, but without an index on &lt;code&gt;status&lt;/code&gt;, even &quot;roughly how many shipped orders&quot; costs a full scan. On the two-million-row &lt;code&gt;orders&lt;/code&gt; table, Postgres has to read every single 8 kB page, all 18,085 of them (141 MB), just to count matching rows.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;shipped&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Aggregate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Buffers: shared hit=18085&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Seq Scan on orders (actual rows=500000.00 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Filter: (status = &amp;#39;shipped&amp;#39;::text)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Rows Removed by Filter: 1500000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Execution Time: 56.188 ms&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;shared hit=18085&lt;/code&gt; is one buffer access per page, every page found already in memory (&lt;a href=&quot;/posts/explain-buffers/&quot;&gt;Reading Buffer statistics in EXPLAIN output&lt;/a&gt; goes through the counters). At 141 MB of heap size that&#39;s nothing. Take a 1 TB table with 134 million pages: a scan reads all of them from disk, every time, however small the answer is.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Postgres reads tables larger than a quarter of &lt;code&gt;shared_buffers&lt;/code&gt; through a small ring buffer. See more in &lt;a href=&quot;/posts/introduction-to-buffers/&quot;&gt;introduction to buffers&lt;/a&gt;.&lt;/div&gt;
At least Postgres avoids flushing its own cache by reading large tables through a small ring buffer. Nevertheless the disk still perform full read. On a primary server handling production traffic is shared with everything else. When all you need is exploratory confirmation &quot;roughly how many&quot; or &quot;are there any representative samples&quot; reading the whole terabyte is too expensive.
&lt;p&gt;SQL Standard comes with &lt;code&gt;TABLESAMPLE&lt;/code&gt; to help with that. It goes in the &lt;code&gt;FROM&lt;/code&gt; clause right after the table name, and the rest of the query runs against the subset of pages it picks.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  id, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;/span&gt;&lt;span&gt;, created_at, amount&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REPEATABLE&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;7&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 5&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; id  |  status   |       created_at       | amount&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----+-----------+------------------------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 429 | delivered | 2024-01-01 00:07:09+00 |  38.46&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 430 | delivered | 2024-01-01 00:07:10+00 | 150.72&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 431 | delivered | 2024-01-01 00:07:11+00 | 295.59&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 432 | delivered | 2024-01-01 00:07:12+00 | 488.23&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 433 | delivered | 2024-01-01 00:07:13+00 | 238.84&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The value &lt;code&gt;(1)&lt;/code&gt; is a percentage, and the size of the sample isn&#39;t fixed: five different seeds returned between 18,107 and 20,416 rows. If you need an exact number of rows, &lt;code&gt;tsm_system_rows&lt;/code&gt; in contrib adds a &lt;code&gt;SYSTEM_ROWS (n)&lt;/code&gt; method. The rows come back in physical order, which is why these five are neighbours, ids 429 to 433. And whatever you compute is computed over the sample, so totals have to be scaled back up by hand, &lt;code&gt;count(*)&lt;/code&gt; and &lt;code&gt;sum()&lt;/code&gt; times 100, while averages, ratios and extremes are read as they come.&lt;/p&gt;
&lt;p&gt;Here&#39;s the shipped-orders count again, scaled up, with its buffer numbers:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 100&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;/span&gt;&lt;span&gt; orders TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;shipped&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Aggregate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Buffers: shared hit=177&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Sample Scan on orders (actual rows=4920.00 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Sampling: system (&amp;#39;1&amp;#39;::real)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Filter: (status = &amp;#39;shipped&amp;#39;::text)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Execution Time: 0.829 ms&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;177 pages instead of 18,085, and the estimate comes out at 492,000 against the real 500,000. For counts like this one it holds up. The rows are inserted one per second in &lt;code&gt;created_at&lt;/code&gt; order, though, so the oldest row sits at the very start of the table. Ask the same 1% sample for it:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; min&lt;/span&gt;&lt;span&gt;(created_at) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Six runs:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 02:51:13+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 01:36:19+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 01:27:24+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 04:41:47+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 02:51:13+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 00:44:36+00&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The real answer is &lt;code&gt;00:00:01&lt;/code&gt;. The sample is off by anything from 44 minutes to almost five hours, and runs one and five agree to the second, which a sample of random rows would almost never do. Everything odd about &lt;code&gt;TABLESAMPLE&lt;/code&gt; comes from one fact: it doesn&#39;t pick rows, it picks addresses.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;a class=&quot;zola-anchor&quot; href=&quot;#setup&quot; aria-label=&quot;Anchor link for: setup&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Everything in this article was captured on PostgreSQL 18.6 using the &lt;code&gt;postgres:18&lt;/code&gt; image.&lt;code&gt;shared_buffers = 1GB&lt;/code&gt; and a warm cache, so the timings are for comparing methods against each other, not for predicting yours.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pageinspect;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id          &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    customer_id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint        NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status      text          NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at  &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz   NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    amount      &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; setseed(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;42&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; orders (customer_id, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;/span&gt;&lt;span&gt;, created_at, amount)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; (random()&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 50000&lt;/span&gt;&lt;span&gt;)::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint +&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;       CASE WHEN&lt;/span&gt;&lt;span&gt; i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1300000&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;delivered&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;            WHEN&lt;/span&gt;&lt;span&gt; i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1800000&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;shipped&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;            WHEN&lt;/span&gt;&lt;span&gt; i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1900000&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;cancelled&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;            ELSE&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;       timestamptz&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2024-01-01 00:00:00+00&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;/span&gt;&lt;span&gt; i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt; interval &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 second&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;       round&lt;/span&gt;&lt;span&gt;((random()&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;/span&gt;&lt;span&gt;)::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2000000&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM ANALYZE orders;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- the same rows in random physical order&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders_shuffled&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS SELECT * FROM&lt;/span&gt;&lt;span&gt; orders &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span&gt; random();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM ANALYZE orders_shuffled;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customers&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS SELECT&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50001&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; id;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;/span&gt;&lt;span&gt; customers &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD PRIMARY KEY&lt;/span&gt;&lt;span&gt; (id);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- a small copy with room on every page, for the pageinspect step&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; h&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIKE&lt;/span&gt;&lt;span&gt; orders INCLUDING ALL) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (fillfactor &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 70&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; h OVERRIDING &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM VALUE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; orders &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1000&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ORDER BY&lt;/span&gt;&lt;span&gt; id;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;every-row-has-an-address&quot;&gt;Every row has an address&lt;a class=&quot;zola-anchor&quot; href=&quot;#every-row-has-an-address&quot; aria-label=&quot;Anchor link for: every-row-has-an-address&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The pages are numbered from 0, and right after its header each page keeps an array of line pointers, one per row, numbered from 1 (&lt;a href=&quot;/posts/inside-the-8kb-page/&quot;&gt;Inside PostgreSQL&#39;s 8KB Page&lt;/a&gt; takes one apart byte by byte). Page number and line pointer together are the row&#39;s &lt;code&gt;ctid&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; ctid, id, created_at &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ctid  | id |       created_at&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+----+------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; (0,1) |  1 | 2024-01-01 00:00:01+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; (0,2) |  2 | 2024-01-01 00:00:02+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; (0,3) |  3 | 2024-01-01 00:00:03+00&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both built-in sampling methods hash part of that address together with a seed and keep whatever hashes under a 1% cutoff. They differ only in which part. &lt;code&gt;SYSTEM&lt;/code&gt;, in &lt;code&gt;src/backend/access/tablesample/system.c&lt;/code&gt;, hashes the page number (trimmed to the lines that matter):&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;c&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;hashinput&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span&gt; nextblock;&lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;          /* page */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;hashinput&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span&gt; sampler&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt;seed;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;hash &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; DatumGetUInt32&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;hash_any&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;const unsigned char *&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; hashinput&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                               (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; sizeof&lt;/span&gt;&lt;span&gt;(hashinput)));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;if&lt;/span&gt;&lt;span&gt; (hash &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span&gt; sampler&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt;cutoff)&lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;        /* keep the whole page */&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;BERNOULLI&lt;/code&gt;, in &lt;code&gt;bernoulli.c&lt;/code&gt;, hashes the page and the line pointer number:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;c&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;hashinput&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span&gt; blockno;&lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;            /* page */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;hashinput&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span&gt; tupoffset;&lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;          /* line pointer */&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;hashinput&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span&gt; sampler&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span&gt;seed;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So &lt;code&gt;SYSTEM&lt;/code&gt; flips one coin per page and takes every row on a winning page. &lt;code&gt;BERNOULLI&lt;/code&gt; flips one coin per line pointer, which means it has to open every page to see how many line pointers there are. That&#39;s the whole performance story: on this table &lt;code&gt;SYSTEM&lt;/code&gt; answers in 1.2 ms and &lt;code&gt;BERNOULLI&lt;/code&gt; in 10.4 ms.&lt;/p&gt;
&lt;h2 id=&quot;pages-come-as-bundles&quot;&gt;Pages come as bundles&lt;a class=&quot;zola-anchor&quot; href=&quot;#pages-come-as-bundles&quot; aria-label=&quot;Anchor link for: pages-come-as-bundles&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;A page here holds about 107 rows, and since the rows went in by time, one page is 107 consecutive seconds of orders. Group a &lt;code&gt;SYSTEM&lt;/code&gt; sample by the page half of &lt;code&gt;ctid&lt;/code&gt; and the bundles are right there:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; (ctid::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;point&lt;/span&gt;&lt;span&gt;)[0]::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int AS page&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS rows&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;       min&lt;/span&gt;&lt;span&gt;(created_at)::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;time AS first&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;max&lt;/span&gt;&lt;span&gt;(created_at)::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;time AS last&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REPEATABLE&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;7&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ORDER BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 4&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; page | rows |  first   |   last&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------+------+----------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    4 |  107 | 00:07:09 | 00:08:55&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   38 |  107 | 01:07:47 | 01:09:33&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   57 |  107 | 01:41:40 | 01:43:26&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  265 |  107 | 07:52:36 | 07:54:22&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sample isn&#39;t 20,000 scattered seconds. It&#39;s about 200 short strips of the timeline, and &lt;code&gt;min()&lt;/code&gt; returns the start of whichever strip the hash kept first. Runs one and five above kept the same first page.&lt;/p&gt;
&lt;p&gt;Shuffle the table and the problem disappears. &lt;code&gt;orders_shuffled&lt;/code&gt; holds the same rows written in &lt;code&gt;ORDER BY random()&lt;/code&gt; order, so every page is a handful of seconds from all over the three weeks, and the same query lands within a few minutes of the truth every time:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 00:00:06+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 00:01:20+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 00:01:43+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 00:01:24+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 00:02:50+00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-01-01 00:05:07+00&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;BERNOULLI&lt;/code&gt; on the original, time-ordered table does just as well, because a coin per line pointer never bundles anything.&lt;/p&gt;
&lt;p&gt;Nobody samples for a minimum, but the bundles hurt averages the same way. Across 200 seeds, &lt;code&gt;avg(created_at)&lt;/code&gt; from a &lt;code&gt;SYSTEM&lt;/code&gt; sample wanders &lt;strong&gt;ten times&lt;/strong&gt; as far as the same average from &lt;code&gt;BERNOULLI&lt;/code&gt;. The reason is plain once you think in pages: 107 rows from one page tell you about as much about the timeline as one row from that page would, so a &lt;code&gt;SYSTEM&lt;/code&gt; sample of 20,000 rows has roughly the precision of a 200-row one. A column that has nothing to do with where rows sit, like a random &lt;code&gt;amount&lt;/code&gt;, averages identically under both methods. Time columns, ever-increasing ids, and anything loaded in batches are the columns where it matters.&lt;/p&gt;
&lt;h2 id=&quot;repeatable-remembers-addresses&quot;&gt;REPEATABLE remembers addresses&lt;a class=&quot;zola-anchor&quot; href=&quot;#repeatable-remembers-addresses&quot; aria-label=&quot;Anchor link for: repeatable-remembers-addresses&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;REPEATABLE (42)&lt;/code&gt; fixes the seed. Same seed, same hash, same addresses. Whether those addresses still hold the same rows depends on what happened to the table in between, and the address model predicts every line of this table. Each run captures a sample with seed 42, changes the table, captures again, and counts how many rows from the first sample are still in it:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;after&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;SYSTEM kept&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;BERNOULLI kept&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;append 200k rows&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;17,595 of 17,595&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;19,800 of 19,800&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;delete half the rows&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;8,795 of 8,795&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;10,003 of 10,003&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;VACUUM&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;8,795 of 8,795&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;10,003 of 10,003&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;UPDATE&lt;/code&gt; every row&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;8,740 of 8,795&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;&lt;strong&gt;109 of 10,003&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;VACUUM FULL&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;113 of 8,795&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;113 of 10,003&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;CLUSTER&lt;/code&gt; on a reversed index&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0 of 8,795&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;95 of 10,003&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Appends create new addresses and deletes empty old ones; no surviving row moves, so nothing changes. &lt;code&gt;VACUUM&lt;/code&gt; marks dead line pointers unused and compacts the tuple data inside each page, but it never renumbers a line pointer that still points at a live row (&lt;a href=&quot;/posts/vacuum-at-the-page-level/&quot;&gt;VACUUM at the Page Level&lt;/a&gt; walks through each phase). &lt;code&gt;VACUUM FULL&lt;/code&gt; and &lt;code&gt;CLUSTER&lt;/code&gt; build a fresh copy of the table, every row gets a new address, and both samples are effectively redrawn.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UPDATE&lt;/code&gt; is the interesting line. Postgres never overwrites a row in place: an update writes a new version, and when the page has room and no indexed column changed, it puts that version on the same page without touching any index. That&#39;s a HOT update (&lt;a href=&quot;/posts/hot-updates/&quot;&gt;HOT Updates in Postgres&lt;/a&gt;), and here 99% of them were. Same page means &lt;code&gt;SYSTEM&lt;/code&gt; doesn&#39;t notice. But the new version gets a new line pointer, and &lt;code&gt;pageinspect&lt;/code&gt; shows it for one row of &lt;code&gt;h&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; ctid &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; h &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- (0,3)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; h &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; amount &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span&gt; amount &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; ctid &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; h &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- (0,76)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, lp_flags, lp_off, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;h&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;)) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; lp &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;76&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_flags | lp_off | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+----------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |        2 |     76 |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 76 |        1 |   2792 | (0,76)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Line pointer 3 is now &lt;code&gt;LP_REDIRECT&lt;/code&gt; (&lt;code&gt;lp_flags = 2&lt;/code&gt;), a signpost that says &quot;moved to 76&quot; so index entries pointing at &lt;code&gt;(0,3)&lt;/code&gt; still find the row. &lt;code&gt;BERNOULLI&lt;/code&gt; doesn&#39;t read signposts. It hashes line pointer 76, which is a fresh 1% coin flip, so almost every updated row drops out of the sample and a different 1% drops in.&lt;/p&gt;
&lt;h2 id=&quot;hashing-the-key-instead&quot;&gt;Hashing the key instead&lt;a class=&quot;zola-anchor&quot; href=&quot;#hashing-the-key-instead&quot; aria-label=&quot;Anchor link for: hashing-the-key-instead&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;When people pin a seed, they usually want a development or test subset that stays the same from one day to the next. An address can&#39;t promise that, because updates and rewrites move rows. A primary key doesn&#39;t move, so hash the key:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; orders &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; (hashint8extended(id, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;42&lt;/span&gt;&lt;span&gt;) &amp;amp; &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1023&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;hashint8extended&lt;/code&gt; hashes a bigint with a seed; keeping 10 of 1,024 buckets returns 0.98% of the rows. Through the same changes it kept every surviving row at every step, &lt;strong&gt;9,916 of 9,916&lt;/strong&gt; after the delete, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;VACUUM FULL&lt;/code&gt; and &lt;code&gt;CLUSTER&lt;/code&gt; included. It has no bundles either: its 200-seed spread on &lt;code&gt;avg(created_at)&lt;/code&gt; matches &lt;code&gt;BERNOULLI&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A key can also be a foreign key. Sample &lt;code&gt;customers&lt;/code&gt; on &lt;code&gt;hashint8extended(id, 42)&lt;/code&gt; and &lt;code&gt;orders&lt;/code&gt; on &lt;code&gt;hashint8extended(customer_id, 42)&lt;/code&gt;, and you get 484 customers with all 19,432 of their orders. The same two tables sampled with &lt;code&gt;SYSTEM (1) REPEATABLE (42)&lt;/code&gt; leave 17,517 of the 17,595 sampled orders pointing at a customer who isn&#39;t in the sample.&lt;/p&gt;
&lt;p&gt;The price is a full scan, since the hash has to be computed for every row: 44.4 ms serially, 21.5 ms with two parallel workers. For a subset you pull often, an expression index on &lt;code&gt;(hashint8extended(id, 42) &amp;amp; 1023)&lt;/code&gt; (14 MB here) turns it into a bitmap scan at 8.5 ms, with the seed fixed in the index definition.&lt;/p&gt;
&lt;h2 id=&quot;the-join-doesn-t-shrink&quot;&gt;The join doesn&#39;t shrink&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-join-doesn-t-shrink&quot; aria-label=&quot;Anchor link for: the-join-doesn-t-shrink&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Everything so far read one table. Join the orders to their customers and count:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders o &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;/span&gt;&lt;span&gt; customers c &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The full join answers in 84.4 ms from a parallel hash join with two workers: 18,751 buffer hits, all but 666 of them on &lt;code&gt;orders&lt;/code&gt;. Sample the orders side:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 100&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders o TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REPEATABLE&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;7&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;/span&gt;&lt;span&gt; customers c &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Aggregate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Buffers: shared hit=424&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Hash Join (actual rows=22316.00 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Hash Cond: (o.customer_id = c.id)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Sample Scan on orders o (actual rows=22316.00 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Sampling: system (&amp;#39;1&amp;#39;::real)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Buffers: shared hit=202&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Hash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Buckets: 65536  Batches: 1  Memory Usage: 2270kB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Buffers: shared hit=222&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               -&amp;gt;  Seq Scan on customers c (actual rows=50001.00 loops=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Buffers: shared hit=222&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Execution Time: 25.590 ms&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sample scan did its part, 202 pages against the full 18,085. The hash over &lt;code&gt;customers&lt;/code&gt; still reads all 222 of its pages, because &lt;code&gt;TABLESAMPLE&lt;/code&gt; only shrinks the table it sits on, and building the hash is 11.5 ms of the 25.6. The query gets 3.3× faster, not 90×. Most of what remains is the table the sample never touched. The workers are gone too: a sample scan runs in the leader process, so the plan falls back to serial however big the table is.&lt;/p&gt;
&lt;p&gt;Forcing the other join shape moves the cost rather than removing it:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; enable_hashjoin &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= off&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; enable_mergejoin &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= off&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same query then runs 22,316 index-only probes against &lt;code&gt;customers_pkey&lt;/code&gt;, 44,636 buffer hits against the sample&#39;s own 202, and finishes in 21.6 ms. Every sampled row pays full price for its customer lookup.&lt;/p&gt;
&lt;p&gt;A predicate on the far side works normally: adding &lt;code&gt;WHERE c.id &amp;lt;= 500&lt;/code&gt; builds the hash over 500 rows and answers in 1.5 ms. Some questions break outright, though. Every one of the 50,001 customers has orders, but the 1% draw sees:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DISTINCT&lt;/span&gt;&lt;span&gt; customer_id) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REPEATABLE&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;7&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; count&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 18034&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Multiplying by 100 can&#39;t bring back a customer whose orders all landed on losing pages. A sample scales sums and counts of what it kept; anything you have to see to count comes out low.&lt;/p&gt;
&lt;p&gt;Sampling the join output directly isn&#39;t an option: &lt;code&gt;TABLESAMPLE&lt;/code&gt; attaches to one table in the &lt;code&gt;FROM&lt;/code&gt; clause, and putting it on a join or a subquery is a syntax error. Sampling both sides is as close as you can get, and it&#39;s close to useless:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; orders o TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REPEATABLE&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;7&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;/span&gt;&lt;span&gt; customers c TABLESAMPLE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REPEATABLE&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;7&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; count&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   288&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The real join has two million rows. Two independent address draws keep 288 of them, because a sampled order finds its customer only when both page hashes kept the matching pair. When a sampled pair of tables has to stay joinable, hashing the foreign key on both sides is the way to do it.&lt;/p&gt;
&lt;h2 id=&quot;which-to-reach-for&quot;&gt;Which to reach for&lt;a class=&quot;zola-anchor&quot; href=&quot;#which-to-reach-for&quot; aria-label=&quot;Anchor link for: which-to-reach-for&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;SYSTEM&lt;/code&gt; for a quick number over a column that has nothing to do with insert order. &lt;code&gt;BERNOULLI&lt;/code&gt; when it might, and reading the whole table once is affordable. A hashed key when the subset has to be the same rows tomorrow, or has to keep its foreign keys intact. None of them when you need a filtered subset: the sample is chosen before your &lt;code&gt;WHERE&lt;/code&gt; runs, so the filter only trims a draw that was already made.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ANALYZE&lt;/code&gt; builds &lt;code&gt;pg_stats&lt;/code&gt; from a sample too, with its own block sampler and a seed you don&#39;t get to set, which is why the numbers move between runs even when the data hasn&#39;t.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Postgres relation files, filed under base/</title>
        <published>2026-10-09T09:30:00+00:00</published>
        <updated>2026-10-09T09:30:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/files-forks-and-relfilenodes/"/>
        <id>https://boringsql.com/posts/files-forks-and-relfilenodes/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/files-forks-and-relfilenodes/">&lt;p&gt;The &lt;a href=&quot;/posts/introduction-to-buffers/&quot;&gt;buffers article&lt;/a&gt; followed an 8KB page from disk into shared memory and back. What it didn&#39;t do is name the file. Every page that passes through the buffer pool is block number N of some file under the data directory, and the file has a naming scheme, a size limit, and companion files that share its name. Before the &lt;a href=&quot;/posts/inside-the-8kb-page/&quot;&gt;next article&lt;/a&gt; reads the inside of a page, this one finds it on disk with &lt;code&gt;ls&lt;/code&gt; and &lt;code&gt;od&lt;/code&gt;, and shows that the bytes are the same ones.&lt;/p&gt;
&lt;p&gt;Nothing here needs more than a scratch database and shell access to its data directory. The examples were captured on PostgreSQL 18.6 with data checksums on, which is the default since 18.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;a class=&quot;zola-anchor&quot; href=&quot;#setup&quot; aria-label=&quot;Anchor link for: setup&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pageinspect;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; files_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    title &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    value numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; files_demo (title, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;value&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;row_&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; i, i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A thousand short rows and a primary key. The &lt;code&gt;text&lt;/code&gt; column is there so a TOAST table gets created.&lt;/p&gt;
&lt;h2 id=&quot;from-table-name-to-file-name&quot;&gt;From table name to file name&lt;a class=&quot;zola-anchor&quot; href=&quot;#from-table-name-to-file-name&quot; aria-label=&quot;Anchor link for: from-table-name-to-file-name&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The function that answers &quot;where is this table on disk&quot; ships with the server:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_filepath(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; pg_relation_filepath&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; base/5/16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The path is relative to the data directory (&lt;code&gt;SHOW data_directory&lt;/code&gt; prints it). Three parts: &lt;code&gt;base&lt;/code&gt; is the directory for the default tablespace, &lt;code&gt;5&lt;/code&gt; is the OID of the database, and &lt;code&gt;16431&lt;/code&gt; is the file&#39;s name. That last number is the &lt;strong&gt;relfilenode&lt;/strong&gt;, and it comes from &lt;code&gt;pg_class&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT oid&lt;/span&gt;&lt;span&gt;, relfilenode, relname&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIKE&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;files_demo%&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  oid  | relfilenode |      relname&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------+-------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16431 |       16431 | files_demo&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16430 |       16430 | files_demo_id_seq&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16438 |       16438 | files_demo_pkey&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three relations, three files. The identity column&#39;s sequence and the primary key index each get their own, because to the storage layer a sequence is one-page relation and an index is a relation like any other. The &lt;code&gt;oid&lt;/code&gt; and &lt;code&gt;relfilenode&lt;/code&gt; columns are equal for all three, which stays true only until the table is first rewritten (see OID versus relfilenode below).&lt;/p&gt;
&lt;p&gt;The middle part of the path, &lt;code&gt;5&lt;/code&gt;, is the OID of the current database from &lt;code&gt;pg_database&lt;/code&gt;. Each database gets its own subdirectory under &lt;code&gt;base/&lt;/code&gt;, and a table&#39;s file lives in the directory of the database it belongs to. That is the whole reason the database OID is part of the path: relfilenodes are only unique within a tablespace and database, so two databases can each have a file called &lt;code&gt;16431&lt;/code&gt; without colliding.&lt;/p&gt;
&lt;p&gt;On disk, the directory looks like the catalog:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -l base/5/16431 base/5/16438 base/5/16430&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres  8192 Sep 11 20:44 base/5/16430&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 57344 Sep 11 20:44 base/5/16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 40960 Sep 11 20:44 base/5/16438&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pg_relation_size&lt;/code&gt; gets its numbers the same way: it does nothing more than &lt;code&gt;stat&lt;/code&gt; the files.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;base/5&lt;/code&gt; directory holds a few hundred files on a fresh database before any of ours, almost all of them system catalogs and their indexes, and every one follows the same scheme.&lt;/p&gt;
&lt;h2 id=&quot;the-files-you-didn-t-create&quot;&gt;The files you didn&#39;t create&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-files-you-didn-t-create&quot; aria-label=&quot;Anchor link for: the-files-you-didn-t-create&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;There is a function that goes the other way, from a file number back to a name. Point it at the numbers between and around ours:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; n, pg_filenode_relation(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;, n)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; unnest(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;/span&gt;&lt;span&gt;[16430, 16431, 16436, 16437, 16438]) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; n;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   n   |     pg_filenode_relation&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16430 | files_demo_id_seq&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16431 | files_demo&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16436 | pg_toast.pg_toast_16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16437 | pg_toast.pg_toast_16431_index&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16438 | files_demo_pkey&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(5 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first argument is the tablespace OID, where &lt;code&gt;0&lt;/code&gt; means the database&#39;s default. Two relations we didn&#39;t ask for appear: a TOAST table and its index. Because &lt;code&gt;files_demo&lt;/code&gt; has a &lt;code&gt;text&lt;/code&gt; column, PostgreSQL created a place to put values too large for a page. They&#39;re ordinary files, &lt;code&gt;16436&lt;/code&gt; and &lt;code&gt;16437&lt;/code&gt;, sitting next to the main table, and nothing has been toasted yet, so the TOAST heap has no pages. The catalog links them:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT oid&lt;/span&gt;&lt;span&gt;, relname, relkind, relfilenode, reltoastrelid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE oid IN&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;16431&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;16436&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;16437&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  oid  |       relname        | relkind | relfilenode | reltoastrelid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+----------------------+---------+-------------+---------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16431 | files_demo           | r       |       16431 |         16436&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16436 | pg_toast_16431       | t       |       16436 |             0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16437 | pg_toast_16431_index | i       |       16437 |             0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So one &lt;code&gt;CREATE TABLE&lt;/code&gt; with a primary key produced five relations and five files. &lt;code&gt;pg_table_size&lt;/code&gt; covers the heap, its TOAST relations, and the forks described next; &lt;code&gt;pg_indexes_size&lt;/code&gt; covers the indexes; &lt;code&gt;pg_total_relation_size&lt;/code&gt; is the sum of those two. The sequence is its own relation and is counted by none of them.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
Two kinds of relation break the one-relation-one-file pattern. Views, partitioned tables, and foreign tables have no storage at all: their &lt;code&gt;relfilenode&lt;/code&gt; is 0 and &lt;code&gt;pg_relation_filepath()&lt;/code&gt; returns NULL. Temporary tables do have files, in the same directory under names of the form &lt;code&gt;t&amp;lt;backend&amp;gt;_&amp;lt;relfilenode&amp;gt;&lt;/code&gt;. &lt;code&gt;pg_relation_filepath()&lt;/code&gt; resolves another session&#39;s temp table too, by OID or by its schema-qualified &lt;code&gt;pg_temp_N&lt;/code&gt; name; only reading its contents fails.
&lt;/div&gt;
&lt;h2 id=&quot;forks&quot;&gt;Forks&lt;a class=&quot;zola-anchor&quot; href=&quot;#forks&quot; aria-label=&quot;Anchor link for: forks&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Each relation has a main file and up to three companion files that share its relfilenode and differ by a suffix. PostgreSQL calls them forks. The one with no suffix is the main fork, the table&#39;s actual pages. After the INSERTs and a &lt;code&gt;VACUUM&lt;/code&gt;, &lt;code&gt;files_demo&lt;/code&gt; has three of the four:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM files_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; fork,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, fork) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; bytes,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, fork) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 8192&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;/span&gt;&lt;span&gt; pages&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; unnest(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;/span&gt;&lt;span&gt;[&amp;#39;main&amp;#39;, &amp;#39;fsm&amp;#39;, &amp;#39;vm&amp;#39;, &amp;#39;init&amp;#39;]) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; fork;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; fork | bytes | pages&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------+-------+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; main | 57344 |     7&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; fsm  | 24576 |     3&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; vm   |  8192 |     1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; init |     0 |     0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -l base/5/16431*&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 57344 Sep 11 20:44 base/5/16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 24576 Sep 11 20:44 base/5/16431_fsm&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres  8192 Sep 11 20:44 base/5/16431_vm&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;16431_fsm&lt;/code&gt; is the &lt;strong&gt;free space map&lt;/strong&gt;, a small tree that records roughly how much room each heap page has left so an INSERT can find a page without scanning them all. It&#39;s three pages here for a seven-page table, which looks disproportionate until you know the map is a three-level tree and each level needs at least one page. &lt;code&gt;16431_vm&lt;/code&gt; is the &lt;strong&gt;visibility map&lt;/strong&gt;, two bits per heap page: all-visible, so a scan can skip visibility checks, and all-frozen, so VACUUM can skip the page for wraparound purposes. The &lt;a href=&quot;/posts/vacuum-at-the-page-level/&quot;&gt;VACUUM article&lt;/a&gt; reads both of these with &lt;code&gt;pg_freespacemap&lt;/code&gt; and &lt;code&gt;pg_visibility&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The fourth fork, &lt;code&gt;init&lt;/code&gt;, is empty for this table because &lt;code&gt;files_demo&lt;/code&gt; is a normal, logged table. An unlogged table gets one:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; UNLOGGED &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TABLE&lt;/span&gt;&lt;span&gt; files_unlogged (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;, note &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; files_unlogged &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;scratch&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_filepath(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_unlogged&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; pg_relation_filepath&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; base/5/16444&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -l base/5/16444* base/5/16447* base/5/16448*&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 8192 Sep 11 20:44 base/5/16444&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres    0 Sep 11 20:44 base/5/16444_init&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres    0 Sep 11 20:44 base/5/16447&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres    0 Sep 11 20:44 base/5/16447_init&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 8192 Sep 11 20:44 base/5/16448&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 8192 Sep 11 20:44 base/5/16448_init&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The init fork is the state the relation is reset to after a crash. An unlogged table skips the write-ahead log, so after unclean shutdown its contents can&#39;t be trusted and PostgreSQL replaces the main fork with a copy of the init fork. For a heap that copy is an empty file, which is why &lt;code&gt;16444_init&lt;/code&gt; is zero bytes. &lt;code&gt;16447&lt;/code&gt; and &lt;code&gt;16448&lt;/code&gt; are the unlogged table&#39;s TOAST heap and TOAST index, created the same way &lt;code&gt;16436&lt;/code&gt; and &lt;code&gt;16437&lt;/code&gt; were, and the pattern repeats: the two heaps get a zero-byte init fork, while the TOAST index at &lt;code&gt;16448&lt;/code&gt; gets a full 8192 bytes, because an empty B-tree still needs its metapage.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Forks are per relation.&lt;/strong&gt; The TOAST table &lt;code&gt;16436&lt;/code&gt;, being a heap, can grow its own &lt;code&gt;_fsm&lt;/code&gt; and &lt;code&gt;_vm&lt;/code&gt;. The index &lt;code&gt;16438&lt;/code&gt; can grow an &lt;code&gt;_fsm&lt;/code&gt;, which B-trees use to track pages freed by deletion, but never a &lt;code&gt;_vm&lt;/code&gt;, because visibility is a heap concept.
&lt;/div&gt;
&lt;h2 id=&quot;the-same-bytes-two-ways&quot;&gt;The same bytes, two ways&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-same-bytes-two-ways&quot; aria-label=&quot;Anchor link for: the-same-bytes-two-ways&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pageinspect&lt;/code&gt;&#39;s &lt;code&gt;get_raw_page&lt;/code&gt; returns a page as it sits in shared buffers. The file on disk is supposed to contain the same page at the same offset. Read the first 24 bytes of block 0 both ways, starting with the buffer:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lsn, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;checksum&lt;/span&gt;&lt;span&gt;, flags, lower, upper, special, pagesize, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;version&lt;/span&gt;&lt;span&gt;, prune_xid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    lsn    | checksum | flags | lower | upper | special | pagesize | version | prune_xid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------+----------+-------+-------+-------+---------+----------+---------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 0/186E9B0 |        0 |     4 |   680 |   712 |    8192 |     8192 |       4 |         0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; encode(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;substring&lt;/span&gt;&lt;span&gt;(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; for&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 24&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;hex&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                      encode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 00000000b0e9860100000400a802c8020020042000000000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the file. &lt;code&gt;od&lt;/code&gt; prints bytes from an offset, and block 0 starts at offset 0:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ od -A d -t x1 -N 24 base/5/16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000016 00 00 00 00 00 00 00 00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000024&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing. The file has the right length, because the relation was extended with zeroed space, but the INSERT and VACUUM only dirtied pages in shared memory and nobody has written them back. The buffers article&#39;s point, seen from the filesystem side. Force it:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CHECKPOINT&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ od -A d -t x1 -N 24 base/5/16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000000 00 00 00 00 b0 e9 86 01 dd f2 04 00 a8 02 c8 02&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000016 00 20 04 20 00 00 00 00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000024&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the bytes match the buffer, with one exception picked apart below. Two fields are worth reading by hand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Bytes 0 to 7, &lt;code&gt;00 00 00 00 b0 e9 86 01&lt;/code&gt;, are &lt;code&gt;pd_lsn&lt;/code&gt;. It is stored as two 32-bit little-endian halves, so the high half is 0 and the low half is &lt;code&gt;0x0186E9B0&lt;/code&gt;, which is how &lt;code&gt;page_header&lt;/code&gt; printed &lt;code&gt;0/186E9B0&lt;/code&gt;. That value is the write-ahead log position of the last change to this page.&lt;/li&gt;
&lt;li&gt;Bytes 8 and 9, &lt;code&gt;dd f2&lt;/code&gt;, are &lt;code&gt;pd_checksum&lt;/code&gt;, little-endian &lt;code&gt;0xF2DD&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The remaining 14 bytes match &lt;code&gt;page_header&lt;/code&gt; field for field.&lt;/p&gt;
&lt;p&gt;Except the checksum. The buffer says 0, the file says &lt;code&gt;f2dd&lt;/code&gt;. PostgreSQL computes the checksum when it writes a page out, into the copy it hands to the kernel, and never touches the shared buffer. (Pages written outside the pool, by index builds or the &lt;code&gt;VACUUM FULL&lt;/code&gt; rewrite, get theirs in place.) A page that was initialised in memory and has never been read from disk carries a zero checksum in the buffer; one that was read and then modified carries whatever checksum the file had when it was read. Read this page back after it&#39;s been evicted and reloaded and the buffer will show the on-disk value, which &lt;code&gt;page_header&lt;/code&gt; prints as signed &lt;code&gt;smallint&lt;/code&gt;: &lt;code&gt;0xF2DD&lt;/code&gt; comes out as -3363, not 62173.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
&lt;code&gt;pageinspect&lt;/code&gt; never re-verifies a checksum; it reads the buffer, not the file. Verification happens when a page is read from disk into the pool (so &lt;code&gt;get_raw_page&lt;/code&gt; on an uncached page does trip over a corrupt one), and offline with &lt;code&gt;pg_checksums&lt;/code&gt;. If you want the on-disk bytes from SQL, &lt;code&gt;pg_read_binary_file&lt;/code&gt; on the relation path reads the file rather than the shared buffer. It still goes through the OS page cache, and it needs superuser or the &lt;code&gt;pg_read_server_files&lt;/code&gt; role.
&lt;/div&gt;
&lt;h2 id=&quot;oid-versus-relfilenode&quot;&gt;OID versus relfilenode&lt;a class=&quot;zola-anchor&quot; href=&quot;#oid-versus-relfilenode&quot; aria-label=&quot;Anchor link for: oid-versus-relfilenode&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Back to the pair of numbers in &lt;code&gt;pg_class&lt;/code&gt; that started out equal. The OID is the relation&#39;s identity, the number every catalog reference points at, and it never changes. The relfilenode is just a file name. Most of the time they match, because &lt;code&gt;CREATE TABLE&lt;/code&gt; assigns the same number to both. Operations that rewrite the table break the tie:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT oid&lt;/span&gt;&lt;span&gt;, relfilenode, pg_relation_filepath(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM FULL files_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT oid&lt;/span&gt;&lt;span&gt;, relfilenode, pg_relation_filepath(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  oid  | relfilenode | pg_relation_filepath&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------+----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16431 |       16431 | base/5/16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  oid  | relfilenode | pg_relation_filepath&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------+----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16431 |       16449 | base/5/16449&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;VACUUM FULL&lt;/code&gt; writes a compacted copy of the table into a brand new file and then swaps the catalog entry to point at it. The OID stays 16431 so nothing that references the table notices; the relfilenode moves to 16449. The primary key was rebuilt too, and moved from 16438 to 16454. Before the next checkpoint, the old file is still there:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -l base/5/16431* base/5/1645*&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres     0 Sep 11 20:45 base/5/16431&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres     0 Sep 11 20:45 base/5/16452&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres  8192 Sep 11 20:45 base/5/16453&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 40960 Sep 11 20:45 base/5/16454&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;16452&lt;/code&gt; and &lt;code&gt;16453&lt;/code&gt; are the rebuilt TOAST table and its index; the gap before them is OIDs the rewrite consumed for its transient relation. The old &lt;code&gt;16431&lt;/code&gt; is still there at zero bytes, and its &lt;code&gt;_fsm&lt;/code&gt; and &lt;code&gt;_vm&lt;/code&gt; aren&#39;t: PostgreSQL unlinks the companion forks and any extra segments immediately, but truncates the main fork&#39;s first segment to nothing and keeps the empty name until the next checkpoint, which unlinks it for good.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
&lt;code&gt;md.c&lt;/code&gt; explains why: under &lt;code&gt;wal_level = minimal&lt;/code&gt;, relation contents are fsynced rather than logged. A relfilenode freed before a checkpoint could be reused by a new relation, and replay of the old relation&#39;s drop after a crash would then delete the new relation&#39;s file. Holding the name until the checkpoint closes that window.
&lt;/div&gt;
&lt;p&gt;&lt;code&gt;TRUNCATE&lt;/code&gt; does the same swap, to an empty file:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TRUNCATE&lt;/span&gt;&lt;span&gt; files_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT oid&lt;/span&gt;&lt;span&gt;, relfilenode, pg_relation_filepath(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;files_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  oid  | relfilenode | pg_relation_filepath&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------+----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 16431 |       16455 | base/5/16455&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Assigning a fresh file rather than emptying the old one is what makes &lt;code&gt;TRUNCATE&lt;/code&gt; transactional. If the transaction rolls back, the catalog row reverts to the old relfilenode and the old file is still there, untouched. &lt;code&gt;CLUSTER&lt;/code&gt;, &lt;code&gt;REINDEX&lt;/code&gt;, &lt;code&gt;ALTER TABLE ... SET TABLESPACE&lt;/code&gt;, and any &lt;code&gt;ALTER TABLE&lt;/code&gt; that rewrites rows all take the same route, because each of them builds a new copy of the relation.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Don&#39;t rely on &lt;code&gt;oid = relfilenode&lt;/code&gt;.&lt;/strong&gt; Tools and scripts that build paths by hand from &lt;code&gt;pg_class.oid&lt;/code&gt; work on a fresh database and break after the first &lt;code&gt;VACUUM FULL&lt;/code&gt;. Use &lt;code&gt;pg_relation_filepath()&lt;/code&gt; to get the path and &lt;code&gt;pg_filenode_relation()&lt;/code&gt; to get the name back. This matters most when reading WAL, where records name relations by their file number, not their OID, because the log describes physical files.
&lt;/div&gt;
&lt;h2 id=&quot;tablespaces&quot;&gt;Tablespaces&lt;a class=&quot;zola-anchor&quot; href=&quot;#tablespaces&quot; aria-label=&quot;Anchor link for: tablespaces&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Everything so far lived under &lt;code&gt;base/&lt;/code&gt;, the home of the &lt;code&gt;pg_default&lt;/code&gt; tablespace. A tablespace is a directory somewhere else, with the same layout inside:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLESPACE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; fast&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; LOCATION&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;/var/lib/postgresql/fast&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; files_ts&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;, note &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;) TABLESPACE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;fast&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_filepath(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_ts&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;          pg_relation_filepath&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; pg_tblspc/16459/PG_18_202506291/5/16460&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -l pg_tblspc/&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;lrwxrwxrwx 1 postgres postgres 24 Sep 11 20:45 16459 -&amp;gt; /var/lib/postgresql/fast&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pg_tblspc&lt;/code&gt; holds one symlink per tablespace, named by its OID. Under the link sits &lt;code&gt;PG_18_202506291&lt;/code&gt;, the server and catalog version, so two major versions can share a location during an upgrade; below that it&#39;s &lt;code&gt;&amp;lt;database OID&amp;gt;/&amp;lt;relfilenode&amp;gt;&lt;/code&gt; again, exactly like &lt;code&gt;base/&lt;/code&gt;. Tablespace OID, database OID and relfilenode together name a relation file uniquely in the cluster, and &lt;code&gt;pg_waldump&lt;/code&gt; identifies every block it touches by that triple.&lt;/p&gt;
&lt;h2 id=&quot;segment-files&quot;&gt;Segment files&lt;a class=&quot;zola-anchor&quot; href=&quot;#segment-files&quot; aria-label=&quot;Anchor link for: segment-files&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;A single file is capped at 1GB (the &lt;code&gt;--with-segsize&lt;/code&gt; build default; packaged builds use it). When a relation grows past that, PostgreSQL starts a second file with the same name and a &lt;code&gt;.1&lt;/code&gt; suffix, then &lt;code&gt;.2&lt;/code&gt;, and so on. Each holds 131072 pages of 8192 bytes. The cap exists so the server works on platforms with file-size limits.&lt;/p&gt;
&lt;p&gt;To see one, the table has to grow past 1GB:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; files_big&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    payload &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; files_big (payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;x&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1200000&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_size_pretty(pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_big&amp;#39;&lt;/span&gt;&lt;span&gt;)) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS size&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_big&amp;#39;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 8192&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;/span&gt;&lt;span&gt; pages,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_relation_filepath(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_big&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  size   | pages  | pg_relation_filepath&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------+--------+----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1339 MB | 171429 | base/5/16466&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -l base/5/16466*&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 1073741824 Sep 11 20:45 base/5/16466&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres  330604544 Sep 11 20:45 base/5/16466.1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres     368640 Sep 11 20:46 base/5/16466_fsm&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first segment is exactly 1073741824 bytes, 131072 pages. The remaining 171429 minus 131072 is 40357 pages, times 8192 is 330604544, the size of &lt;code&gt;.1&lt;/code&gt;. &lt;code&gt;pg_relation_filepath&lt;/code&gt; still returns the base name; the segment suffix is added by the storage manager when it computes which file holds a given block. &lt;code&gt;pg_relation_size&lt;/code&gt; sums all segments.&lt;/p&gt;
&lt;p&gt;Block numbers don&#39;t know about segments. Block 131072 is simply the first page of &lt;code&gt;16466.1&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; encode(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;substring&lt;/span&gt;&lt;span&gt;(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;files_big&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;131072&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; for&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 24&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;hex&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                      encode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 00000000304b1e3c63ad00003400c8030020042000000000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ od -A d -t x1 -N 24 base/5/16466.1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000000 00 00 00 00 30 4b 1e 3c 63 ad 00 00 34 00 c8 03&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000016 00 20 04 20 00 00 00 00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;0000024&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same bytes, checksum included this time. This table is ten times larger than this instance&#39;s 128MB &lt;code&gt;shared_buffers&lt;/code&gt;, so the page was evicted after being written and read back from disk before &lt;code&gt;get_raw_page&lt;/code&gt; saw it, and it carries the checksum the write computed.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Where PostgreSQL stores row locks</title>
        <published>2026-09-17T06:40:00+00:00</published>
        <updated>2026-09-17T06:40:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/row-locks-on-the-page/"/>
        <id>https://boringsql.com/posts/row-locks-on-the-page/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/row-locks-on-the-page/">&lt;p&gt;In PostgreSQL, every tuple starts with 23-byte header, and the first eight bytes are two transaction IDs. &lt;code&gt;t_xmin&lt;/code&gt; for the transaction that created the row and &lt;code&gt;t_xmax&lt;/code&gt; for the one that deleted or updated it. That is the visibility story covered in &lt;a href=&quot;/posts/postgresql-mvcc-byte-by-byte/&quot;&gt;PostgreSQL MVCC, Byte by Byte&lt;/a&gt;. For now we have discussed &lt;code&gt;t_xmax&lt;/code&gt; acting as the delete marker.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;t_xmax&lt;/code&gt; has a second job. When you run &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; or an insert checks a foreign key, PostgreSQL has nowhere else to record the row lock. The shared memory lock table is limited by max_locks_per_transaction. Locking a million rows would exceed its capacity. PostgreSQL works around this by storing the locking transaction ID in t_xmax and marking the row as locked with flags in &lt;code&gt;t_infomask&lt;/code&gt;, while readers can still see it, so every row lock in PostgreSQL ends up as a write to the page.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;a class=&quot;zola-anchor&quot; href=&quot;#setup&quot; aria-label=&quot;Anchor link for: setup&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The setup is one parent table in the usual shape, plus a child table with a foreign key, since foreign key checks lock parent rows. Everything below was captured on a single PostgreSQL 18.6 cluster using the &lt;code&gt;postgres:18&lt;/code&gt; image. Transaction IDs will be different on your cluster; compare the bits instead.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pageinspect;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; lock_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    owner text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    balance &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;12&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; lock_demo_tx&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    account_id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer NOT NULL REFERENCES&lt;/span&gt;&lt;span&gt; lock_demo (id),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    amount &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;12&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; lock_demo (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;owner&lt;/span&gt;&lt;span&gt;, balance)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;alice&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span&gt;), (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;bob&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;200&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span&gt;), (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;carol&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;300&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; lock_demo;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;sidenote&quot;&gt;`heap_tuple_infomask_flags()` from `pageinspect` turns the two raw bitmask integers into flag names.&lt;/div&gt;
Every page read below uses this query:
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                        raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+----------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |      0 | (0,1)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The insert itself stamps &lt;code&gt;HEAP_XMAX_INVALID&lt;/code&gt;. &lt;code&gt;HEAP_XMIN_COMMITTED&lt;/code&gt; was set later by the &lt;code&gt;count(*)&lt;/code&gt;; a page read straight after the insert would still show it unset.&lt;/p&gt;
&lt;h2 id=&quot;what-for-update-writes&quot;&gt;What FOR UPDATE writes&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-for-update-writes&quot; aria-label=&quot;Anchor link for: what-for-update-writes&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Call the original session A. Open a second psql session, B, lock the first row there and keep the transaction open:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_current_xact_id();   &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 768&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; id, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;owner FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FOR UPDATE&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Back in session A, read the page:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                            raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+--------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |    768 | (0,1)  | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Session B&#39;s xid sits in &lt;code&gt;t_xmax&lt;/code&gt; exactly where a &lt;code&gt;DELETE&lt;/code&gt; would put it. Three bits say what kind of lock it is, two of them in &lt;code&gt;t_infomask&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;HEAP_XMAX_LOCK_ONLY&lt;/code&gt; says this xmax is a locker and the row isn&#39;t going anywhere.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HEAP_XMAX_EXCL_LOCK&lt;/code&gt; gives the strength: exclusive.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;HEAP_KEYS_UPDATED&lt;/code&gt; in &lt;code&gt;t_infomask2&lt;/code&gt; is set even though nothing was updated. A lock always sets at least one of the two &lt;code&gt;t_infomask&lt;/code&gt; bits, which leaves three patterns for four strengths, so this bit does double duty: it separates &lt;code&gt;FOR UPDATE&lt;/code&gt; from &lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt;. It is the same bit a key-column &lt;code&gt;UPDATE&lt;/code&gt; or a &lt;code&gt;DELETE&lt;/code&gt; sets, which is why &lt;code&gt;FOR UPDATE&lt;/code&gt; conflicts with the key-share locks foreign keys take.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;HEAP_XMAX_INVALID&lt;/code&gt; hint no longer applies, yet the row is still readable from session A:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT owner&lt;/span&gt;&lt;span&gt;, balance &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; owner | balance&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+---------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; alice |  100.00&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A reader checks visibility, sees &lt;code&gt;HEAP_XMAX_LOCK_ONLY&lt;/code&gt;, and treats the row as live without even looking at whether 768 committed. Readers never wait for row locks in any case, because snapshot visibility decides what they see; what the bit does is keep the row alive after the locker commits. Without it, a committed lock-only xmax would read as a committed delete.&lt;/p&gt;
&lt;p&gt;Now look at what the lock manager knows about all this, asked from session B itself:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B, still inside the open transaction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; locktype, relation::regclass &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; relation, mode, granted&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_locks &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; pid &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span&gt; pg_backend_pid()&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ORDER BY&lt;/span&gt;&lt;span&gt; locktype;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   locktype    |    relation    |      mode       | granted&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------+----------------+-----------------+---------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relation      | lock_demo      | RowShareLock    | t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relation      | pg_locks       | AccessShareLock | t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relation      | lock_demo_pkey | RowShareLock    | t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; transactionid |                | ExclusiveLock   | t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; virtualxid    |                | ExclusiveLock   | t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(5 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;sidenote&quot;&gt;
The &lt;code&gt;transactionid&lt;/code&gt; lock is the queue. Every transaction that has an xid holds an &lt;code&gt;ExclusiveLock&lt;/code&gt; on it until it ends. Anyone who needs to wait for that transaction, for any row it has locked, requests a &lt;code&gt;ShareLock&lt;/code&gt; on the same xid and blocks.
&lt;/div&gt;
&lt;p&gt;There is no entry for row 1, because the row lock exists only in the tuple header. A second session that wants the row finds 768 in &lt;code&gt;t_xmax&lt;/code&gt;, sees it is still in progress, and waits on the transaction ID lock instead.&lt;/p&gt;
&lt;h2 id=&quot;the-stamp-outlives-the-lock&quot;&gt;The stamp outlives the lock&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-stamp-outlives-the-lock&quot; aria-label=&quot;Anchor link for: the-stamp-outlives-the-lock&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Commit session B and read the page again:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                            raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+--------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |    768 | (0,1)  | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_KEYS_UPDATED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing changed. I&#39;d assumed commit would tidy the tuple up, but &lt;code&gt;COMMIT&lt;/code&gt; doesn&#39;t revisit the pages a transaction locked; that would mean remembering every one of them. The lock is released because 768 is no longer in progress, and anybody who looks at the row will work that out for themselves by checking the transaction&#39;s status. The stamp sits there until something that cares about xmax comes past:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM lock_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                                     raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+--------------------------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |    768 | (0,1)  | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID,HEAP_KEYS_UPDATED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;VACUUM checks lock-only xmax values too, finds 768 finished, and sets the hint, which is enough to tell every future visitor to ignore the rest of the bits. The next locker overwrites them.&lt;/p&gt;
&lt;h2 id=&quot;four-modes-four-bit-patterns&quot;&gt;Four modes, four bit patterns&lt;a class=&quot;zola-anchor&quot; href=&quot;#four-modes-four-bit-patterns&quot; aria-label=&quot;Anchor link for: four-modes-four-bit-patterns&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;PostgreSQL has four row lock strengths, encoded in three bits across the two infomask fields. Repeat the experiment with each of the other three modes, one per row, each in its own transaction that session B holds open while session A reads the page, then commits before the next:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B, one at a time, each in its own open transaction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FOR NO KEY UPDATE&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- xid 769&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FOR&lt;/span&gt;&lt;span&gt; SHARE;           &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- xid 770&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FOR KEY&lt;/span&gt;&lt;span&gt; SHARE;       &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- xid 771&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmax |                                   raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    769 | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmax |                                              raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+------------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    770 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmax |                                    raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+----------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    771 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock on row 1 overwrote the stale &lt;code&gt;FOR UPDATE&lt;/code&gt; stamp completely. Side by side:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;SQL lock mode&lt;/th&gt;&lt;th style=&quot;text-align: center&quot;&gt;&lt;code&gt;KEYSHR_LOCK&lt;/code&gt;&lt;/th&gt;&lt;th style=&quot;text-align: center&quot;&gt;&lt;code&gt;EXCL_LOCK&lt;/code&gt;&lt;/th&gt;&lt;th style=&quot;text-align: center&quot;&gt;&lt;code&gt;KEYS_UPDATED&lt;/code&gt;&lt;/th&gt;&lt;th&gt;Taken implicitly by&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR KEY SHARE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;&lt;td&gt;foreign key checks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR SHARE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;&lt;td&gt;nothing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE&lt;/code&gt; that leaves key columns alone&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;0&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;1&lt;/td&gt;&lt;td&gt;&lt;code&gt;UPDATE&lt;/code&gt; of a key column, &lt;code&gt;DELETE&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The source calls the &lt;code&gt;FOR SHARE&lt;/code&gt; combination &lt;code&gt;HEAP_XMAX_SHR_LOCK&lt;/code&gt;, but it is not a separate bit. In practice, the last column explains most row-lock behaviour: every &lt;code&gt;UPDATE&lt;/code&gt; that does not touch a key column (a column under a unique index that a foreign key could reference) takes &lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt; on the old row version, and every foreign key check takes &lt;code&gt;FOR KEY SHARE&lt;/code&gt;. The two are compatible by design. That&#39;s what lets you update a customer&#39;s balance while someone else inserts an order for them.&lt;/p&gt;
&lt;p&gt;Which modes block which:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Requested, versus held&lt;/th&gt;&lt;th style=&quot;text-align: center&quot;&gt;&lt;code&gt;KEY SHARE&lt;/code&gt;&lt;/th&gt;&lt;th style=&quot;text-align: center&quot;&gt;&lt;code&gt;SHARE&lt;/code&gt;&lt;/th&gt;&lt;th style=&quot;text-align: center&quot;&gt;&lt;code&gt;NO KEY UPDATE&lt;/code&gt;&lt;/th&gt;&lt;th style=&quot;text-align: center&quot;&gt;&lt;code&gt;UPDATE&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR KEY SHARE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR SHARE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;td style=&quot;text-align: center&quot;&gt;blocks&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt; conflicts with everything, so a &lt;code&gt;DELETE&lt;/code&gt; waits behind a foreign key check but a balance update doesn&#39;t. The same conflict applies to explicit locks: an application that runs &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on a parent row before changing a non-key column blocks every child insert that references it. &lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt; would have done the job. &lt;code&gt;FOR UPDATE&lt;/code&gt; should rarely be anyone&#39;s default, and it mostly is only because it&#39;s the one everybody remembers. Laurenz Albe covers the consequences in &lt;a rel=&quot;external&quot; href=&quot;https://www.cybertec-postgresql.com/en/select-for-update-considered-harmful-postgresql/&quot;&gt;SELECT FOR UPDATE considered harmful in PostgreSQL&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;two-lockers-the-multixactid&quot;&gt;Two lockers: the MultiXactId&lt;a class=&quot;zola-anchor&quot; href=&quot;#two-lockers-the-multixactid&quot; aria-label=&quot;Anchor link for: two-lockers-the-multixactid&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;t_xmax&lt;/code&gt; is one 32-bit field. Two sessions can hold &lt;code&gt;FOR SHARE&lt;/code&gt; on the same row at the same time, and PostgreSQL has to record both. With the three mode transactions committed, take the lock in session B, then in a third session C, and read the page from session A:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B (xid 772) and session C (xid 773), both left open&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FOR&lt;/span&gt;&lt;span&gt; SHARE;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                                        raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+-------------------------------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |    771 | (0,1)  | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |    769 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      1 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_EXCL_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Row 3&#39;s &lt;code&gt;t_xmax&lt;/code&gt; of 1 isn&#39;t a transaction ID. &lt;code&gt;HEAP_XMAX_IS_MULTI&lt;/code&gt; marks it as a &lt;strong&gt;MultiXactId&lt;/strong&gt;, the first this cluster has created, pointing at a member list stored outside the page:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; pg_get_multixact_members(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; xid | mode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----+------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 772 | sh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 773 | sh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The other &lt;code&gt;mode&lt;/code&gt; values are &lt;code&gt;keysh&lt;/code&gt;, &lt;code&gt;fornokeyupd&lt;/code&gt; and &lt;code&gt;forupd&lt;/code&gt; for the remaining lockers, and &lt;code&gt;nokeyupd&lt;/code&gt; and &lt;code&gt;upd&lt;/code&gt; for members that actually wrote a new row version. The infomask bits on the tuple summarise the strongest mode held by any member, so a compatible locker can be granted without walking the member list; deciding whether to wait still means reading it.&lt;/p&gt;
&lt;p&gt;That structure is on disk, in two directories under the data directory:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$ ls -l $PGDATA/pg_multixact/offsets $PGDATA/pg_multixact/members&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;pg_multixact/members:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 8192 Sep 11 21:06 0000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;pg_multixact/offsets:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-rw------- 1 postgres postgres 8192 Sep 11 21:06 0000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;offsets&lt;/code&gt; maps a MultiXactId to a position in &lt;code&gt;members&lt;/code&gt;; &lt;code&gt;members&lt;/code&gt; is the flat list of (xid, mode) pairs. Both are SLRU files, the same family as the commit log. &lt;code&gt;offsets&lt;/code&gt; grows by one entry per multixact, &lt;code&gt;members&lt;/code&gt; by one entry per member.&lt;/p&gt;
&lt;p&gt;Both lockers commit. As with the single-xid case, nothing on the page changes until VACUUM visits:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM lock_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                 raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+----------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |      0 | (0,1)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All members finished, none of them an updater, so the multixact carries no information any more. VACUUM replaced row 3&#39;s &lt;code&gt;t_xmax&lt;/code&gt; with 0 instead of setting a hint. A multixact reference costs more to leave around than a plain xid: resolving it means a lookup in &lt;code&gt;pg_multixact&lt;/code&gt;, and the multixact can&#39;t be recycled while any tuple points at it. &lt;code&gt;HEAP_XMIN_INVALID&lt;/code&gt; next to &lt;code&gt;HEAP_XMIN_COMMITTED&lt;/code&gt; means VACUUM also froze the page, which clearing an old multixact forces.&lt;/p&gt;
&lt;h2 id=&quot;foreign-keys-take-for-key-share&quot;&gt;Foreign keys take FOR KEY SHARE&lt;a class=&quot;zola-anchor&quot; href=&quot;#foreign-keys-take-for-key-share&quot; aria-label=&quot;Anchor link for: foreign-keys-take-for-key-share&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Inserting into &lt;code&gt;lock_demo_tx&lt;/code&gt; locks a parent row without any &lt;code&gt;FOR ...&lt;/code&gt; clause in the statement. The foreign key check has to make sure that alice&#39;s &lt;code&gt;id&lt;/code&gt; still exists and does not change until the insert commits, and it does that by locking the parent row. Start an insert in session B and leave it open:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B (xid 774)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; lock_demo_tx (account_id, amount) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;25&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                             raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+----------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |    774 | (0,1)  | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The foreign key check took a &lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock on alice&#39;s row, in a table the statement never wrote to.&lt;/p&gt;
&lt;p&gt;Now, still with session B open, update alice&#39;s balance from session A, in autocommit mode. Balance is not a key column, so the &lt;code&gt;UPDATE&lt;/code&gt; wants &lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt;, which doesn&#39;t conflict with &lt;code&gt;FOR KEY SHARE&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A, autocommit (xid 775)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; balance &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 150&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;UPDATE 1&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                                    raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+------------------------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |      2 | (0,4)  | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_IS_MULTI,HEAP_HOT_UPDATED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |      0 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |    775 |    774 | (0,4)  | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_UPDATED,HEAP_ONLY_TUPLE}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The old version at line pointer 1 has to record session B&#39;s key-share lock and session A&#39;s update in one field, so &lt;code&gt;t_xmax&lt;/code&gt; became MultiXactId 2:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; pg_get_multixact_members(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; xid |   mode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 774 | keysh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 775 | nokeyupd&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With an updater among the members, &lt;code&gt;HEAP_XMAX_LOCK_ONLY&lt;/code&gt; is gone and this version has been dead since 775 committed. &lt;code&gt;HEAP_HOT_UPDATED&lt;/code&gt; is there because the new version landed on the same page with no index change, the subject of the &lt;a href=&quot;/posts/hot-updates/&quot;&gt;next chapter&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;UPDATE&lt;/code&gt; carried session B&#39;s key-share lock forward to the new version at (0,4), because session B still relies on alice&#39;s &lt;code&gt;id&lt;/code&gt; not changing and the current row now lives there.&lt;/p&gt;
&lt;p&gt;Try, from session C, to delete alice while the child insert is still uncommitted:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session C&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; statement_timeout &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2s&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ERROR:  canceling statement due to statement timeout&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;CONTEXT:  while deleting tuple (0,4) in relation &amp;quot;lock_demo&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;DELETE&lt;/code&gt; needs &lt;code&gt;FOR UPDATE&lt;/code&gt; strength, so it queued behind 774 on (0,4), the version carrying the inherited lock. While it was waiting, &lt;code&gt;pg_locks&lt;/code&gt; did show a row, the one exception to it never showing row locks:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A, while C is waiting&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; locktype, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;page&lt;/span&gt;&lt;span&gt;, tuple, mode, granted &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_locks &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; locktype &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;tuple&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; locktype | page | tuple |        mode         | granted&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------+------+-------+---------------------+---------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; tuple    |    0 |     4 | AccessExclusiveLock | t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pg_locks&lt;/code&gt; shows you what a blocked session is waiting on, never what a session already holds at row level. That&#39;s also the answer to &quot;why can&#39;t I delete this row, nothing has it locked&quot;. Something inserted a child row and hasn&#39;t committed yet, and this &lt;code&gt;tuple&lt;/code&gt; entry plus the &lt;code&gt;transactionid&lt;/code&gt; it is waiting for are the only traces.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
A waiter takes a heavyweight &lt;code&gt;tuple&lt;/code&gt; lock on the row it is queued for before sleeping on the holder&#39;s transaction ID, so that later waiters line up behind it in order rather than racing for the row when the holder finishes. It disappears the moment the waiter gets the row or gives up.
&lt;/div&gt;
&lt;p&gt;Once session B commits, a delete goes through on a different row to show the last pattern:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session C&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;/span&gt;&lt;span&gt; lock_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lock_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid |                                                    raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+------------------------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    767 |      2 | (0,4)  | {HEAP_HASVARWIDTH,HEAP_XMAX_EXCL_LOCK,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_IS_MULTI,HEAP_HOT_UPDATED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    767 |    777 | (0,2)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_KEYS_UPDATED}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    767 |      0 | (0,3)  | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |    775 |    774 | (0,4)  | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_UPDATED,HEAP_ONLY_TUPLE}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A delete sets &lt;code&gt;HEAP_KEYS_UPDATED&lt;/code&gt; and no lock bits, because it has to conflict with every lock mode, key share included.&lt;/p&gt;
&lt;h2 id=&quot;multixacts-on-disk&quot;&gt;Multixacts on disk&lt;a class=&quot;zola-anchor&quot; href=&quot;#multixacts-on-disk&quot; aria-label=&quot;Anchor link for: multixacts-on-disk&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Every multixact allocated is a permanent record until VACUUM says otherwise, so it matters how many a workload creates. Take a parent table with 20,000 rows and a child table referencing it, in the same shape as before:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; mx_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    owner text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    balance &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;12&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; mx_child&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    parent_id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer NOT NULL REFERENCES&lt;/span&gt;&lt;span&gt; mx_demo (id)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; mx_demo (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;owner&lt;/span&gt;&lt;span&gt;, balance)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;user_&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; i, i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;20000&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; relname, relminmxid &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;mx_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relname | relminmxid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; mx_demo |          3&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;relminmxid&lt;/code&gt; is the oldest multixact any tuple in this table might still reference. A new table starts at the oldest multixact still in use, which on an idle cluster is the next one to be allocated; after the two created above, that&#39;s 3. Now have two transactions insert 5,000 child rows each for the same parents, overlapping in time:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B (xid 781) and session C (xid 782), both left open&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- (778 to 780 went to the DDL above, 776 to the DELETE that timed out)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; mx_child (parent_id) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5000&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmax,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;mx_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;)) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; pg_get_multixact_members(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;3&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmax |                                              raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+-----------------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |      3 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |      3 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |      3 | {HEAP_HASVARWIDTH,HEAP_XMAX_KEYSHR_LOCK,HEAP_XMAX_LOCK_ONLY,HEAP_XMIN_COMMITTED,HEAP_XMAX_IS_MULTI}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; xid | mode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 781 | keysh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 782 | keysh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A backend caches the multixacts it created or read in the current transaction (up to 256 entries, dropped at transaction end), so transaction 782 built {781 keysh, 782 keysh} once and reused it for all 5,000 rows. The counters confirm it after a checkpoint:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CHECKPOINT&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; next_multixact_id, next_multi_offset &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_control_checkpoint();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; next_multixact_id | next_multi_offset&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------+-------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 4 |                 7&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Offsets start at 1, so six members leave &lt;code&gt;next_multi_offset&lt;/code&gt; at 7.&lt;/p&gt;
&lt;p&gt;The expensive shape is the opposite one: a single long transaction holding key-share locks, and many short transactions each adding their own member. Commit session C, keep session B&#39;s 5,000-row insert open, and from session C run 5,000 separate one-row inserts, each its own transaction:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session C, autocommit, 5,000 times&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; mx_child (parent_id) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; mx_child (parent_id) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; pg_get_multixact_members(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;4&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; pg_get_multixact_members(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;5&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; xid | mode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 781 | keysh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 784 | keysh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; xid | mode&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 781 | keysh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 785 | keysh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each short transaction forms a new member set with 781, so each row gets a new multixact (783 went to a stray statement):&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CHECKPOINT&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; next_multixact_id, next_multi_offset &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_control_checkpoint();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_size_pretty(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;sum&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;size&lt;/span&gt;&lt;span&gt;)) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; members_size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_ls_dir(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;pg_multixact/members&amp;#39;&lt;/span&gt;&lt;span&gt;) f, pg_stat_file(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;pg_multixact/members/&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; f) s;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; next_multixact_id | next_multi_offset&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------+-------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;              5004 |             10007&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; members_size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 56 kB&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A long-running transaction that touches many parent rows, alongside a steady stream of short inserts, produces exactly this. A common source is a nightly batch job holding key-share locks on a hot reference table while the application keeps inserting. At roughly five bytes per member, the hard limit of 2&lt;sup&gt;32&lt;/sup&gt; members works out to about 20 GB.&lt;/p&gt;
&lt;p&gt;Multixact IDs are 32-bit counters like transaction IDs, and they wrap around the same way:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; relname, relminmxid, mxid_age(relminmxid)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;mx_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relname | relminmxid | mxid_age&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------+------------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; mx_demo |          3 |     5001&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mxid_age&lt;/code&gt; is how far the counter has run ahead of the oldest multixact the table still references. VACUUM advances &lt;code&gt;relminmxid&lt;/code&gt; the same way it advances &lt;code&gt;relfrozenxid&lt;/code&gt;, by removing or replacing old multixacts in tuple headers, and it has its own set of thresholds for when to do so:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT name&lt;/span&gt;&lt;span&gt;, setting &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_settings &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE name LIKE&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;%multixact%age&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                name                 |  setting&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; autovacuum_multixact_freeze_max_age | 400000000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; vacuum_multixact_failsafe_age       | 1600000000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; vacuum_multixact_freeze_min_age     | 5000000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; vacuum_multixact_freeze_table_age   | 150000000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These mirror the transaction ID settings but track a separate counter, so a database can be nowhere near xid wraparound and still hit &lt;code&gt;autovacuum_multixact_freeze_max_age&lt;/code&gt;. Autovacuum then starts an anti-wraparound vacuum you didn&#39;t schedule, on a table whose &lt;code&gt;n_dead_tup&lt;/code&gt; is zero, which is confusing the first time you see it. Once session B commits, freezing clears it:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM FREEZE mx_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; relname, relminmxid, mxid_age(relminmxid)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;mx_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmax,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;mx_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;)) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relname | relminmxid | mxid_age&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------+------------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; mx_demo |       5004 |        0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmax |                                 raw_flags&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+----------------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |      0 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |      0 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |      0 | {HEAP_HASVARWIDTH,HEAP_XMIN_COMMITTED,HEAP_XMIN_INVALID,HEAP_XMAX_INVALID}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The files in &lt;code&gt;pg_multixact&lt;/code&gt; don&#39;t shrink yet. They are truncated once every database&#39;s &lt;code&gt;datminmxid&lt;/code&gt; has moved past a segment. On this cluster that has not happened. &lt;code&gt;datminmxid&lt;/code&gt; is the minimum &lt;code&gt;relminmxid&lt;/code&gt; over a database&#39;s tables, and the system catalogs still carry the 1 they got from initdb, in every database including this one. Then there&#39;s &lt;code&gt;template0&lt;/code&gt;, not connectable by default, whose value moves only when autovacuum&#39;s wraparound pass gets around to it.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;The multixact members limit&lt;/strong&gt;&lt;br&gt;
When the 2&lt;sup&gt;32&lt;/sup&gt; member slots run out, any statement that needs a new multixact fails with &lt;code&gt;multixact &quot;members&quot; limit exceeded&lt;/code&gt;. Autovacuum starts freezing multixacts more aggressively once half the space is used. On a system with many foreign key lockers, watch &lt;code&gt;next_multi_offset&lt;/code&gt; in &lt;code&gt;pg_control_checkpoint()&lt;/code&gt;, not just &lt;code&gt;next_multixact_id&lt;/code&gt;.
&lt;/div&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>PostgreSQL&#39;s MVCC is bad. So is everyone else&#39;s.</title>
        <published>2026-07-27T16:01:00+00:00</published>
        <updated>2026-07-27T16:01:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/mvcc-bad-bad/"/>
        <id>https://boringsql.com/posts/mvcc-bad-bad/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/mvcc-bad-bad/">&lt;p&gt;The first thing you will probably learn about Postgres, if you follow people who don&#39;t like Postgres, is that MVCC is bad. The 40-year-old design mistake. It&#39;s signatures are everywhere. Bloated tables that double in size, 32-bit transaction counter limit, the never ending struggle with VACCUM, dead tuples nightmares. It comes with credentials, too: Uber measured the write amplification in 2016 and &lt;a rel=&quot;external&quot; href=&quot;https://www.uber.com/blog/postgres-to-mysql-migration/&quot;&gt;left for MySQL&lt;/a&gt; over it; Andy Pavlo&#39;s database group called MVCC &lt;a rel=&quot;external&quot; href=&quot;https://www.cs.cmu.edu/~pavlo/blog/2023/04/the-part-of-postgresql-we-hate-the-most.html&quot;&gt;the part of PostgreSQL they hate the most&lt;/a&gt;. It&#39;s a real thing. Postgres is as bad as it gets.&lt;/p&gt;
&lt;p&gt;While none of this is exaggerated, it comes down to a real design choice. The bloat, the amplified writes, the vacuum babysitting: every charge traces to a decision, not a defect, and we reproduce each one below on a live PostgreSQL 19 beta2 instance, so you can watch the damage happen yourself. But the verdict that spreads from community to community always stops one question early: &lt;strong&gt;compared to what?&lt;/strong&gt; What does every other engine do instead, and what does &lt;em&gt;that&lt;/em&gt; cost?&lt;/p&gt;
&lt;p&gt;Because MVCC is not optional. Any database that wants readers to not block writers has to keep multiple versions of rows somewhere, and every engine that does so answers the same four questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Where do old versions live?&lt;/strong&gt; In the table itself, or in a separate structure?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which way do version chains point?&lt;/strong&gt; From old to new, or new to old?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What do indexes point at?&lt;/strong&gt; A physical row location, or a logical key?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Who cleans up, and when?&lt;/strong&gt; A background process later, or the transaction itself?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;PostgreSQL&#39;s answers: in the table, old to new, physical location, background process later. Every cost the critics list follows from those four answers. And every alternative is a different set of answers with the bill sent to someone else, the writer, the reader of history, tempdb, the cache, the compactor. One of them spent years of engineering to buy the one property PostgreSQL&#39;s design has had for free since day one. All of them fail, differently, when a transaction stays open over lunch.&lt;/p&gt;
&lt;h2 id=&quot;the-charges-against-postgresql&quot;&gt;The charges against PostgreSQL&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-charges-against-postgresql&quot; aria-label=&quot;Anchor link for: the-charges-against-postgresql&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;If you want the full mechanism, &lt;a href=&quot;/posts/postgresql-mvcc-byte-by-byte/&quot;&gt;PostgreSQL MVCC, Byte by Byte&lt;/a&gt; walks through it with &lt;code&gt;pageinspect&lt;/code&gt;. The short version: an UPDATE in PostgreSQL never modifies a row. It writes a complete new copy of the row into the heap, stamps the old version&#39;s &lt;code&gt;t_xmax&lt;/code&gt;, and leaves both versions sitting on disk. Visibility is decided at read time, tuple by tuple. Cleanup is somebody else&#39;s problem, specifically &lt;a href=&quot;/posts/vacuum-at-the-page-level/&quot;&gt;VACUUM&#39;s&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Four charges follow, reproduced one at a time below.&lt;/p&gt;
&lt;h3 id=&quot;charge-1-write-amplification&quot;&gt;Charge 1: write amplification&lt;a class=&quot;zola-anchor&quot; href=&quot;#charge-1-write-amplification&quot; aria-label=&quot;Anchor link for: charge-1-write-amplification&quot;&gt;&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;The heart of Uber&#39;s complaint. Because every index on the table points at the row&#39;s &lt;em&gt;physical&lt;/em&gt; location (a page number plus a slot, the ctid), and an UPDATE creates a new physical row, every index needs a new entry pointing at the new location. Even indexes on columns you didn&#39;t touch.&lt;/p&gt;
&lt;p&gt;Set up two copies of the same 1M-row table. One with just a primary key, one with four additional secondary indexes:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; accounts&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  email &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  status text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  balance &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  created_at &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  last_seen &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; accounts&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; g, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;user&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; g &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;@example.com&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;active&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;now&lt;/span&gt;&lt;span&gt;(), &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;now&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000000&lt;/span&gt;&lt;span&gt;) g;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;/span&gt;&lt;span&gt; accounts (email);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;/span&gt;&lt;span&gt; accounts (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;/span&gt;&lt;span&gt; accounts (balance);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;/span&gt;&lt;span&gt; accounts (created_at);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; accounts_lean&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIKE&lt;/span&gt;&lt;span&gt; accounts);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;/span&gt;&lt;span&gt; accounts_lean &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD PRIMARY KEY&lt;/span&gt;&lt;span&gt; (id);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; accounts_lean &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; accounts;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now update 100,000 rows, touching only &lt;code&gt;last_seen&lt;/code&gt;. Note that &lt;code&gt;last_seen&lt;/code&gt; is not in &lt;em&gt;any&lt;/em&gt; index, on either table. Measure the WAL generated (&lt;code&gt;pg_stat_wal&lt;/code&gt;, after a &lt;code&gt;CHECKPOINT&lt;/code&gt; and &lt;code&gt;pg_stat_reset_shared(&#39;wal&#39;)&lt;/code&gt; to get a clean window):&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; accounts_lean &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; last_seen &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= now&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 100000&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 200000&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; wal_records | wal_fpi | wal_bytes | wal_pretty&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------+---------+-----------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      302510 |    1419 |  38218531 | 36 MB&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; accounts &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; last_seen &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= now&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 100000&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 200000&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; wal_records | wal_fpi | wal_bytes | wal_pretty&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------+---------+-----------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      709440 |    1981 |  72394573 | 69 MB&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same logical change, 100,000 timestamps. The lean table produced about 3.0 WAL records per row. The indexed table produced 7.1: the heap update, plus one new entry in the primary key, plus one new entry in &lt;em&gt;each of the four secondary indexes&lt;/em&gt;, none of which index the column we changed. They all got rewritten anyway, because the row moved and they all point at physical locations.&lt;/p&gt;
&lt;p&gt;This is the amplification Uber measured, and it compounds: more WAL means more checkpoint work, more full-page writes, and more bytes shipped to every replica. A single-column update on a heavily indexed table is one of the most expensive things you can do per byte of useful change.&lt;/p&gt;
&lt;p&gt;PostgreSQL&#39;s mitigation is the HOT update (&lt;a href=&quot;/posts/hot-updates/&quot;&gt;Heap-Only Tuples&lt;/a&gt;): if no indexed column changed &lt;em&gt;and&lt;/em&gt; the new version fits on the same page, indexes are left alone. &lt;code&gt;last_seen&lt;/code&gt; isn&#39;t indexed, so that condition held; the pages were just packed full, and every new version landed on a different page. Give the table breathing room and try again:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;/span&gt;&lt;span&gt; accounts &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; (fillfactor &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 70&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM FULL accounts;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; accounts &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; last_seen &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= now&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 200000&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 300000&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; wal_records | wal_bytes | n_tup_hot_upd (this batch)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------+-----------+----------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      455812 |     51 MB | 41,996 of 100,000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Better, but note the number: 42% HOT, not 100%. Each page has 30% free space and roughly 80 rows; once the first ~30 updates on a page consume the reserve, the rest of that page&#39;s rows spill elsewhere, indexes and all. Run the same batch a second time, after opportunistic pruning has recycled the dead versions in-page:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; wal_records | wal_bytes | n_tup_hot_upd (this batch)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------+-----------+----------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      304990 |     22 MB | 66,314 of 100,000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;66% HOT and 3.0 WAL records per row, nearly back to lean-table cost. That&#39;s the honest shape of the mitigation: HOT is opportunistic, arrives in steady state rather than on demand, and quietly costs you 30% of your table size in reserved space. It works, and OLTP tables in the wild routinely see 90%+ HOT ratios with well-chosen fillfactor. But the default behavior is the 7.1 records per row, and the critics are describing the default.&lt;/p&gt;
&lt;h3 id=&quot;charge-2-your-table-is-an-accident-scene&quot;&gt;Charge 2: your table is an accident scene&lt;a class=&quot;zola-anchor&quot; href=&quot;#charge-2-your-table-is-an-accident-scene&quot; aria-label=&quot;Anchor link for: charge-2-your-table-is-an-accident-scene&quot;&gt;&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;Every UPDATE is an INSERT plus a deferred DELETE. Do nothing about it and the dead versions accumulate:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM (FULL, ANALYZE) accounts_lean;  &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- reset: 89 MB, 0% dead&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\timing &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;on&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; accounts_lean &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; balance &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span&gt; balance &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;  &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- all 1M rows&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ROLLBACK&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_size_pretty(pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;accounts_lean&amp;#39;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; tuple_count, dead_tuple_count, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;round&lt;/span&gt;&lt;span&gt;(dead_tuple_percent)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pgstattuple(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;accounts_lean&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;BEGIN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Time: 0.032 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;UPDATE 1000000&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Time: 1590.112 ms (00:01.590)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ROLLBACK&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Time: 0.124 ms&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; pg_size_pretty&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 178 MB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; tuple_count | dead_tuple_count | dead_pct&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------+------------------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     1000000 |          1000000 |       47&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One statement, and a transaction that &lt;em&gt;didn&#39;t even commit&lt;/em&gt;, doubled the table. A million dead tuples wait for vacuum, and as &lt;a href=&quot;/posts/vacuum-is-lie/&quot;&gt;VACUUM Is a Lie&lt;/a&gt; covers, the indexes that grew along the way will never shrink back on their own. This is the bloat treadmill: the storage design guarantees garbage is created at write speed and collected at vacuum speed, and keeping those two rates matched is an operations job that PostgreSQL outsources to you, with autovacuum&#39;s defaults as the starting point.&lt;/p&gt;
&lt;h3 id=&quot;charge-3-one-idle-transaction-poisons-the-well&quot;&gt;Charge 3: one idle transaction poisons the well&lt;a class=&quot;zola-anchor&quot; href=&quot;#charge-3-one-idle-transaction-poisons-the-well&quot; aria-label=&quot;Anchor link for: charge-3-one-idle-transaction-poisons-the-well&quot;&gt;&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;Keep the experiment going. In a second session, open a transaction and just... hold a snapshot:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN ISOLATION LEVEL REPEATABLE READ&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; accounts_lean;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- go to lunch&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Back in session A, update 100,000 rows and vacuum:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;INFO:  finished vacuuming &amp;quot;mvcc_critics.public.accounts_lean&amp;quot;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;tuples: 14 removed, 1099945 remain,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        100000 are dead but not yet removable&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;removable cutoff: 718, which was 1 XIDs old when operation ended&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;Dead but not yet removable.&quot; Session B&#39;s snapshot might still need those versions, so vacuum must preserve them, and not just in this table: the xmin horizon is held back for every table in the database, whether session B ever reads them or not. PostgreSQL will never cancel the reader and never make it fail.&lt;span class=&quot;sidenote&quot;&gt;Strictly true at the MVCC level, but production setups routinely add that cancellation back in from outside: &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; and &lt;code&gt;statement_timeout&lt;/code&gt; exist specifically to kill the lunch-break transaction before it holds back the xmin horizon.&lt;/span&gt; It just silently stops collecting garbage, database-wide, for as long as the transaction idles. Every Postgres operator eventually learns to hunt long-running transactions and stale replication slots.&lt;/p&gt;
&lt;h3 id=&quot;charge-4-the-32-bit-debt&quot;&gt;Charge 4: the 32-bit debt&lt;a class=&quot;zola-anchor&quot; href=&quot;#charge-4-the-32-bit-debt&quot; aria-label=&quot;Anchor link for: charge-4-the-32-bit-debt&quot;&gt;&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;Every tuple header stores its creator transaction ID in 32 bits. Four billion transactions and the counter wraps, so PostgreSQL must periodically &lt;em&gt;freeze&lt;/em&gt; old tuples, rewriting pages that haven&#39;t been touched in months, purely to reclaim XID space. Fall too far behind and the database stops accepting writes to protect itself. Ask &lt;a rel=&quot;external&quot; href=&quot;https://blog.sentry.io/transaction-id-wraparound-in-postgres/&quot;&gt;Sentry, who wrote up their 2015 wraparound outage&lt;/a&gt;, or the several companies that have re-learned it since. But only PostgreSQL makes you periodically rewrite untouched pages to keep the clock from running out.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
To be fair, Oracle&#39;s counter is finite too: the SCN was 48 bits for decades, and Oracle DBAs spent 2019 patching for SCN headroom after database links were found to jump counters forward. The widening came first, and caused the scramble: 12.2 grew the SCN to 64 bits (&quot;big SCN&quot;) back in 2016, and the 2019 patches were what let older databases accept the larger values a linked 12.2 could now send, at the price of a version-compatibility dance across database links.
&lt;/div&gt;
&lt;p&gt;So: charges stick. Amplified writes by default, garbage created at write speed and collected by a process you have to tune, cleanup held hostage by any idle snapshot, and a 32-bit clock that must be wound forever. PostgreSQL&#39;s MVCC is bad, in four specific and demonstrable ways.&lt;/p&gt;
&lt;h2 id=&quot;the-undo-camp-oracle-and-innodb&quot;&gt;The undo camp: Oracle and InnoDB&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-undo-camp-oracle-and-innodb&quot; aria-label=&quot;Anchor link for: the-undo-camp-oracle-and-innodb&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Oracle and MySQL&#39;s InnoDB answer the four questions the opposite way: versions live &lt;em&gt;outside&lt;/em&gt; the table, chains point new to old, cleanup is partly the writer&#39;s problem. This is the design most &quot;Postgres MVCC is bad&quot; articles implicitly hold up as the fix, so it deserves the closest look.&lt;/p&gt;
&lt;p&gt;An UPDATE in InnoDB modifies the row &lt;em&gt;in place&lt;/em&gt;, inside the clustered index (in InnoDB, the table &lt;em&gt;is&lt;/em&gt; a B-tree ordered by primary key). Before overwriting, it copies the old values into an &lt;strong&gt;undo log&lt;/strong&gt;, a separate storage area, and stamps the row with a pointer to that undo record. Every row carries two hidden system columns for this: &lt;code&gt;DB_TRX_ID&lt;/code&gt;, the last transaction to touch it, and &lt;code&gt;DB_ROLL_PTR&lt;/code&gt;, the pointer into undo. Oracle&#39;s structure differs in detail (undo segments, block-level consistent reads) but the shape is identical: the table holds only the newest version, and history hangs off it in a chain of deltas.&lt;/p&gt;
&lt;p&gt;A reader with an older snapshot finds the current row, notices &lt;code&gt;DB_TRX_ID&lt;/code&gt; is too new, and walks the undo chain backwards, applying deltas until it reconstructs the version it&#39;s entitled to see. Oracle does this at block granularity, rebuilding a whole consistent image of the block in memory.&lt;/p&gt;
&lt;p&gt;Look at what this buys, point by point against the four charges:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The table stays compact.&lt;/strong&gt; One version per row, ever. No bloat treadmill, no &lt;code&gt;pgstattuple&lt;/code&gt; showing 47% dead. Undo is recycled in a fixed-size ring (Oracle) or purged continuously (InnoDB).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secondary indexes are spared.&lt;/strong&gt; InnoDB&#39;s secondary indexes point at the primary key, a logical reference; Oracle&#39;s secondary indexes store the &lt;code&gt;ROWID&lt;/code&gt;, a physical address, but Oracle keeps that address stable during in-place updates and row migration. That way updating &lt;code&gt;last_seen&lt;/code&gt; leaves the four secondary indexes untouched. This single decision is most of the write-amplification gap Uber measured.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No vacuum.&lt;/strong&gt; Cleanup is woven into normal operation: InnoDB&#39;s purge threads trim undo history continuously, Oracle recycles undo segments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No wraparound rituals.&lt;/strong&gt; InnoDB&#39;s transaction IDs are 48-bit and Oracle&#39;s SCN is 64-bit since 12.2 (the 48-bit era ended with the headroom scare from charge 4), wide enough that neither engine ever has to freeze a tuple.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Four for four. Case closed?&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
One caveat before we count the wins: Oracle&#39;s default &lt;code&gt;READ COMMITTED&lt;/code&gt; takes a snapshot at the start of each statement, not at the start of the transaction. An idle transaction does not pin undo the way a PostgreSQL &lt;code&gt;REPEATABLE READ&lt;/code&gt; transaction does. The undo pressure, and the risk of &lt;code&gt;ORA-01555&lt;/code&gt;, comes from a long-running statement, an open cursor, or an explicit &lt;code&gt;SERIALIZABLE&lt;/code&gt; or &lt;code&gt;READ ONLY&lt;/code&gt; transaction.
&lt;/div&gt;
&lt;p&gt;One caveat to &quot;in place&quot;: it only works while the new version fits in the block. Oracle leaves headroom at insert time with &lt;code&gt;PCTFREE&lt;/code&gt; (default 10%). If the update fits there, the row expands in place and indexes are untouched. If it does not fit, Oracle moves the row to another block and leaves a forwarding pointer in the original slot. The &lt;code&gt;ROWID&lt;/code&gt; stays the same, so the indexes still need no maintenance.&lt;/p&gt;
&lt;p&gt;The cost moves to reads: an index lookup follows the pointer to a second block. Enough growth-heavy updates create migrated rows, and the table is eventually reorganized (&lt;code&gt;ALTER TABLE ... MOVE&lt;/code&gt;). PostgreSQL pays expansion as write amplification; Oracle pays it as read amplification and maintenance.&lt;/p&gt;
&lt;p&gt;Here is the bill.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rollback costs what the transaction cost.&lt;/strong&gt; PostgreSQL rolled back a one-million-row UPDATE in 0.124 milliseconds, because in a heap-versioning system &quot;abort&quot; means doing nothing: the old versions are already there. (It took 1.59 seconds to make the mess and 0.124 milliseconds to disown it; the mess remains, for vacuum, but your transaction is done.) In an undo-based engine, rollback walks the undo chain and reverses every change one row at a time, at roughly the cost of the forward direction, sometimes more. Every MySQL operator eventually kills a long-running UPDATE and then waits longer for the rollback than the statement ran, because the undo has to be applied; crash recovery replays the same job before the engine is fully healthy. It is precisely to escape this that SQL Server later grew a Postgres-style version store, but we&#39;re getting ahead of ourselves.&lt;/p&gt;
&lt;p&gt;Readers pay for that history at read time, too. In PostgreSQL, an old row version stays in the heap. Reading it costs the same as reading anything else. &lt;span class=&quot;sidenote&quot;&gt;When a reader accesses a recently committed tuple, it may first check pg_xact to set hint bits. This is a one-time cost per tuple, after which it remains in the heap.&lt;/span&gt; In the undo model, every version except the latest must be reconstructed for each read. This involves following pointers to another storage area and applying deltas. A long-running report on a hot table slows down as the snapshot gets older. This delay puts pressure on the buffer pool due to the undo pages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The idle-snapshot problem didn&#39;t go away. It changed its failure mode.&lt;/strong&gt; Undo space is finite. When a long-running query needs a version whose undo has already been recycled, Oracle doesn&#39;t pause cleanup like PostgreSQL does. It kills the query: &lt;code&gt;ORA-01555: snapshot too old&lt;/code&gt;, one of the most familiar error codes in databases. InnoDB makes the opposite choice within the same design: purge &lt;em&gt;waits&lt;/em&gt; for the oldest read view, history piles up (watch the history list length climb in &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt;), and undo tablespaces balloon.&lt;span class=&quot;sidenote&quot;&gt;MySQL 8&#39;s automatic undo truncation does shrink tablespaces whose logs have gone inactive, but the long read that caused the growth is exactly what keeps the logs active, so the garbage stays put for as long as the reader does.&lt;/span&gt; That is bloat, relocated to a place &lt;code&gt;pgstattuple&lt;/code&gt; can&#39;t see. Same fundamental tension, choose your poison: cancel the reader, or keep the garbage.&lt;/p&gt;
&lt;p&gt;InnoDB&#39;s purge threads are, functionally, vacuum under another name: they scan undo history, remove delete-marked index records, and can fall behind under write bursts exactly the way autovacuum can. The war-story threads on the MySQL side (history list length in the millions, purge lag stalls) read like Postgres bloat threads with the nouns changed.&lt;/p&gt;
&lt;p&gt;So the undo camp didn&#39;t eliminate MVCC&#39;s costs. It moved them: away from the future (background cleanup, table bloat) and onto the writer&#39;s critical path (rollback, in-place update bookkeeping) and the reader of old data (reconstruction). If your workload commits nearly always, updates heavily indexed rows, and rarely runs long reads against hot tables, that trade is genuinely better, that is, roughly, the workload Uber had.&lt;/p&gt;
&lt;h2 id=&quot;sql-server-versioning-as-an-optional-extra&quot;&gt;SQL Server: versioning as an optional extra&lt;a class=&quot;zola-anchor&quot; href=&quot;#sql-server-versioning-as-an-optional-extra&quot; aria-label=&quot;Anchor link for: sql-server-versioning-as-an-optional-extra&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;SQL Server is the reminder that MVCC itself was a choice. Its default READ COMMITTED isn&#39;t versioned at all: readers take shared locks, writers take exclusive locks, and they block each other. The concurrency problems MVCC exists to solve are simply... allowed to happen, managed with lock hints and shorter transactions. For years the standard fix for &quot;my readers are blocked&quot; was &lt;code&gt;WITH (NOLOCK)&lt;/code&gt;, which is opting into reading uncommitted rows.&lt;/p&gt;
&lt;p&gt;Since 2005, versioning is available by opt-in (&lt;code&gt;READ_COMMITTED_SNAPSHOT&lt;/code&gt; or full &lt;code&gt;SNAPSHOT&lt;/code&gt; isolation). Flip it on and SQL Server starts copying pre-update row versions into a &lt;strong&gt;version store&lt;/strong&gt; that lives in &lt;code&gt;tempdb&lt;/code&gt;, stamping each modified row with a version pointer.&lt;span class=&quot;sidenote&quot;&gt;The pointer is 14 bytes, added to every versioned row, and used to chase that row&#39;s chain back through tempdb.&lt;/span&gt; It&#39;s an undo-style design bolted onto a locking engine, with one memorable operational property: the version store is shared, instance-wide, and lives in the same tempdb as everyone&#39;s sort spills and temp tables. One forgotten long-running snapshot transaction and the version store cannot be trimmed; tempdb grows until it runs out of disk, at which point the &lt;em&gt;entire instance&lt;/em&gt; starts failing queries, including databases that had nothing to do with it. The idle-snapshot problem again, with the widest blast radius of any design in this post.&lt;/p&gt;
&lt;p&gt;The most interesting SQL Server development is recent: &lt;strong&gt;Accelerated Database Recovery&lt;/strong&gt; (2019, on by default in Azure SQL) added a &lt;em&gt;persistent&lt;/em&gt; version store, and note where it lives: inside the user database, not tempdb. Row versions move into a per-database PVS, which both shrinks the instance-wide blast radius described above and makes rollback and crash recovery instant regardless of transaction size, reading old versions back instead of replaying undo. The flagship locking-and-undo engine spent significant engineering effort to acquire the one property, constant-time abort, that PostgreSQL&#39;s much-maligned design has had for free since day one. Trade-offs run in both directions.&lt;/p&gt;
&lt;h2 id=&quot;mongodb-versions-in-the-cache&quot;&gt;MongoDB: versions in the cache&lt;a class=&quot;zola-anchor&quot; href=&quot;#mongodb-versions-in-the-cache&quot; aria-label=&quot;Anchor link for: mongodb-versions-in-the-cache&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;MongoDB&#39;s WiredTiger engine answers the four questions with an accent you haven&#39;t seen yet: old versions live in RAM. A read runs against a snapshot taken when it starts; a writer appends its change as an in-memory delta hanging off the page&#39;s current image, and readers walk the chain back to the version their snapshot is entitled to see. Checkpoints periodically flush clean page images to disk, a journal provides durability, and, note this, coming out of the undo section, rollback is free: uncommitted versions were never on disk, so aborting a transaction means discarding memory. WiredTiger has had constant-time abort since day one, without Microsoft&#39;s engineering program.&lt;/p&gt;
&lt;p&gt;The design buys real things: no dead tuples on disk, no undo tablespace to size, readers never blocked. The bill arrives through a single fact: the cache is finite, shared, and cannot evict a version that some snapshot still needs.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The oldest snapshot pins history.&lt;/strong&gt; Versions pushed out of the cache while still visible to an old reader spill into a &lt;em&gt;history store&lt;/em&gt; (&lt;code&gt;WiredTigerHS.wt&lt;/code&gt;), which grows for exactly as long as the pinning reader lives. InnoDB&#39;s history list, as a file: bloat, relocated somewhere you have to know to look.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Under pressure, everyone pays.&lt;/strong&gt; Let the cache fill with dirty or pinned content and eviction can&#39;t keep up; WiredTiger then recruits application threads into eviction work, and the failure mode is not an error but a latency smear across every operation on the node, readers included. In the worst cases the engine starts rolling transactions back for you, cache full. The idle-snapshot problem, failing by slowdown instead of by bloat.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Snapshot too old, again.&lt;/strong&gt; Ask for a read at a timestamp older than the pinned history can serve and MongoDB answers with &lt;code&gt;SnapshotTooOld&lt;/code&gt;. Third engine in this post to have that error.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The physical-pointer charge survives.&lt;/strong&gt; Documents are indexed by RecordId, a physical slot; grow a document past its space and it moves, and every secondary index updates to follow it. Uber&#39;s complaint, in a document store.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cleanup is eviction plus checkpoints: vacuum, running continuously, in RAM, mostly invisible, until a snapshot stays open over lunch.&lt;/p&gt;
&lt;h2 id=&quot;the-lsm-generation-garbage-collection-as-a-lifestyle&quot;&gt;The LSM generation: garbage collection as a lifestyle&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-lsm-generation-garbage-collection-as-a-lifestyle&quot; aria-label=&quot;Anchor link for: the-lsm-generation-garbage-collection-as-a-lifestyle&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The distributed SQL newcomers, CockroachDB and YugabyteDB most prominently, store data in LSM trees (RocksDB or derivatives), and their MVCC answer is different again: a row version is simply a &lt;em&gt;key&lt;/em&gt;, the logical key suffixed with a commit timestamp. Updates never touch anything in place, they just write a newer key. Reads scan for the newest version at or below their snapshot timestamp. Deletes write a tombstone key.&lt;/p&gt;
&lt;p&gt;In one sense this is PostgreSQL&#39;s philosophy taken further: append versions, never modify, clean later. The cleanup story differs. Old versions are removed by &lt;strong&gt;compaction&lt;/strong&gt;, the LSM&#39;s normal background rewriting of storage layers, governed by a garbage-collection window: versions younger than the TTL are kept for time-travel reads and follower reads; CockroachDB&#39;s default window has historically been measured in hours (originally 25, since reduced). No vacuum command exists, and the marketing will mention that. But compaction &lt;em&gt;is&lt;/em&gt; vacuum, running continuously, paying the same deferred-cleanup tax as write amplification inside the storage engine, and the failure modes rhyme with everything above:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Read too far in the past, below the GC window, and the query fails. Snapshot too old, rediscovered.&lt;/li&gt;
&lt;li&gt;Heavy update or delete churn on a narrow key range leaves layers of tombstones and stale versions that every scan must wade through until compaction catches up. The classic symptom is the queue table that gets slower the emptier it is, an antipattern LSM users know as intimately as Postgres users know bloated tables.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nothing was solved. The four questions got new answers, and the costs moved into the compactor.&lt;/p&gt;
&lt;h2 id=&quot;outside-the-rdbms-entirely-etcd&quot;&gt;Outside the RDBMS entirely: etcd&lt;a class=&quot;zola-anchor&quot; href=&quot;#outside-the-rdbms-entirely-etcd&quot; aria-label=&quot;Anchor link for: outside-the-rdbms-entirely-etcd&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Leave the relational world and the same four questions reappear, verbatim. etcd, the consensus-backed key-value store that holds the desired state of every Kubernetes cluster, is MVCC by name and by design. Every write bumps a cluster-wide &lt;strong&gt;revision&lt;/strong&gt; counter, and values are stored under &lt;code&gt;(key, revision)&lt;/code&gt; in a bbolt file. An update never overwrites; it appends a newer revision of the key. A read at a given revision gets the newest version at or below it. A delete writes a tombstone. If that sounds like the LSM section you just read, it should. And if it sounds like the four charges at the top of this post, keep reading.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Versions live in the store; cleanup is a separate job.&lt;/strong&gt; Old revisions sit in the same backend file as current data until &lt;strong&gt;compaction&lt;/strong&gt; removes them, run by hand (&lt;code&gt;etcdctl compact&lt;/code&gt;) or on a schedule (&lt;code&gt;--auto-compaction-retention&lt;/code&gt;). Skip it and the backend grows without bound: garbage created at write speed, collected at compaction speed. The bloat treadmill, minus the SQL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cleanup doesn&#39;t return the disk.&lt;/strong&gt; bbolt, like PostgreSQL&#39;s heap, never shrinks its file; compacted pages go to an internal freelist for reuse. Getting the space back means &lt;code&gt;etcdctl defrag&lt;/code&gt;, run member by member, each node briefly stalling while it rewrites. VACUUM FULL, one quorum member at a time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&quot;Snapshot too old,&quot; rediscovered verbatim.&lt;/strong&gt; Read or watch from a revision older than the compaction point and etcd fails you: &lt;code&gt;etcdserver: mvcc: required revision has been compacted&lt;/code&gt;. ORA-01555 again, surfacing in every Kubernetes cluster whose controllers resume a watch from a stale &lt;code&gt;resourceVersion&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exceed the default 2GB backend quota and etcd raises &lt;code&gt;NOSPACE&lt;/code&gt; and refuses writes, including the Kubernetes control plane&#39;s, until a human compacts, defrags, and disarms the alarm. It&#39;s the wraparound outage&#39;s closest non-relational cousin: a safety limit that turns a storage-hygiene chore into an outage, with the babysitting outsourced to the operator.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;etcd answered the four questions almost exactly the way PostgreSQL did: append versions, never modify, clean later, never cancel a reader early. It inherited the same operations calendar. The questions predate the relational model&#39;s dominance and will outlive it; there is no escaping the bill, only choosing who pays it.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
Its distributed-KV neighbor FoundationDB answers the lunch question more brutally than anyone in this post: transactions have a hard five-second lifetime, full stop. Hold a transaction open over lunch and you don&#39;t hold back a vacuum, a purge, or a compactor, your transaction is simply dead. It&#39;s the one design here that made the idle-transaction problem the client&#39;s problem, by fiat.
&lt;/div&gt;
&lt;h2 id=&quot;fixing-postgresql-itself&quot;&gt;Fixing PostgreSQL itself&lt;a class=&quot;zola-anchor&quot; href=&quot;#fixing-postgresql-itself&quot; aria-label=&quot;Anchor link for: fixing-postgresql-itself&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;If the undo design were clearly better, we should simply use an undo-based storage engine for PostgreSQL. People tried. &lt;strong&gt;zheap&lt;/strong&gt;, announced in 2018, was exactly that: in-place updates with undo logs, targeting the bloat problem head-on. It led to years of work at EnterpriseDB on a general undo framework for core. Then, it found a second life at CYBERTEC, but now the code is mostly dormant. The point isn’t that Álvaro Herrera from EDB outlined Postgres for 2029 in his &lt;a rel=&quot;external&quot; href=&quot;https://www.postgresql.eu/events/pgconfde2026/sessions/session/7744/slides/866/edb-keynote-pgconfde-2026.pdf&quot;&gt;PGConf.DE 2026 keynote&lt;/a&gt;. The main request for in-core table access was &quot;zheap (no need for vacuum).&quot; Note the verb tense, though: could have, in ten years. The key lesson from zheap is its simple design is crucial. Once versions exist in undo, every subsystem—like recovery, replication, indexes, and hot standby—adopts new rules. This means you might need to rebuild half the engine just to change how the other half stores rows.&lt;/p&gt;
&lt;p&gt;The attempt continues outside core. &lt;strong&gt;OrioleDB&lt;/strong&gt; (the engine by Alexander Korotkov, now part of Supabase) blends undo-based versioning with index-organised tables and row-level WAL. It shows impressive benchmark results, proving that a fresh approach can be beneficial. However, it still needs patches to core PostgreSQL, which are being integrated upstream. The &lt;strong&gt;table access method&lt;/strong&gt; API (PostgreSQL 12) allows alternatives to the heap. Also, 64-bit transaction IDs could prevent wraparound completely. They have a long-running patchset and are included in at least one commercial fork.&lt;/p&gt;
&lt;p&gt;Meanwhile, core PostgreSQL has spent fifteen years shrinking the constants without changing the design. HOT updates arrived in 8.3. The visibility map that lets vacuum skip clean pages came in 8.4 and improved in 9.6. B-tree deduplication landed in 13, and bottom-up index deletion that fights version churn inside index pages came in 14. Version 17 updated the vacuum&#39;s TID store. It removed the memory limit that used to require multi-pass index vacuums. Version 19 added &lt;code&gt;REPACK&lt;/code&gt;, consolidating &lt;code&gt;VACUUM FULL&lt;/code&gt; and &lt;code&gt;CLUSTER&lt;/code&gt; with a &lt;code&gt;CONCURRENTLY&lt;/code&gt; mode that rebuilds without the exclusive lock. The four charges from the top of this post were all worse in 2010. The design held; the bill shrank.&lt;/p&gt;
&lt;h2 id=&quot;the-scorecard&quot;&gt;The scorecard&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-scorecard&quot; aria-label=&quot;Anchor link for: the-scorecard&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Five storage designs (etcd maps onto the PostgreSQL column), the same four questions, and no column without a failure mode:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;PostgreSQL heap&lt;/th&gt;&lt;th&gt;Oracle / InnoDB undo&lt;/th&gt;&lt;th&gt;SQL Server + RCSI&lt;/th&gt;&lt;th&gt;WiredTiger (MongoDB)&lt;/th&gt;&lt;th&gt;LSM (CockroachDB etc.)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Old versions live&lt;/td&gt;&lt;td&gt;in the table&lt;/td&gt;&lt;td&gt;undo segments/logs&lt;/td&gt;&lt;td&gt;tempdb version store&lt;/td&gt;&lt;td&gt;in the cache, as delta chains (spill to history store)&lt;/td&gt;&lt;td&gt;in the LSM, timestamped keys&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Update writes&lt;/td&gt;&lt;td&gt;full new row + all index entries (unless HOT)&lt;/td&gt;&lt;td&gt;in place + undo delta; unchanged secondary indexes untouched&lt;/td&gt;&lt;td&gt;in place + version copy to tempdb&lt;/td&gt;&lt;td&gt;in-memory delta; page rewritten at checkpoint; moved documents update all indexes&lt;/td&gt;&lt;td&gt;new key version&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Secondary indexes point at&lt;/td&gt;&lt;td&gt;physical ctid; every index follows every moved version (HOT excepted)&lt;/td&gt;&lt;td&gt;logical PK (InnoDB), rowid (Oracle); stable under in-place update&lt;/td&gt;&lt;td&gt;clustering key / RID; rows gain a 14-byte version tag&lt;/td&gt;&lt;td&gt;RecordId; a moved document rewrites every index&lt;/td&gt;&lt;td&gt;logical keys; each version is a new key, timestamp-suffixed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Abort cost&lt;/td&gt;&lt;td&gt;~zero&lt;/td&gt;&lt;td&gt;proportional to work done&lt;/td&gt;&lt;td&gt;~zero with ADR (2019+); undo replay before&lt;/td&gt;&lt;td&gt;~zero (uncommitted deltas discarded)&lt;/td&gt;&lt;td&gt;~zero (intents removed lazily)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Long reader causes&lt;/td&gt;&lt;td&gt;bloat, vacuum starvation, held xmin&lt;/td&gt;&lt;td&gt;ORA-01555 &lt;em&gt;or&lt;/em&gt; undo/history growth&lt;/td&gt;&lt;td&gt;tempdb growth, instance-wide risk (ADR moves versions into the user DB)&lt;/td&gt;&lt;td&gt;history store growth, cache pressure, eviction stalls&lt;/td&gt;&lt;td&gt;GC-window errors, tombstone debt&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cleanup&lt;/td&gt;&lt;td&gt;vacuum (yours to tune)&lt;/td&gt;&lt;td&gt;purge/undo recycling (theirs, still stalls)&lt;/td&gt;&lt;td&gt;version store cleanup&lt;/td&gt;&lt;td&gt;eviction + checkpoints (continuous, in RAM)&lt;/td&gt;&lt;td&gt;compaction (continuous)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Signature outage&lt;/td&gt;&lt;td&gt;wraparound, bloat&lt;/td&gt;&lt;td&gt;history list explosion, day-long rollback&lt;/td&gt;&lt;td&gt;tempdb full&lt;/td&gt;&lt;td&gt;node-wide latency smear; SnapshotTooOld&lt;/td&gt;&lt;td&gt;tombstone-choked scans&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The costs of multi-versioning, keeping history, reading history, discarding history, are conserved. Each engine only chooses &lt;em&gt;who&lt;/em&gt; pays, &lt;em&gt;when&lt;/em&gt;, and &lt;em&gt;how it fails&lt;/em&gt;: PostgreSQL charges the future and fails by bloating; the undo camp charges the writer and the reader-of-history, and fails by cancelling queries or ballooning undo; SQL Server charges tempdb and fails instance-wide; WiredTiger charges the cache and fails by slowing everyone at once; the LSM engines charge the compactor and fail on time-travel and churn.&lt;/p&gt;
&lt;p&gt;PostgreSQL&#39;s particular choice, viewed from this side of the table, is the one that never blocks a reader, never cancels a query with &quot;snapshot too old&quot; (unless you &lt;a rel=&quot;external&quot; href=&quot;https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-OLD-SNAPSHOT-THRESHOLD&quot;&gt;opt in&lt;/a&gt;, and even that was removed in 17 for lack of love), never makes you wait for a rollback, and never hides its garbage where you can&#39;t inspect it. Every dead tuple is sitting in an 8KB page you can open with &lt;code&gt;pageinspect&lt;/code&gt;. The price is that the garbage is yours: visible, measurable, and on your maintenance calendar.&lt;/p&gt;
&lt;p&gt;So yes, PostgreSQL&#39;s MVCC is bad. So is everyone else&#39;s. Postgres chose operationally loud failure modes and manually tuned mitigations, which is why zheap was attempted and why OrioleDB exists. But if someone tells you a database has solved multi-versioning, ask them the four questions: where do old versions live, which way do chains point, what do indexes point at, and who cleans up. Then ask what happens when a transaction stays open over lunch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;UPDATED 2027-07-29:&lt;/strong&gt; Added the Oracle row-migration caveat after consulting with a reader about what was worth noting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;UPDATED 2027-07-30:&lt;/strong&gt; Added a callout clarifying that Oracle&#39;s default &lt;code&gt;READ COMMITTED&lt;/code&gt; uses statement-level snapshots, not transaction-level snapshots.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;UPDATED 2026-08-05:&lt;/strong&gt; Fixed to HOT-update paragraph (failure conditions) and correct 64-bit SCN chronology.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>VACUUM at the Page Level</title>
        <published>2026-07-05T17:13:00+00:00</published>
        <updated>2026-07-05T17:13:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/vacuum-at-the-page-level/"/>
        <id>https://boringsql.com/posts/vacuum-at-the-page-level/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/vacuum-at-the-page-level/">&lt;p&gt;In &lt;a href=&quot;/posts/hot-updates/&quot;&gt;HOT Updates in Postgres&lt;/a&gt; we covered page pruning clean up HOT chains, an elegant shortcut where PostgreSQL reclaims dead tuple space during ordinary reads. All that without waiting for any background process. But pruning is exactly that: a shortcut. It only works within a single page, and only for HOT-updated tuples. For everything else (cold updates that touch indexed columns, plain DELETEs, index entry cleanup, free space map registration, visibility map maintenance) we need VACUUM.&lt;/p&gt;
&lt;p&gt;This article won&#39;t repeat what VACUUM does operationally. The &lt;a href=&quot;/posts/deletes-are-difficult/&quot;&gt;DELETEs are difficult&lt;/a&gt; article covers autovacuum tuning, worker allocation, and the operational side of dead tuple cleanup. Here we are going to watch VACUUM work byte by byte. We&#39;ll snapshot a page before and after each phase, tracking exactly what changes in the page header, line pointers, tuple headers, free space map, and visibility map. Same tools as always: &lt;code&gt;pageinspect&lt;/code&gt;, &lt;code&gt;pg_visibility&lt;/code&gt;, and &lt;code&gt;pg_freespacemap&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;a class=&quot;zola-anchor&quot; href=&quot;#setup&quot; aria-label=&quot;Anchor link for: setup&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;We need a table with enough rows to make the before-and-after comparison meaningful, plus indexes to demonstrate the full VACUUM cycle.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pageinspect;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pg_visibility;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pg_freespacemap;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; vacuum_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    payload &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; vacuum_demo (category, payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;cat_&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; (i % &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;x&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fifty rows with a 100-byte payload each. The primary key gives us an index, which matters: VACUUM&#39;s behavior changes when indexes are involved. Run VACUUM once upfront so we start from a clean baseline:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM vacuum_demo;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;snapshot-before-any-deletes&quot;&gt;Snapshot before any deletes&lt;a class=&quot;zola-anchor&quot; href=&quot;#snapshot-before-any-deletes&quot; aria-label=&quot;Anchor link for: snapshot-before-any-deletes&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Record the baseline state of page 0. First the page header:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lower, upper, special, pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lower | upper | special | pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------+---------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   224 |  1392 |    8192 |     8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pd_lower is at 224: that&#39;s the 24-byte page header plus 50 line pointers at 4 bytes each (24 + 200 = 224). pd_upper is at 1392, so our tuples occupy bytes 1392 through 8191. Free space is 1392 - 224 = 1168 bytes. Not much room left; those 100-byte payloads add up.&lt;/p&gt;
&lt;p&gt;Now the line pointers and tuple headers:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+----------+--------+--------+--------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |        1 |   8056 |    135 |    746 |      0 | (0,1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |        1 |   7920 |    135 |    746 |      0 | (0,2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |        1 |   7784 |    135 |    746 |      0 | (0,3)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |        1 |   7648 |    135 |    746 |      0 | (0,4)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |        1 |   7512 |    135 |    746 |      0 | (0,5)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |        1 |   7376 |    135 |    746 |      0 | (0,6)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |        1 |   7240 |    135 |    746 |      0 | (0,7)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  8 |        1 |   7104 |    135 |    746 |      0 | (0,8)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  9 |        1 |   6968 |    135 |    746 |      0 | (0,9)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 10 |        1 |   6832 |    135 |    746 |      0 | (0,10)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;&lt;code&gt;lp_len&lt;/code&gt; is 135, the tuple&#39;s actual byte length, but each tuple occupies MAXALIGN&#39;d slot of 136 bytes on the page; notice the &lt;code&gt;lp_off&lt;/code&gt; values step down by 136. That aligned stride is what the free-space arithmetic below uses.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Every line pointer is LP_NORMAL (lp_flags = 1). Every tuple has &lt;code&gt;t_xmax = 0&lt;/code&gt;: nobody has touched these rows since they were inserted. Every &lt;code&gt;t_ctid&lt;/code&gt; points to itself. This is a perfectly clean page.&lt;/p&gt;
&lt;h2 id=&quot;create-dead-tuples&quot;&gt;Create dead tuples&lt;a class=&quot;zola-anchor&quot; href=&quot;#create-dead-tuples&quot; aria-label=&quot;Anchor link for: create-dead-tuples&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Now make some rows dead:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;/span&gt;&lt;span&gt; vacuum_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id % &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DELETE 16&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That deletes roughly every third row: IDs 3, 6, 9, 12, and so on. Sixteen rows are now dead. Look at the page before VACUUM runs:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+----------+--------+--------+--------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |        1 |   8056 |    135 |    746 |      0 | (0,1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |        1 |   7920 |    135 |    746 |      0 | (0,2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |        1 |   7784 |    135 |    746 |    747 | (0,3)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |        1 |   7648 |    135 |    746 |      0 | (0,4)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |        1 |   7512 |    135 |    746 |      0 | (0,5)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |        1 |   7376 |    135 |    746 |    747 | (0,6)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |        1 |   7240 |    135 |    746 |      0 | (0,7)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  8 |        1 |   7104 |    135 |    746 |      0 | (0,8)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  9 |        1 |   6968 |    135 |    746 |    747 | (0,9)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 10 |        1 |   6832 |    135 |    746 |      0 | (0,10)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look at rows 3, 6, and 9. Their &lt;code&gt;t_xmax&lt;/code&gt; is now 747, the transaction ID of the DELETE statement. But everything else is unchanged. lp_flags is still 1 (LP_NORMAL). lp_off and lp_len are the same. The tuples are still physically sitting on the page, consuming space. The page header hasn&#39;t changed either:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lower, upper, special, pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lower | upper | special | pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------+---------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   224 |  1392 |    8192 |     8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pd_lower and pd_upper are identical to before the DELETE. PostgreSQL marked the rows as dead (by stamping t_xmax) but did not reclaim a single byte. The dead tuples are bloat, and they will stay that way until VACUUM arrives.&lt;/p&gt;
&lt;h2 id=&quot;how-vacuum-processes-a-table&quot;&gt;How VACUUM processes a table&lt;a class=&quot;zola-anchor&quot; href=&quot;#how-vacuum-processes-a-table&quot; aria-label=&quot;Anchor link for: how-vacuum-processes-a-table&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Before we run VACUUM and watch the page change, one thing about &lt;em&gt;how&lt;/em&gt; it runs will explain everything the snapshots are about to show. VACUUM does its work in three passes, and the split between them is the whole reason a deleted tuple&#39;s storage disappears at one moment and its line pointer disappears at another. Keep an eye on that gap: it is what the rest of this article makes visible.&lt;/p&gt;
&lt;h3 id=&quot;phase-1-heap-scan-prune-freeze-collect-dead-tids&quot;&gt;Phase 1: Heap scan - prune, freeze, collect dead TIDs&lt;a class=&quot;zola-anchor&quot; href=&quot;#phase-1-heap-scan-prune-freeze-collect-dead-tids&quot; aria-label=&quot;Anchor link for: phase-1-heap-scan-prune-freeze-collect-dead-tids&quot;&gt;&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;VACUUM scans every heap page sequentially, and this first pass does far more than look. For each page, it runs page pruning (the same &lt;code&gt;heap_page_prune_and_freeze&lt;/code&gt; machinery that fires opportunistically during ordinary reads). Pruning is where the bytes actually come back: it removes the storage of dead tuples, defragments the page, and advances pd_upper. It also opportunistically freezes tuples that are old enough.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
Prior to PostgreSQL 17, VACUUM stored dead TIDs in a flat array allocated from &lt;code&gt;maintenance_work_mem&lt;/code&gt;. If the array filled up, VACUUM had to pause, perform index and heap cleanup for the collected batch, then resume scanning. Since PostgreSQL 17, VACUUM uses a radix tree-based TID store that is far more memory-efficient, making &lt;code&gt;maintenance_work_mem&lt;/code&gt; much less likely to be a bottleneck.
&lt;/div&gt;
&lt;p&gt;But here is the subtlety that the rest of this article hinges on. Pruning cannot simply mark a deleted tuple&#39;s line pointer LP_UNUSED, because indexes still point at it by TID. So for a table with indexes, a dead tuple&#39;s line pointer is set to &lt;strong&gt;LP_DEAD&lt;/strong&gt;: its storage is gone, but the 4-byte slot stays put, holding the TID&#39;s place until the index entries are removed. Those LP_DEAD TIDs are what VACUUM collects into its dead-TID store for the next phase.&lt;/p&gt;
&lt;h3 id=&quot;phase-2-index-cleanup&quot;&gt;Phase 2: Index cleanup&lt;a class=&quot;zola-anchor&quot; href=&quot;#phase-2-index-cleanup&quot; aria-label=&quot;Anchor link for: phase-2-index-cleanup&quot;&gt;&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;With the list of dead TIDs in hand, VACUUM scans each index on the table. For every index, it walks through all index entries and removes any that point to a dead TID. This is the expensive part. VACUUM must read every index page, even if only handful of entries need removal.&lt;/p&gt;
&lt;p&gt;This is also why index bloat happens. If VACUUM cannot finish this phase, because a long-running transaction is holding back the visibility horizon or the table has many indexes and the dead TID list exceeds memory, index entries pointing to dead tuples accumulate. We covered the index bloat implications in detail in &lt;a href=&quot;/posts/vacuum-is-lie/&quot;&gt;VACUUM Is a Lie&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;phase-3-heap-cleanup-freeing-the-line-pointers&quot;&gt;Phase 3: Heap cleanup - freeing the line pointers&lt;a class=&quot;zola-anchor&quot; href=&quot;#phase-3-heap-cleanup-freeing-the-line-pointers&quot; aria-label=&quot;Anchor link for: phase-3-heap-cleanup-freeing-the-line-pointers&quot;&gt;&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;After the indexes are clean, no index entry references those dead TIDs anymore, so the reserved slots can finally be released. VACUUM revisits each page that had dead tuples and does what it couldn&#39;t do in phase 1:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Flips each collected LP_DEAD line pointer to LP_UNUSED (0), reclaiming the slot&lt;/li&gt;
&lt;li&gt;Sets the visibility map bit if all remaining tuples are visible to everyone&lt;/li&gt;
&lt;/ol&gt;
&lt;div class=&quot;sidenote&quot;&gt;
A table with &lt;strong&gt;no indexes&lt;/strong&gt; skips this split entirely: with no index entries to worry about, phase 1&#39;s prune sets dead line pointers straight to LP_UNUSED in a single heap pass, and there is no phase 3. The two-pass dance, LP_DEAD now and LP_UNUSED later, exists precisely because of the indexes.
&lt;/div&gt;
&lt;p&gt;Notice what is &lt;em&gt;not&lt;/em&gt; on that list. The tuple data was already removed and the page already defragmented back in phase 1&#39;s prune; that is where pd_upper moved. Phase 3 reclaims the line-pointer slots, not the tuple bytes. The distinction is invisible if you only look before and after plain &lt;code&gt;VACUUM&lt;/code&gt;, so let&#39;s make it visible.&lt;/p&gt;
&lt;p&gt;Let&#39;s run VACUUM in two steps to catch the intermediate state. &lt;code&gt;VACUUM (INDEX_CLEANUP OFF)&lt;/code&gt; performs phase 1&#39;s prune but skips index cleanup, and therefore skips the second heap pass too; it has no choice but to leave the dead line pointers as LP_DEAD.&lt;/p&gt;
&lt;div class=&quot;visualizer-banner&quot;&gt;
    &lt;div class=&quot;visualizer-banner__preview&quot; style=&quot;grid-template-columns: 1fr; gap: 3px; width: 54px; padding: 0.4rem;&quot;&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(22,163,74,0.55);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(220,38,38,0.4); border:1px dashed rgba(220,38,38,0.6);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(22,163,74,0.55);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(0,0,0,0.12);&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;visualizer-banner__content&quot;&gt;
        &lt;strong&gt;Interactive VACUUM Visualizer&lt;/strong&gt;
        &lt;p&gt;Run DELETE across three heap pages and watch nothing get reclaimed, page-prune to reach &lt;code&gt;LP_DEAD&lt;/code&gt;, then step VACUUM through heap scan, index cleanup, and heap cleanup that flips the slots to &lt;code&gt;LP_UNUSED&lt;/code&gt;.&lt;/p&gt;
        &lt;a href=&quot;/visualizers/vacuum/&quot; class=&quot;visualizer-banner__button&quot;&gt;Open Visualizer&lt;/a&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;after-the-prune-lp-dead-and-space-already-back&quot;&gt;After the prune: LP_DEAD, and space already back&lt;a class=&quot;zola-anchor&quot; href=&quot;#after-the-prune-lp-dead-and-space-already-back&quot; aria-label=&quot;Anchor link for: after-the-prune-lp-dead-and-space-already-back&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Page header after pruning:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lower, upper, special, pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lower | upper | special | pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------+---------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   224 |  3568 |    8192 |     8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pd_lower is still 224; the line pointer array hasn&#39;t shrunk. But pd_upper jumped from 1392 to 3568. That&#39;s 2176 bytes of reclaimed space (16 dead tuples at the 136-byte aligned stride = 2176). Free space on the page went from 1168 to 3344 bytes. And we have not touched an index yet: this all happened during pruning, in the first heap pass.&lt;/p&gt;
&lt;p&gt;Now the line pointers:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+----------+--------+--------+--------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |        1 |   8056 |    135 |    746 |      0 | (0,1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |        1 |   7920 |    135 |    746 |      0 | (0,2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |        3 |      0 |      0 |        |        |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |        1 |   7784 |    135 |    746 |      0 | (0,4)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |        1 |   7648 |    135 |    746 |      0 | (0,5)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |        3 |      0 |      0 |        |        |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |        1 |   7512 |    135 |    746 |      0 | (0,7)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  8 |        1 |   7376 |    135 |    746 |      0 | (0,8)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  9 |        3 |      0 |      0 |        |        |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 10 |        1 |   7240 |    135 |    746 |      0 | (0,10)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Line pointers 3, 6, and 9 are now &lt;strong&gt;lp_flags = 3 (LP_DEAD)&lt;/strong&gt;, not LP_UNUSED. Their storage is gone (lp_off and lp_len are 0), but the slots are still there, reserving those TIDs. They can&#39;t be freed yet, because the primary key still points at them:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; live_items &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; bt_page_stats(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo_pkey&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; live_items&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         50&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fifty index entries, same as before the delete. The index cleanup we skipped is exactly what removes the sixteen stale ones, and until it runs, those LP_DEAD slots stay stuck.&lt;/p&gt;
&lt;h2 id=&quot;after-the-full-vacuum-lp-unused&quot;&gt;After the full VACUUM: LP_UNUSED&lt;a class=&quot;zola-anchor&quot; href=&quot;#after-the-full-vacuum-lp-unused&quot; aria-label=&quot;Anchor link for: after-the-full-vacuum-lp-unused&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Now run an ordinary VACUUM to finish the job:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM vacuum_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lower, upper, special, pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lower | upper | special | pagesize&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------+---------+----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   224 |  3568 |    8192 |     8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pd_upper is unchanged at 3568. That is the point: the second heap pass reclaims no tuple bytes; there are none left, pruning already took them. What it does is free the line-pointer slots, now that the index is clean:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+----------+--------+--------+--------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |        1 |   8056 |    135 |    746 |      0 | (0,1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |        1 |   7920 |    135 |    746 |      0 | (0,2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |        0 |      0 |      0 |        |        |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |        1 |   7784 |    135 |    746 |      0 | (0,4)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |        1 |   7648 |    135 |    746 |      0 | (0,5)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |        0 |      0 |      0 |        |        |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |        1 |   7512 |    135 |    746 |      0 | (0,7)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  8 |        1 |   7376 |    135 |    746 |      0 | (0,8)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  9 |        0 |      0 |      0 |        |        |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 10 |        1 |   7240 |    135 |    746 |      0 | (0,10)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(10 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Line pointers 3, 6, and 9 have gone from LP_DEAD to &lt;strong&gt;lp_flags = 0 (LP_UNUSED)&lt;/strong&gt;. The slots are now empty and available for reuse by the next INSERT that lands on this page. And the index has dropped to 34 entries; the sixteen stale ones are gone:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; live_items &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; bt_page_stats(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo_pkey&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; live_items&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         34&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The surviving heap tuples were compacted back in the prune; notice the lp_off values shifted from their pre-VACUUM positions, VACUUM having moved surviving tuples to form a single contiguous block of free space between pd_lower and pd_upper.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;pd_lower didn&#39;t change.&lt;/strong&gt; Even though line pointers 3, 6, and 9 are now LP_UNUSED, they still occupy slots in the array. The next INSERT will reuse one of these slots rather than appending a new one at the end. PostgreSQL tracks reusable slots via the &lt;code&gt;PD_HAS_FREE_LINES&lt;/code&gt; flag in the page header.
&lt;/div&gt;
&lt;h2 id=&quot;the-line-pointer-lifecycle-precisely&quot;&gt;The line pointer lifecycle, precisely&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-line-pointer-lifecycle-precisely&quot; aria-label=&quot;Anchor link for: the-line-pointer-lifecycle-precisely&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Let&#39;s be exact about how line pointers transition between states. This matters because different code paths produce different transitions:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LP_UNUSED (0)&lt;/strong&gt;: the slot is empty. Either it was never used, or VACUUM has fully reclaimed it. The next INSERT that targets this page will grab this slot and assign it to a new tuple, transitioning it to LP_NORMAL.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LP_NORMAL (1)&lt;/strong&gt;: the slot points to a tuple on the page. That tuple might be live (t_xmax = 0 or t_xmax aborted), or it might be dead (t_xmax committed and invisible to all). The line pointer itself doesn&#39;t encode liveness; that&#39;s determined by the tuple header and the visibility rules we covered in &lt;a href=&quot;/posts/postgresql-mvcc-byte-by-byte/&quot;&gt;PostgreSQL MVCC, Byte by Byte&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LP_REDIRECT (2)&lt;/strong&gt;: the slot points not to a tuple but to another line pointer. This is created by HOT pruning: when the head of a HOT chain is pruned, its line pointer becomes a redirect so that indexes (which still reference the original line pointer number) can follow the redirect to find the current version. We saw this in &lt;a href=&quot;/posts/hot-updates/&quot;&gt;HOT Updates in Postgres&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LP_DEAD (3)&lt;/strong&gt;: the slot is known to contain a dead tuple whose storage has been reclaimed, but whose TID may still be referenced by index entries. Index scans that encounter it skip it immediately without a visibility check. On the heap it is set by page pruning, including the prune in VACUUM&#39;s own first heap pass. The index side carries the same LP_DEAD hint, and ordinary readers set it: when an index scan follows an entry to a heap tuple that turns out to be dead to everyone, the &lt;code&gt;kill_prior_tuple&lt;/code&gt; optimization flags that index entry LP_DEAD so later scans skip it without a heap visit, cleanup work that plain SELECTs do long before VACUUM arrives. For a table with indexes, this is the state a plain deleted tuple sits in between pruning and index cleanup, exactly as we watched above.&lt;/p&gt;
&lt;p&gt;The transitions look like this:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LP_UNUSED (0) ----INSERT----&amp;gt; LP_NORMAL (1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-- table WITH indexes: two heap passes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LP_NORMAL (1) --prune (1st pass)--&amp;gt; LP_DEAD (3)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LP_DEAD (3) --index cleanup + 2nd pass--&amp;gt; LP_UNUSED (0)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-- table WITHOUT indexes: single heap pass&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LP_NORMAL (1) --prune--&amp;gt; LP_UNUSED (0)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-- HOT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LP_NORMAL (1) --HOT prune (chain head)--&amp;gt; LP_REDIRECT (2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LP_NORMAL (1) --HOT prune (heap-only chain member)--&amp;gt; LP_UNUSED (0)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LP_REDIRECT (2) --VACUUM (when target is also dead)--&amp;gt; LP_UNUSED (0)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Whether a deleted tuple passes through LP_DEAD depends on whether the table has indexes. With indexes, pruning can&#39;t free the slot (an index entry still references the TID), so it parks the line pointer at LP_DEAD, and only the second heap pass, after index cleanup, turns it into LP_UNUSED. Without indexes there is nothing to reference the TID, so pruning sets it straight to LP_UNUSED in a single pass. Either way the tuple&#39;s &lt;em&gt;storage&lt;/em&gt; is reclaimed during the prune; LP_DEAD is only ever about the fate of the 4-byte slot.&lt;/p&gt;
&lt;h2 id=&quot;free-space-map&quot;&gt;Free space map&lt;a class=&quot;zola-anchor&quot; href=&quot;#free-space-map&quot; aria-label=&quot;Anchor link for: free-space-map&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Before VACUUM, PostgreSQL&#39;s free space map (FSM) had no idea that our page contained reclaimable space. The dead tuples were invisible to the FSM; they still looked like occupied bytes. After VACUUM, the reclaimed space is registered:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; blkno, avail&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_freespace(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; blkno | avail&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     0 |  3328&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Page 0 now reports 3,328 bytes of available space. This is slightly less than the raw pd_upper - pd_lower value of 3,344 because the FSM tracks free space in coarse categories of roughly 32 bytes and rounds down. But the critical point is that this page is now a candidate for new INSERTs.&lt;/p&gt;
&lt;p&gt;Without this FSM update, PostgreSQL would skip past this page when inserting new rows and extend the table file with a fresh page instead. That&#39;s how tables grow even when they contain plenty of free space internally; the FSM wasn&#39;t updated because VACUUM never ran.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- New inserts reuse the vacuumed space instead of extending the file&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; vacuum_demo (category, payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;cat_new&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;y&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS size&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The table is still a single 8 KB page. The ten new rows landed in the space VACUUM freed on page 0; the FSM pointed the inserter straight at it, so no new page was appended. Delete-then-insert without a VACUUM in between would tell a different story: the FSM would still report the page as full, the inserter would skip it, and the file would grow. That is the mechanism behind a table that keeps expanding on disk while sitting half empty.&lt;/p&gt;
&lt;h2 id=&quot;visibility-map&quot;&gt;Visibility map&lt;a class=&quot;zola-anchor&quot; href=&quot;#visibility-map&quot; aria-label=&quot;Anchor link for: visibility-map&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The visibility map tracks two bits per heap page. Those ten inserts we just did left fresh, not-yet-all-visible tuples on page 0, so the bit is currently clear. One more VACUUM settles the page, and now it&#39;s set:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM vacuum_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; blkno, all_visible, all_frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_visibility(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; blkno | all_visible | all_frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     0 | t           | f&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Page 0 is marked &lt;strong&gt;all_visible&lt;/strong&gt;. This means every tuple currently on the page is visible to all transactions. The implications are significant:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Index-only scans&lt;/strong&gt; can return results from the index without fetching this heap page at all. The visibility map confirms that whatever is on this page, it&#39;s visible, so the index&#39;s copy of the data is guaranteed to be correct.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Future VACUUM passes&lt;/strong&gt; can potentially skip this page during the heap scan phase, since there&#39;s nothing dead to find.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;The visibility map is stored as a separate fork file alongside the main heap file. For a table in &lt;code&gt;base/16384/24601&lt;/code&gt;, the visibility map is &lt;code&gt;base/16384/24601_vm&lt;/code&gt;. It&#39;s tiny: two bits per page means a 1 GB table (131,072 pages) needs only about 32 KB for its visibility map.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;all_frozen&lt;/strong&gt; bit is still false. That flag indicates something stronger: not only are all tuples visible, but all of their xmin values have been frozen: they will survive XID wraparound without any further attention.&lt;/p&gt;
&lt;p&gt;If we now delete a row from this page, the all_visible bit gets cleared:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;/span&gt;&lt;span&gt; vacuum_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; blkno, all_visible, all_frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_visibility(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; blkno | all_visible | all_frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     0 | f           | f&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A single dead tuple on the page is enough to invalidate the all_visible flag. PostgreSQL clears this bit eagerly, at the moment of the DELETE, because it must be conservative. Index-only scans rely on this flag being correct.&lt;/p&gt;
&lt;h2 id=&quot;freezing&quot;&gt;Freezing&lt;a class=&quot;zola-anchor&quot; href=&quot;#freezing&quot; aria-label=&quot;Anchor link for: freezing&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Let&#39;s bring the page to its final resting state. Running &lt;code&gt;VACUUM FREEZE&lt;/code&gt; forces PostgreSQL to freeze every tuple on the table, regardless of age:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM FREEZE vacuum_demo;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now inspect the tuples:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_infomask,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;/span&gt;&lt;span&gt; (t_infomask &amp;amp; &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;256&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;/span&gt;&lt;span&gt; (t_infomask &amp;amp; &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;512&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;FROZEN&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         WHEN&lt;/span&gt;&lt;span&gt; (t_infomask &amp;amp; &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;256&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;XMIN_COMMITTED&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         ELSE&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;no hint bits&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END AS&lt;/span&gt;&lt;span&gt; freeze_status&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; lp_flags &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 8&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_infomask | freeze_status&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+------------+---------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    746 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    748 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |    746 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |    746 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |    748 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |    746 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  8 |    746 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  9 |    748 |       2818 | FROZEN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(8 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;sidenote&quot;&gt;
In older PostgreSQL versions (before 9.4), freezing literally rewrote &lt;code&gt;t_xmin&lt;/code&gt; to 2 (FrozenTransactionId). Modern PostgreSQL preserves the original xmin value and sets the infomask bits instead. This is better for debugging: you can still see which transaction originally created the row.
&lt;/div&gt;
&lt;p&gt;Line pointer 1 is gone: that was &lt;code&gt;id = 1&lt;/code&gt;, which we just deleted, and &lt;code&gt;VACUUM FREEZE&lt;/code&gt; pruned it away before freezing the rest. Line pointers 3, 6, and 9 now carry &lt;code&gt;t_xmin = 748&lt;/code&gt;: those are the &lt;code&gt;cat_new&lt;/code&gt; rows from the free space map section, which reused the very slots the earlier VACUUM had freed. Every surviving tuple is FROZEN. The t_infomask value of 2818 is &lt;code&gt;0x0B02&lt;/code&gt;, which is &lt;code&gt;HEAP_XMIN_COMMITTED&lt;/code&gt; (0x0100) plus &lt;code&gt;HEAP_XMIN_INVALID&lt;/code&gt; (0x0200) plus &lt;code&gt;HEAP_XMAX_INVALID&lt;/code&gt; (0x0800), plus the &lt;code&gt;HEAP_HASVARWIDTH&lt;/code&gt; attribute bit (0x0002) that every row here carries because of its &lt;code&gt;text&lt;/code&gt; columns. As we covered in &lt;a href=&quot;/posts/postgresql-mvcc-byte-by-byte/&quot;&gt;PostgreSQL MVCC, Byte by Byte&lt;/a&gt;, the combination of both XMIN_COMMITTED and XMIN_INVALID is the freeze marker. It means &quot;this tuple&#39;s xmin is permanently in the past, regardless of what the XID counter does.&quot;&lt;/p&gt;
&lt;p&gt;We can also verify the table-level freeze horizon:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; relfrozenxid &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relfrozenxid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;          750&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells PostgreSQL: every tuple in &lt;code&gt;vacuum_demo&lt;/code&gt; has an xmin that&#39;s either frozen or newer than XID 750. Autovacuum uses this value to decide when anti-wraparound freezing is needed. Since we just froze everything, this horizon advanced to the current transaction ID.&lt;/p&gt;
&lt;p&gt;And the visibility map now shows both flags set:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; blkno, all_visible, all_frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_visibility(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vacuum_demo&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; blkno | all_visible | all_frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     0 | t           | t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;all_frozen&lt;/strong&gt; bit is now true. Future VACUUM passes, including anti-wraparound freezing passes, can skip this page entirely. There is nothing to clean and nothing to freeze. For large, mostly-static tables (lookup tables, historical data, archived partitions), this is a substantial performance benefit: VACUUM can skip millions of pages in seconds by checking two bits per page instead of reading every tuple.&lt;/p&gt;
&lt;h2 id=&quot;vacuum-full-vs-regular-vacuum&quot;&gt;VACUUM FULL vs regular VACUUM&lt;a class=&quot;zola-anchor&quot; href=&quot;#vacuum-full-vs-regular-vacuum&quot; aria-label=&quot;Anchor link for: vacuum-full-vs-regular-vacuum&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Everything we&#39;ve seen so far is regular (lazy) VACUUM. There is a persistent myth that it can never shrink a table&#39;s file on disk. That&#39;s not quite right: at the very end of its run, lazy VACUUM tries to truncate trailing pages that have become completely empty, handing that space back to the operating system. What it &lt;em&gt;cannot&lt;/em&gt; do is compact free space stranded in the interior of the file: half-empty pages surrounded by full ones. That distinction is the whole story, and both halves are easy to see.&lt;/p&gt;
&lt;p&gt;Start with a table that fills 18 pages:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; vt&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;, category &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;, payload &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; vt (category, payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;cat_&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; (i % &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;x&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vt&amp;#39;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; full_size;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; full_size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    147456&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;147,456 bytes is 18 pages. Now delete everything past the first fifty rows, which all live on page 0, and vacuum:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;/span&gt;&lt;span&gt; vt &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 50&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM vt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vt&amp;#39;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; after_regular_vacuum;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; after_regular_vacuum&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Down to a single page. Every page after the first became completely empty, so those seventeen pages sat at the tail of the file with nothing live on them, and VACUUM&#39;s truncation phase returned them to the OS. Regular VACUUM &lt;em&gt;did&lt;/em&gt; shrink the file, because the free space happened to be all at the end.&lt;/p&gt;
&lt;p&gt;Now the case it can&#39;t help with. Rebuild the table and delete every &lt;em&gt;other&lt;/em&gt; row, so no page ever fully empties:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP TABLE&lt;/span&gt;&lt;span&gt; vt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; vt&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;, category &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;, payload &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; vt (category, payload)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;cat_&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; (i % &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;x&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;/span&gt;&lt;span&gt; vt &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id % &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM vt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vt&amp;#39;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; after_regular_vacuum;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; after_regular_vacuum&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               147456&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Still 18 pages. Half the rows are gone and VACUUM registered all that free space in the FSM, but every page still holds live tuples, so there are no trailing empty pages to truncate. The file cannot shrink; the free space is stranded in the interior. This is exactly the bloat people mean when they say &quot;I deleted half the table and it&#39;s still the same size on disk.&quot;&lt;/p&gt;
&lt;p&gt;That is what VACUUM FULL is for:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM FULL vt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_relation_size(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;vt&amp;#39;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; after_vacuum_full;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; after_vacuum_full&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;             73728&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;147 KB down to 73 KB, 18 pages to 9. VACUUM FULL rewrote the entire table into a fresh file, packing the 500 surviving rows tightly and deleting the old file. Regular VACUUM had already made that space reusable, but only VACUUM FULL could compact it back out of the file.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
VACUUM FULL requires an &lt;strong&gt;AccessExclusiveLock&lt;/strong&gt;: it blocks ALL reads and writes on the table for the entire duration. For a large table, that can mean minutes or hours of complete downtime for that table. In production, consider &lt;a href=&quot;/posts/the-bloat-busters-pg-repack-pg-squeeze/&quot;&gt;pg_repack or pg_squeeze&lt;/a&gt; as online alternatives that achieve the same compaction without exclusive locks.
&lt;/div&gt;
&lt;p&gt;The tradeoff is clear: regular VACUUM is lightweight (ShareUpdateExclusiveLock, doesn&#39;t block reads or writes) and makes space reusable, truncating the file only when trailing pages are empty. VACUUM FULL compacts interior free space out of the file but takes an AccessExclusiveLock and blocks everything. For most workloads, regular VACUUM is sufficient: new INSERTs reuse the freed space, and the table reaches a steady-state size. VACUUM FULL is a remediation tool for when bloat has gotten out of hand.&lt;/p&gt;
&lt;h2 id=&quot;putting-it-all-together&quot;&gt;Putting it all together&lt;a class=&quot;zola-anchor&quot; href=&quot;#putting-it-all-together&quot; aria-label=&quot;Anchor link for: putting-it-all-together&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Let&#39;s trace the complete lifecycle of a tuple from INSERT to VACUUM, with every state change visible:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;INSERT&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Line pointer allocated as LP_NORMAL (1), pointing to the new tuple&lt;/li&gt;
&lt;li&gt;Tuple stamped with t_xmin = inserting XID, t_xmax = 0&lt;/li&gt;
&lt;li&gt;pd_lower advances by 4 bytes (new line pointer); pd_upper decreases by the tuple size&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;DELETE&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tuple&#39;s t_xmax set to the deleting XID&lt;/li&gt;
&lt;li&gt;Visibility map all_visible bit cleared for the page&lt;/li&gt;
&lt;li&gt;No space reclaimed, no line pointer change, no pd_lower/pd_upper change&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;VACUUM phase 1 (heap scan + prune)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each page pruned: dead tuple data removed, page defragmented&lt;/li&gt;
&lt;li&gt;pd_upper increases; free space map updated&lt;/li&gt;
&lt;li&gt;Dead line pointers set to LP_DEAD (indexed table) or LP_UNUSED (no indexes)&lt;/li&gt;
&lt;li&gt;Dead TIDs collected; tuples opportunistically frozen&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;VACUUM phase 2 (index cleanup)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Index entries pointing to dead TIDs removed from every index&lt;/li&gt;
&lt;li&gt;Heap pages untouched&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;VACUUM phase 3 (heap cleanup, indexed tables only)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LP_DEAD line pointers flipped to LP_UNUSED (0), slots reusable&lt;/li&gt;
&lt;li&gt;No tuple bytes reclaimed here; pruning already did that in phase 1&lt;/li&gt;
&lt;li&gt;Visibility map all_visible bit set (if all remaining tuples are visible)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;VACUUM FREEZE&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tuple infomask bits set to XMIN_COMMITTED + XMIN_INVALID (frozen)&lt;/li&gt;
&lt;li&gt;Visibility map all_frozen bit set&lt;/li&gt;
&lt;li&gt;pg_class.relfrozenxid advanced&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The line pointers move through the state machine we traced above (LP_NORMAL to LP_DEAD to LP_UNUSED on an indexed table, straight to LP_UNUSED without one), but the phase trace is where the &lt;em&gt;why&lt;/em&gt; lives.&lt;/p&gt;
&lt;p&gt;Every INSERT creates a line pointer. Every DELETE stamps t_xmax but changes nothing else on the page. And every VACUUM cycle (prune, index cleanup, heap cleanup) converts dead space back into reusable space. The page never grows beyond 8 KB, and VACUUM&#39;s job is to make sure as much of those 8,192 bytes as possible are available for live data.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>TOAST: Where PostgreSQL hides big values</title>
        <published>2026-05-24T15:10:00+00:00</published>
        <updated>2026-05-24T15:10:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/postgresql-toast/"/>
        <id>https://boringsql.com/posts/postgresql-toast/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/postgresql-toast/">&lt;p&gt;In earlier posts in this series we established that every heap tuple lives inside a strict &lt;a href=&quot;/posts/inside-the-8kb-page/&quot;&gt;8KB page&lt;/a&gt;. Everything else is built on top of that hard limit: &lt;a href=&quot;/posts/postgresql-mvcc-byte-by-byte/&quot;&gt;MVCC&lt;/a&gt;, &lt;a href=&quot;/posts/hot-updates/&quot;&gt;HOT updates&lt;/a&gt;, and indexes that point at &lt;code&gt;(page, line_pointer)&lt;/code&gt;. And yet this still works:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; docs&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int PRIMARY KEY&lt;/span&gt;&lt;span&gt;, body jsonb);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; docs &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; jsonb_agg(g) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;/span&gt;&lt;span&gt;) g));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;body&lt;/code&gt; value is somewhere north of half a megabyte. The heap page is still 8KB. Both statements are true at the same time, and the mechanism that makes them coexist is &lt;strong&gt;TOAST&lt;/strong&gt;: &lt;em&gt;The Oversized-Attribute Storage Technique&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-2kb-threshold&quot;&gt;The 2KB threshold&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-2kb-threshold&quot; aria-label=&quot;Anchor link for: the-2kb-threshold&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The page is 8KB, but PostgreSQL starts shrinking tuples at around 2KB. The goal is to keep at least four tuples per page, so anything bigger triggers TOAST.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Two constants are at play. &lt;code&gt;TOAST_TUPLE_THRESHOLD&lt;/code&gt; decides when the toaster runs; &lt;code&gt;TOAST_TUPLE_TARGET&lt;/code&gt; is the size it tries to shrink the tuple down to. Both default to 2032 bytes on a standard 8KB build. The threshold is fixed at compile time; the target is per-table tunable via the &lt;code&gt;toast_tuple_target&lt;/code&gt; storage parameter.&lt;/div&gt;
&lt;p&gt;When an INSERT or UPDATE produces a tuple wider than the threshold, PostgreSQL doesn&#39;t wait to see whether the row would actually fail to fit on a page. It starts compressing and relocating attributes one at a time, and stops the moment the tuple drops below the target. A row only modestly over the line often gets away with one cheap compression pass; a much wider one walks the full procedure.&lt;/p&gt;
&lt;p&gt;Only variable-length attributes are candidates. A &lt;code&gt;bigint&lt;/code&gt; is always 8 bytes, so there is nothing to compress and nowhere to move it. A &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;bytea&lt;/code&gt;, &lt;code&gt;jsonb&lt;/code&gt;, &lt;code&gt;numeric&lt;/code&gt;, or array, on the other hand, is a &lt;code&gt;varlena&lt;/code&gt;: a length-prefixed structure PostgreSQL can freely rearrange.&lt;/p&gt;
&lt;h2 id=&quot;the-four-storage-strategies&quot;&gt;The four storage strategies&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-four-storage-strategies&quot; aria-label=&quot;Anchor link for: the-four-storage-strategies&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Each variable-length column has a &lt;strong&gt;storage strategy&lt;/strong&gt;: a flag in &lt;code&gt;pg_attribute.attstorage&lt;/code&gt; that answers two yes/no questions for the toaster. Can this value be compressed? Can it be moved out-of-line? PostgreSQL picks a default based on the column&#39;s type, and you can change it with &lt;code&gt;ALTER TABLE ... ALTER COLUMN ... SET STORAGE&lt;/code&gt;. There are four valid values:&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Strategy&lt;/th&gt;&lt;th&gt;Compress?&lt;/th&gt;&lt;th&gt;Out-of-line?&lt;/th&gt;&lt;th&gt;Default for&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PLAIN&lt;/code&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;fixed-width types&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;EXTENDED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;most varlena types&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;EXTERNAL&lt;/code&gt;&lt;/td&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;(opt-in)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MAIN&lt;/code&gt;&lt;/td&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;last resort only&lt;/td&gt;&lt;td&gt;(opt-in)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;EXTENDED&lt;/code&gt; is what you get for &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;bytea&lt;/code&gt;, &lt;code&gt;jsonb&lt;/code&gt;, and friends unless you ask for something else. The other three are levers for specific tradeoffs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;EXTERNAL&lt;/code&gt; skips compression entirely. Useful when the column is already compressed (images, gzipped blobs), or when you plan to use substring/byte-range reads, which work better on uncompressed out-of-line data.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MAIN&lt;/code&gt; tries hard to keep the value in the main heap tuple even after compression. It only moves out-of-line as a last resort.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PLAIN&lt;/code&gt; forbids both. Fixed-width types use it because they have no choice; you can set it on a varlena column too, but then any value that doesn&#39;t fit will fail the INSERT.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can inspect and change strategies through &lt;code&gt;\d+&lt;/code&gt; or directly:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;/span&gt;&lt;span&gt; docs &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;/span&gt;&lt;span&gt; COLUMN body &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; STORAGE &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;EXTERNAL&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;the-shrinking-procedure&quot;&gt;The shrinking procedure&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-shrinking-procedure&quot; aria-label=&quot;Anchor link for: the-shrinking-procedure&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Once the toaster is running, PostgreSQL walks the tuple&#39;s variable-length attributes in a fixed order, applying the cheapest transformations first:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Compress&lt;/strong&gt; &lt;code&gt;EXTENDED&lt;/code&gt; attributes, largest first. If any single attribute is large enough that it should clearly go out-of-line anyway, push it out immediately in the same pass.&lt;/li&gt;
&lt;li&gt;If the tuple still doesn&#39;t fit, &lt;strong&gt;move&lt;/strong&gt; remaining &lt;code&gt;EXTENDED&lt;/code&gt; or &lt;code&gt;EXTERNAL&lt;/code&gt; attributes out-of-line, largest first.&lt;/li&gt;
&lt;li&gt;If still too big, &lt;strong&gt;compress&lt;/strong&gt; &lt;code&gt;MAIN&lt;/code&gt; attributes.&lt;/li&gt;
&lt;li&gt;If &lt;em&gt;still&lt;/em&gt; too big, move &lt;code&gt;MAIN&lt;/code&gt; attributes out-of-line as a last resort.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each pass stops as soon as the tuple drops below the target. A row with one chubby JSON column and a handful of small text fields usually only touches the JSON: a single compression pass typically does it.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The whole procedure lives in &lt;code&gt;heap_toast_insert_or_update()&lt;/code&gt; in &lt;a href=&quot;https://github.com/postgres/postgres/blob/master/src/backend/access/heap/heaptoast.c&quot;&gt;&lt;code&gt;src/backend/access/heap/heaptoast.c&lt;/code&gt;&lt;/a&gt;, with the per-attribute helpers in &lt;code&gt;src/backend/access/common/toast_helper.c&lt;/code&gt;. The four numbered loops in &lt;code&gt;heap_toast_insert_or_update&lt;/code&gt; map one-to-one onto the four steps above. It&#39;s one of the more readable hot paths in the heap code if you want to see the policy in source form.&lt;/div&gt;
&lt;h2 id=&quot;watching-it-happen&quot;&gt;Watching it happen&lt;a class=&quot;zola-anchor&quot; href=&quot;#watching-it-happen&quot; aria-label=&quot;Anchor link for: watching-it-happen&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;To watch this happen we need a table with one wide column and the &lt;code&gt;pageinspect&lt;/code&gt; extension we&#39;ve been using throughout this series.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pageinspect;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; toast_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id    &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    label &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    body  &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Insert three rows of very different sizes:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; toast_demo (label, body)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;tiny&amp;#39;&lt;/span&gt;&lt;span&gt;,         &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;hello&amp;#39;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;medium&amp;#39;&lt;/span&gt;&lt;span&gt;,       &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;compressible-text-&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;200&lt;/span&gt;&lt;span&gt;)),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;large-random&amp;#39;&lt;/span&gt;&lt;span&gt;, (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; string_agg&lt;/span&gt;&lt;span&gt;(md5(g::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;                   FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000&lt;/span&gt;&lt;span&gt;) g));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The middle row is highly compressible: the same 18-byte sequence repeated 200 times. The third is effectively random (MD5 output concatenated), so compression won&#39;t help. Both are well over 2KB before TOAST gets involved.&lt;/p&gt;
&lt;p&gt;Every table with a toastable column gets a companion TOAST table created for it automatically:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; reltoastrelid::regclass &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;toast_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       reltoastrelid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; pg_toast.pg_toast_1367271&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;pg_toast_&amp;lt;oid&amp;gt;&lt;/code&gt; table is where out-of-line chunks live. It has a fixed shape, the same for every TOAST table in the database:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\d &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;pg_toast&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;pg_toast_1367271&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Column   |  Type&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------+---------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; chunk_id   | oid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; chunk_seq  | integer&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; chunk_data | bytea&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Owning table: &amp;quot;public.toast_demo&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Indexes:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    &amp;quot;pg_toast_1367271_index&amp;quot; PRIMARY KEY, btree (chunk_id, chunk_seq)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every out-of-line value is sliced into chunks of up to &lt;code&gt;TOAST_MAX_CHUNK_SIZE&lt;/code&gt; (~2000 bytes), each row identified by &lt;code&gt;(chunk_id, chunk_seq)&lt;/code&gt;. The primary key on those two columns is how PostgreSQL stitches chunks back together in order, on demand.&lt;/p&gt;
&lt;h2 id=&quot;what-the-heap-tuple-looks-like&quot;&gt;What the heap tuple looks like&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-the-heap-tuple-looks-like&quot; aria-label=&quot;Anchor link for: what-the-heap-tuple-looks-like&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Look at the main heap page now:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;toast_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_off | lp_len | t_xmin | t_xmax | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |   8152 |     39 | 414236 |      0 | (0,1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |   8040 |    108 | 414236 |      0 | (0,2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |   7976 |     59 | 414236 |      0 | (0,3)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three rows, three very different lengths on disk. The &lt;code&gt;tiny&lt;/code&gt; row at lp 1 is 39 bytes: tuple header, the label, and &lt;code&gt;&#39;hello&#39;&lt;/code&gt; stored inline. The &lt;code&gt;large-random&lt;/code&gt; row at lp 3 is 59 bytes despite carrying a 32KB &lt;code&gt;body&lt;/code&gt; value, because the data went out-of-line and only an 18-byte TOAST pointer is left behind in the main heap tuple.&lt;/p&gt;
&lt;p&gt;The interesting one is lp 2. 108 bytes is way too big for a TOAST pointer, and way too small for a 3.6KB string. That&#39;s because the &lt;code&gt;medium&lt;/code&gt; value was compressed by &lt;code&gt;pglz&lt;/code&gt; and &lt;strong&gt;stayed inline&lt;/strong&gt;. The repeating 18-byte pattern crushed down to roughly 70 bytes; once the tuple was under the target, step 1 of the procedure was done and step 2 (move out-of-line) never ran. This is exactly the &lt;em&gt;compress-first, relocate-only-if-needed&lt;/em&gt; policy in action: a highly compressible column can stay on the main heap and never produce a single TOAST chunk.&lt;/p&gt;
&lt;p&gt;What sits at lp 3 in place of the value is an 18-byte &lt;strong&gt;TOAST pointer&lt;/strong&gt;: 1-byte varlena header tagged &lt;code&gt;0x80&lt;/code&gt;, 1-byte kind discriminator (&lt;code&gt;VARTAG_ONDISK&lt;/code&gt;), and then four fields, the uncompressed size, the on-disk size (with a compression-method tag baked in on PG14+), the &lt;code&gt;chunk_id&lt;/code&gt; to look up, and the OID of the TOAST table to look in. The remaining bytes of the 59-byte tuple are the tuple header, the &lt;code&gt;&#39;large-random&#39;&lt;/code&gt; label, and the standard alignment padding.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;If you go reading raw page hex, don&#39;t look for the usual 4-byte varlena length word in front of a TOAST pointer. There isn&#39;t one. External TOAST pointers always use the short 1-byte header form (&lt;code&gt;0x80&lt;/code&gt; marks them as &quot;external, no inline length&quot;), and the actual size is implied by the &lt;code&gt;va_tag&lt;/code&gt; byte that follows. There&#39;s no alignment padding inside the pointer either; the 18 bytes are 1 + 1 + 16 contiguous.&lt;/div&gt;
&lt;p&gt;The toast table itself has the real data:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; chunk_id, chunk_seq, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;length&lt;/span&gt;&lt;span&gt;(chunk_data)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; pg_toast&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;pg_toast_1367271&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span&gt; chunk_id, chunk_seq;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; chunk_id | chunk_seq | length&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------+-----------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         0 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         1 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         2 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         3 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         4 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         5 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         6 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         7 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         8 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |         9 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |        10 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |        11 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |        12 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |        13 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |        14 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |        15 |   1996&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1367279 |        16 |     64&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(17 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Only one &lt;code&gt;chunk_id&lt;/code&gt;, because only the &lt;code&gt;large-random&lt;/code&gt; value ended up out-of-line. The &lt;code&gt;medium&lt;/code&gt; value never made it here at all: compression alone was enough, and it stayed in the main heap. &lt;code&gt;large-random&lt;/code&gt; was effectively incompressible, so &lt;code&gt;pglz&lt;/code&gt; sampled the start, gave up early, and the value was stored &lt;strong&gt;uncompressed&lt;/strong&gt; out-of-line. The full 32KB got sliced into 16 chunks of ~2KB plus a 64-byte tail.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
The TOAST table is a regular heap table. It has its own pages, its own MVCC, its own VACUUM accounting, and its own indexes. When you update a TOASTed column, the chunks for the old value remain dead until VACUUM removes them, which is why &lt;code&gt;VACUUM (VERBOSE)&lt;/code&gt; on a wide table reports separate counts for the main relation and its TOAST relation.
&lt;/div&gt;
&lt;div class=&quot;visualizer-banner&quot;&gt;
    &lt;div class=&quot;visualizer-banner__preview&quot; style=&quot;grid-template-columns: 18px 8px repeat(4, 9px); gap: 3px; width: 96px; padding: 0.4rem; align-items: center;&quot;&gt;
        &lt;div style=&quot;height:22px; border-radius:2px; background:rgba(168,85,247,0.35); border:1px solid rgba(168,85,247,0.6);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:2px; background:rgba(168,85,247,0.7);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:18px; border-radius:2px; background:rgba(168,85,247,0.55);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:18px; border-radius:2px; background:rgba(168,85,247,0.55);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:18px; border-radius:2px; background:rgba(168,85,247,0.55);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:12px; border-radius:2px; background:rgba(168,85,247,0.4);&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;visualizer-banner__content&quot;&gt;
        &lt;strong&gt;Interactive TOAST Visualizer&lt;/strong&gt;
        &lt;p&gt;Insert tiny, compressible, incompressible, and huge values into the same column. Switch between &lt;code&gt;EXTENDED&lt;/code&gt;, &lt;code&gt;EXTERNAL&lt;/code&gt;, &lt;code&gt;MAIN&lt;/code&gt;, and &lt;code&gt;PLAIN&lt;/code&gt; and watch each one route the bytes differently. UPDATE a row to leave dead chunks for VACUUM. Click any heap tuple for its full &lt;code&gt;varatt_external&lt;/code&gt; pointer.&lt;/p&gt;
        &lt;a href=&quot;/visualizers/toast/&quot; class=&quot;visualizer-banner__button&quot;&gt;Open Visualizer&lt;/a&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;compression-pglz-vs-lz4&quot;&gt;Compression: pglz vs lz4&lt;a class=&quot;zola-anchor&quot; href=&quot;#compression-pglz-vs-lz4&quot; aria-label=&quot;Anchor link for: compression-pglz-vs-lz4&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;For years, PostgreSQL had exactly one compression algorithm for TOAST: &lt;code&gt;pglz&lt;/code&gt;, a built-in LZ-family compressor. Since PostgreSQL 14, &lt;code&gt;lz4&lt;/code&gt; is also available. It&#39;s usually faster on both compress and decompress, with similar or slightly worse ratios on text-like data. The choice is per-column, with a cluster-wide default:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SHOW default_toast_compression;   &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- &amp;#39;pglz&amp;#39; or &amp;#39;lz4&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;/span&gt;&lt;span&gt; docs &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;/span&gt;&lt;span&gt; COLUMN body &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET COMPRESSION&lt;/span&gt;&lt;span&gt; lz4;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A column&#39;s current setting is visible in &lt;code&gt;pg_attribute.attcompression&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; attname, attstorage, attcompression&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_attribute&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; attrelid &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;toast_demo&amp;#39;&lt;/span&gt;&lt;span&gt;::regclass &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AND&lt;/span&gt;&lt;span&gt; attnum &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; attname | attstorage | attcompression&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------+------------+----------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; id      | p          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; label   | x          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; body    | x          |&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;attstorage&lt;/code&gt; mirrors the four strategies (&lt;code&gt;p&lt;/code&gt;=PLAIN, &lt;code&gt;x&lt;/code&gt;=EXTENDED, &lt;code&gt;e&lt;/code&gt;=EXTERNAL, &lt;code&gt;m&lt;/code&gt;=MAIN). &lt;code&gt;attcompression&lt;/code&gt; is a single char: &lt;code&gt;p&lt;/code&gt; for pglz, &lt;code&gt;l&lt;/code&gt; for lz4, and blank when the column uses the cluster default. Changing the setting only affects values written &lt;em&gt;after&lt;/em&gt; the change. Existing rows keep whatever they were compressed with, because the compression method is recorded inside the TOAST pointer itself.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;If you flip &lt;code&gt;SET COMPRESSION&lt;/code&gt; on a table full of &lt;code&gt;pglz&lt;/code&gt; data, nothing changes on disk until those rows are updated or rewritten by something like &lt;code&gt;VACUUM FULL&lt;/code&gt; or &lt;code&gt;CLUSTER&lt;/code&gt;. Mixed-compression columns are completely normal and supported.&lt;/div&gt;
&lt;p&gt;Because the compression method is recorded inside each TOAST pointer, you can ask any individual row how &lt;em&gt;it&lt;/em&gt; was compressed without trusting the column-level setting:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; id,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_column_compression(body) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; method,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_column_size(body)        &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; on_disk_bytes,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       octet_length(body)          &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; logical_bytes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; toast_demo;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; id | method | on_disk_bytes | logical_bytes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+---------------+---------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |        |             6 |             5&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 | pglz   |            72 |          3600&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |        |         32000 |         32000&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pg_column_compression&lt;/code&gt; returns the compression method (&lt;code&gt;pglz&lt;/code&gt; or &lt;code&gt;lz4&lt;/code&gt;) when the on-disk bytes are actually compressed, and NULL otherwise. &lt;code&gt;pg_column_size&lt;/code&gt; reads the on-disk size out of the TOAST pointer (or just reports the inline size). Both are cheap, neither has to fetch a chunk or decompress anything. &lt;code&gt;octet_length&lt;/code&gt; (or &lt;code&gt;length&lt;/code&gt; on a text value) is the expensive one: it has to detoast and decompress the value to count.&lt;/p&gt;
&lt;p&gt;The three rows show three different outcomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Row 1&lt;/strong&gt;: value is inline, never compressed (tiny string). &lt;code&gt;method&lt;/code&gt; is NULL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row 2&lt;/strong&gt;: value is inline &lt;em&gt;and&lt;/em&gt; compressed. &lt;code&gt;method&lt;/code&gt; is &lt;code&gt;pglz&lt;/code&gt;; &lt;code&gt;on_disk_bytes&lt;/code&gt; is far smaller than &lt;code&gt;logical_bytes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row 3&lt;/strong&gt;: value is out-of-line &lt;em&gt;and&lt;/em&gt; uncompressed. &lt;code&gt;method&lt;/code&gt; is NULL because &lt;code&gt;pglz&lt;/code&gt; sampled the data, saw it wasn&#39;t compressing, and gave up. &lt;code&gt;on_disk_bytes&lt;/code&gt; equals &lt;code&gt;logical_bytes&lt;/code&gt;; the raw bytes live in the TOAST relation untouched.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;NULL in &lt;code&gt;method&lt;/code&gt; means &quot;the on-disk bytes are not compressed&quot; and tells you nothing about whether the value is inline or out-of-line. To distinguish those, compare &lt;code&gt;on_disk_bytes&lt;/code&gt; to &lt;code&gt;logical_bytes&lt;/code&gt;, or use PG17&#39;s &lt;code&gt;pg_column_toast_chunk_id()&lt;/code&gt; which returns NULL for inline values and the actual &lt;code&gt;chunk_id&lt;/code&gt; for out-of-line ones.&lt;/p&gt;
&lt;h2 id=&quot;what-it-costs-you&quot;&gt;What it costs you&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-it-costs-you&quot; aria-label=&quot;Anchor link for: what-it-costs-you&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;TOAST is transparent in the same way virtual memory is: a seamless abstraction until performance characteristics force you to look at the underlying plumbing.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;SELECT body FROM docs WHERE id = 1&lt;/code&gt; for a 1MB out-of-line value does &lt;strong&gt;a lot&lt;/strong&gt; more than read one heap tuple. PostgreSQL fetches the tuple, follows the TOAST pointer, looks up &lt;code&gt;chunk_id&lt;/code&gt; in the TOAST index, reads every chunk in order from the TOAST relation, reassembles them, decompresses the result, and returns it. For a 1MB value with default chunk size, that&#39;s around 500 toast-table tuples plus their index lookups, on top of the main heap read.&lt;/p&gt;
&lt;p&gt;The implications are practical:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SELECT *&lt;/code&gt; is more expensive than it looks.&lt;/strong&gt; Every wide column gets detoasted even if your application throws the value away. Asking for only the columns you need is not just a style preference; on TOAST-heavy tables it&#39;s an I/O decision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pg_column_size(body)&lt;/code&gt; is cheap; &lt;code&gt;length(body)&lt;/code&gt; is not.&lt;/strong&gt; &lt;code&gt;pg_column_size&lt;/code&gt; reads the on-disk size right out of the TOAST pointer without fetching a single chunk. &lt;code&gt;length&lt;/code&gt; and most other functions have to detoast and decompress the whole value first. &lt;code&gt;substring(body, n, m)&lt;/code&gt; is a useful middle case: when the value is stored out-of-line and uncompressed (&lt;code&gt;EXTERNAL&lt;/code&gt;), PostgreSQL can fetch only the chunks the substring actually touches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;WHERE body LIKE &#39;%foo%&#39;&lt;/code&gt; reads everything.&lt;/strong&gt; A sequential scan that filters on a TOASTed column has to detoast every row to evaluate the predicate. This is one of the strongest arguments for an expression index, a GIN trigram index, or just storing a separate searchable summary column.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Updates to a TOASTed value rewrite the chunks.&lt;/strong&gt; PostgreSQL does not patch individual chunks. Update one key inside a 1MB &lt;code&gt;jsonb&lt;/code&gt;, and you get a brand-new value, a brand-new &lt;code&gt;chunk_id&lt;/code&gt;, and a full new set of chunks. The old chunks become dead and wait for VACUUM. This is the operational cost behind the common advice &quot;don&#39;t store frequently-updated big documents in a single column.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;limits&quot;&gt;Limits&lt;a class=&quot;zola-anchor&quot; href=&quot;#limits&quot; aria-label=&quot;Anchor link for: limits&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The varlena length field gives every variable-length value a 1GB ceiling per column per row. That&#39;s the hard limit. Anything larger has to live outside the database, typically in object storage, with the database keeping a reference. There is no setting that raises this limit.&lt;/p&gt;
&lt;p&gt;The toast pointer itself is small (18 bytes) and adds almost nothing to the main tuple. So while a TOASTed row is logically huge, its footprint &lt;em&gt;on the main heap page&lt;/em&gt; is tiny, which is the whole point. A row whose wide column was pushed out-of-line is around 60 bytes on the heap, the same density as any narrow table. The expense only materializes when you actually ask for the wide column.&lt;/p&gt;
&lt;h2 id=&quot;following-a-tuple-through-the-toaster&quot;&gt;Following a tuple through the toaster&lt;a class=&quot;zola-anchor&quot; href=&quot;#following-a-tuple-through-the-toaster&quot; aria-label=&quot;Anchor link for: following-a-tuple-through-the-toaster&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Every wide tuple takes the same path, but exits at a different door. The diagram below traces it end to end: threshold check, compress &lt;code&gt;EXTENDED&lt;/code&gt;, move &lt;code&gt;EXTENDED&lt;/code&gt;/&lt;code&gt;EXTERNAL&lt;/code&gt; out-of-line, compress &lt;code&gt;MAIN&lt;/code&gt;, move &lt;code&gt;MAIN&lt;/code&gt; out-of-line. Each step is followed by a size check that lets the tuple leave early.&lt;/p&gt;
&lt;p&gt;Our demo rows mapped neatly onto this flow. The &lt;code&gt;medium&lt;/code&gt; value compressed under the target on the first step and never left the main heap. The &lt;code&gt;large-random&lt;/code&gt; value failed the first size check, got pushed out-of-line by step two, and only the small TOAST pointer made it back to the heap page.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/images/posts/toast-light.svg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; class=&quot;only-light&quot;&gt;&lt;img src=&quot;/images/posts/toast-light.svg&quot; alt=&quot;TOAST shrinking procedure: compress EXTENDED, then move out-of-line, then compress MAIN, then move MAIN out-of-line, exiting as soon as the tuple drops below the target&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;/images/posts/toast-dark.svg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; class=&quot;only-dark&quot;&gt;&lt;img src=&quot;/images/posts/toast-dark.svg&quot; alt=&quot;TOAST shrinking procedure: compress EXTENDED, then move out-of-line, then compress MAIN, then move MAIN out-of-line, exiting as soon as the tuple drops below the target&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;keeping-the-page-invariant-safe&quot;&gt;Keeping the page invariant safe&lt;a class=&quot;zola-anchor&quot; href=&quot;#keeping-the-page-invariant-safe&quot; aria-label=&quot;Anchor link for: keeping-the-page-invariant-safe&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Whenever a heap tuple would exceed about 2KB, PostgreSQL shrinks it: first by compressing variable-length columns, then by relocating them, largest first, into the table&#39;s companion TOAST relation. The main-heap tuple is left holding a small TOAST pointer in place of the value. The out-of-line bytes live as &lt;code&gt;(chunk_id, chunk_seq, chunk_data)&lt;/code&gt; rows, indexed for ordered reassembly. Reads transparently glue the chunks back together and decompress on the fly. The page-level invariants, so one tuple per page slot, indexes pointing at &lt;code&gt;(page, lp)&lt;/code&gt;, MVCC and HOT operating on heap tuples, they all hold, because as far as the page is concerned, every value is small. TOAST is the trick that makes &quot;every tuple fits in one page&quot; still true in a world of megabyte JSON documents.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>HOT Updates in Postgres</title>
        <published>2026-04-28T22:23:00+02:00</published>
        <updated>2026-04-28T22:23:00+02:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/hot-updates/"/>
        <id>https://boringsql.com/posts/hot-updates/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/hot-updates/">&lt;p&gt;In &lt;a href=&quot;/posts/postgresql-mvcc-byte-by-byte/&quot;&gt;the previous article&lt;/a&gt; we watched every UPDATE leave dead tuple behind. The same copy-on-write behaviour shows up from the operational angle in &lt;a href=&quot;/posts/deletes-are-difficult/&quot;&gt;DELETEs are difficult&lt;/a&gt;. That&#39;s the tradeoff of MVCC and on the heap alone it&#39;s tolerable. The problem is the indexes.&lt;/p&gt;
&lt;p&gt;Every UPDATE in PostgreSQL potentially writes to every index on the table, even when the indexed columns didn&#39;t change. Five indexes, one updated column? Five extra index writes, five new entries to vacuum, five times the WAL traffic. At thousands of updates per second this becomes the dominant cost of running a write-heavy table.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Heap-Only Tuple (HOT) updates&lt;/strong&gt; are PostgreSQL&#39;s escape hatch from this problem. They are, in my opinion, the single cleverest optimization in the storage engine. Let&#39;s trace exactly how they work.&lt;/p&gt;
&lt;h2 id=&quot;cost-of-a-normal-update&quot;&gt;Cost of a normal UPDATE&lt;a class=&quot;zola-anchor&quot; href=&quot;#cost-of-a-normal-update&quot; aria-label=&quot;Anchor link for: cost-of-a-normal-update&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Without HOT, index maintenance scales poorly. Here&#39;s a table with multiple indexes:&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;&lt;code&gt;pageinspect&lt;/code&gt; ships with the contrib modules and is available on most installations. It exposes raw page contents, useful for understanding storage, but never expose it to application users.&lt;/div&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pageinspect;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; hot_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status text NOT NULL DEFAULT&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;active&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    score &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; idx_hot_name&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;/span&gt;&lt;span&gt; hot_demo (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; idx_hot_status&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;/span&gt;&lt;span&gt; hot_demo (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; hot_demo (&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;/span&gt;&lt;span&gt;, score) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;alice&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;active&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;95&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;bob&amp;#39;&lt;/span&gt;&lt;span&gt;,   &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;active&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;82&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;carol&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;active&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;77&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;25&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Updating the indexed &lt;code&gt;name&lt;/code&gt; column requires substantial background work.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; hot_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;BOB&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostgreSQL has to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;t_xmax&lt;/code&gt; on the old tuple to the current transaction ID, marking it dead&lt;/li&gt;
&lt;li&gt;Create a new tuple&lt;/li&gt;
&lt;li&gt;Insert a primary key index entry pointing to the new tuple&#39;s &lt;code&gt;ctid&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Insert a new entry into &lt;code&gt;idx_hot_name&lt;/code&gt; pointing to the new tuple&#39;s &lt;code&gt;ctid&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Insert a new entry into &lt;code&gt;idx_hot_status&lt;/code&gt; pointing to the new tuple&#39;s &lt;code&gt;ctid&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; values remain identical, yet their respective indexes require new entries because the physical tuple location changed. The old index pointers become dead weight, pending removal by VACUUM.&lt;/p&gt;
&lt;h2 id=&quot;bypassing-index-updates&quot;&gt;Bypassing index updates&lt;a class=&quot;zola-anchor&quot; href=&quot;#bypassing-index-updates&quot; aria-label=&quot;Anchor link for: bypassing-index-updates&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Index entries map key values to a physical tuple location (the &lt;code&gt;ctid&lt;/code&gt;). If the new tuple is reachable through the old ctid, the index references remain valid and require no updates.&lt;/p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;A &lt;code&gt;ctid&lt;/code&gt; is a tuple&#39;s physical address: &lt;code&gt;(page_number, line_pointer)&lt;/code&gt;. Indexes store ctids, not row contents.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;PostgreSQL uses a HOT update when two specific conditions are met. The new tuple must fit on the same page as the old tuple. And none of the updated columns are indexed.&lt;/p&gt;
&lt;p&gt;If either condition fails, it&#39;s cold update with the full index dance. If both hold, the update goes HOT.&lt;/p&gt;
&lt;h2 id=&quot;demonstrating-a-hot-update&quot;&gt;Demonstrating a HOT update&lt;a class=&quot;zola-anchor&quot; href=&quot;#demonstrating-a-hot-update&quot; aria-label=&quot;Anchor link for: demonstrating-a-hot-update&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Let&#39;s see it. We update &lt;code&gt;score&lt;/code&gt;, a column that isn&#39;t in any index:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; hot_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; score &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 99&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alice&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;pageinspect&lt;/code&gt; extension shows the HOT flags which are stored in &lt;code&gt;t_infomask2&lt;/code&gt;.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    lp, lp_off, lp_flags, lp_len,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;/span&gt;&lt;span&gt; (t_infomask2 &amp;amp; x&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;4000&amp;#39;&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;HOT_UPDATED&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END AS&lt;/span&gt;&lt;span&gt; hot_old,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;/span&gt;&lt;span&gt; (t_infomask2 &amp;amp; x&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;8000&amp;#39;&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;HEAP_ONLY&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END AS&lt;/span&gt;&lt;span&gt; hot_new&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;hot_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The page also still carries the leftovers from the earlier &lt;code&gt;name = &#39;BOB&#39;&lt;/code&gt; cold update (dead &lt;code&gt;bob&lt;/code&gt; at lp 2, new &lt;code&gt;BOB&lt;/code&gt; at lp 4). Focus on lp 1 and lp 5, the alice update we just ran.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid |   hot_old   |  hot_new&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+----------+--------+--------+--------+--------+-------------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |   8144 |        1 |     46 | 411759 | 411761 | (0,5)  | HOT_UPDATED |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |   8096 |        1 |     46 | 411759 | 411760 | (0,4)  |             |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |   8048 |        1 |     48 | 411759 |      0 | (0,3)  |             |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |   8000 |        1 |     46 | 411760 |      0 | (0,4)  |             |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |   7952 |        1 |     46 | 411761 |      0 | (0,5)  |             | HEAP_ONLY&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(5 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Line pointer 1 marks the old tuple as dead with &lt;code&gt;t_xmax = 411761&lt;/code&gt;. Its &lt;code&gt;t_ctid&lt;/code&gt; points forward to &lt;code&gt;(0,5)&lt;/code&gt;. The &lt;code&gt;HOT_UPDATED&lt;/code&gt; flag confirms the successor is a heap-only tuple.&lt;/p&gt;
&lt;p&gt;Line pointer 5 holds the new tuple. The &lt;code&gt;HEAP_ONLY&lt;/code&gt; flag means no index entries point directly to this tuple. It is only reachable by following the chain from line pointer 1.&lt;/p&gt;
&lt;p&gt;The indexes remain untouched. They still point to &lt;code&gt;(0,1)&lt;/code&gt;. During an index scan, PostgreSQL reads the &lt;code&gt;HOT_UPDATED&lt;/code&gt; flag at line pointer 1, follows &lt;code&gt;t_ctid&lt;/code&gt; to &lt;code&gt;(0,5)&lt;/code&gt;, and returns the current version.&lt;/p&gt;
&lt;h2 id=&quot;the-ctid-chain&quot;&gt;The ctid chain&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-ctid-chain&quot; aria-label=&quot;Anchor link for: the-ctid-chain&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Successive HOT updates form confined chains in a single page.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; hot_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; score &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 98&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alice&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; hot_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; score &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 97&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alice&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid |   hot_old   |  hot_new&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+----------+--------+--------+--------+--------+-------------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |   8144 |        1 |     46 | 411759 | 411761 | (0,5)  | HOT_UPDATED |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |   8096 |        1 |     46 | 411759 | 411760 | (0,4)  |             |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |   8048 |        1 |     48 | 411759 |      0 | (0,3)  |             |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |   8000 |        1 |     46 | 411760 |      0 | (0,4)  |             |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |   7952 |        1 |     46 | 411761 | 411762 | (0,6)  | HOT_UPDATED | HEAP_ONLY&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |   7904 |        1 |     46 | 411762 | 411763 | (0,7)  | HOT_UPDATED | HEAP_ONLY&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |   7856 |        1 |     46 | 411763 |      0 | (0,7)  |             | HEAP_ONLY&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(7 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The chain routes through &lt;code&gt;lp 1 -&amp;gt; lp 5 -&amp;gt; lp 6 -&amp;gt; lp 7&lt;/code&gt;. Intermediate versions carry both &lt;code&gt;HOT_UPDATED&lt;/code&gt; and &lt;code&gt;HEAP_ONLY&lt;/code&gt; flags. They are dead links in the middle of the chain.&lt;/p&gt;
&lt;p&gt;And here&#39;s the part that makes this work: the indexes still have exactly one entry for alice, pointing to &lt;code&gt;(0,1)&lt;/code&gt;. No matter how many times we HOT-update her score, the indexes never grow. PostgreSQL walks the chain from &lt;code&gt;(0,1)&lt;/code&gt; through the dead intermediates until it lands on the live tuple at &lt;code&gt;(0,7)&lt;/code&gt;.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
Long HOT chains have cost. Every index scan must walk the chain from start to finish. If you HOT-update the same row 50 times between vacuums, that&#39;s 50 hops. The chain is confined to a single page so it&#39;s all in-memory work, but it adds up on high-throughput workloads. Page pruning, which we&#39;ll see next, is how PostgreSQL shortens these chains without waiting for VACUUM.
&lt;/div&gt;
&lt;div class=&quot;visualizer-banner&quot;&gt;
    &lt;div class=&quot;visualizer-banner__preview&quot; style=&quot;grid-template-columns: 1fr; gap: 3px; width: 54px; padding: 0.4rem;&quot;&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(220,38,38,0.55);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(220,38,38,0.4); border:1px dashed rgba(220,38,38,0.6);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(220,38,38,0.4); border:1px dashed rgba(220,38,38,0.6);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(22,163,74,0.55);&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;visualizer-banner__content&quot;&gt;
        &lt;strong&gt;Interactive HOT Updates Visualizer&lt;/strong&gt;
        &lt;p&gt;Watch a ctid chain form across multiple HOT updates of the same row, see &lt;code&gt;HOT_UPDATED&lt;/code&gt; and &lt;code&gt;HEAP_ONLY&lt;/code&gt; flags flip on each line pointer, then fire pruning to collapse dead intermediates into &lt;code&gt;LP_REDIRECT&lt;/code&gt; and &lt;code&gt;LP_UNUSED&lt;/code&gt;.&lt;/p&gt;
        &lt;a href=&quot;/visualizers/hot-updates/&quot; class=&quot;visualizer-banner__button&quot;&gt;Open Visualizer&lt;/a&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;page-pruning&quot;&gt;Page pruning&lt;a class=&quot;zola-anchor&quot; href=&quot;#page-pruning&quot; aria-label=&quot;Anchor link for: page-pruning&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;When PostgreSQL accesses a page with dead HOT chain members, it can clean up opportunistically. No VACUUM, no background worker, no special scheduling. Just a regular query that happens to touch the page and decides there&#39;s work worth doing.&lt;/p&gt;
&lt;p&gt;But &quot;can&quot; is doing real work in that sentence. Pruning is gated by two checks, not one. The first is the visibility check: &lt;code&gt;pd_prune_xid &amp;lt; RecentGlobalXmin&lt;/code&gt;, meaning the dead tuples are invisible to every running transaction. The second is a cost check: the page has to be full enough that compacting it is worth the cycles. The threshold is roughly 10% free space. Above that, PostgreSQL leaves the page alone even when there&#39;s pruneable garbage on it.&lt;/p&gt;
&lt;p&gt;Our demo page has seven tiny tuples and most of the page is empty. A plain SELECT against it does nothing. The flags don&#39;t move, &lt;code&gt;t_xmax&lt;/code&gt; stays set on the dead intermediates, the chain stays long. On a busy table where pages actually fill up, pruning fires constantly during regular SELECTs and UPDATEs. On a quiet demo table, you can stare at it all day.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
When pruning &lt;em&gt;does&lt;/em&gt; fire, a plain SELECT can dirty a page and trigger a disk write. That&#39;s true here (pruning) and also for hint bits, covered in the &lt;a href=&quot;/posts/postgresql-mvcc-byte-by-byte/&quot;&gt;MVCC article&lt;/a&gt;. &quot;Read-only query&quot; at the SQL level doesn&#39;t always mean &quot;read-only&quot; at the storage level.
&lt;/div&gt;
&lt;p&gt;To force the issue on our demo table, run VACUUM. VACUUM always prunes, regardless of free space:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;VACUUM hot_demo;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Re-inspecting the page reveals the structural changes.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_ctid | hot_old |  hot_new&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+----------+--------+--------+--------+--------+---------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |      7 |        2 |      0 |        |        |        |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |      0 |        0 |      0 |        |        |        |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |   8144 |        1 |     48 | 411759 |      0 | (0,3)  |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |   8096 |        1 |     46 | 411760 |      0 | (0,4)  |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |      0 |        0 |      0 |        |        |        |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |      0 |        0 |      0 |        |        |        |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |   8048 |        1 |     46 | 411763 |      0 | (0,7)  |         | HEAP_ONLY&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(7 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Line pointer 1 transitions to &lt;code&gt;LP_REDIRECT&lt;/code&gt; (&lt;code&gt;lp_flags = 2&lt;/code&gt;). It abandons its tuple data and points directly to line pointer 7.&lt;/li&gt;
&lt;li&gt;Line pointers 2, 5, and 6 become &lt;code&gt;LP_UNUSED&lt;/code&gt; (&lt;code&gt;lp_flags = 0&lt;/code&gt;). Their tuple space is reclaimed for future inserts. (Line pointer 2 was the old &lt;code&gt;bob&lt;/code&gt; tuple from the cold update earlier, finally cleaned up by VACUUM as well.)&lt;/li&gt;
&lt;li&gt;Line pointer 7 is compacted. The page is defragmented, packing surviving tuples tightly at the end of the page.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;lp-redirect&quot;&gt;LP_REDIRECT&lt;a class=&quot;zola-anchor&quot; href=&quot;#lp-redirect&quot; aria-label=&quot;Anchor link for: lp-redirect&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;div class=&quot;note&quot;&gt;
PostgreSQL&#39;s four line pointer states (&lt;code&gt;LP_UNUSED&lt;/code&gt;, &lt;code&gt;LP_NORMAL&lt;/code&gt;, &lt;code&gt;LP_REDIRECT&lt;/code&gt;, &lt;code&gt;LP_DEAD&lt;/code&gt;) were introduced in &lt;a href=&quot;/posts/inside-the-8kb-page/&quot;&gt;Inside the 8KB Page&lt;/a&gt;. HOT pruning is where &lt;code&gt;LP_REDIRECT&lt;/code&gt; earns its keep.
&lt;/div&gt;
&lt;p&gt;PostgreSQL retains the redirect at line pointer 1 because the indexes still reference &lt;code&gt;(0,1)&lt;/code&gt;. Freeing it would leave dangling pointers. The redirect only occupies the line pointer slot itself (four bytes), with no tuple data attached, and persists until a future VACUUM rewrites the index entries to point at &lt;code&gt;(0,7)&lt;/code&gt; directly. Only then can line pointer 1 finally become &lt;code&gt;LP_UNUSED&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;when-hot-fails&quot;&gt;When HOT fails&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-hot-fails&quot; aria-label=&quot;Anchor link for: when-hot-fails&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;It helps to see the opposite case. Let&#39;s update &lt;code&gt;name&lt;/code&gt;, which &lt;em&gt;is&lt;/em&gt; indexed:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; hot_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;ALICE&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alice&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    lp, lp_flags, t_xmin, t_xmax, t_ctid,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;/span&gt;&lt;span&gt; (t_infomask2 &amp;amp; x&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;4000&amp;#39;&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;HOT_UPDATED&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END AS&lt;/span&gt;&lt;span&gt; hot_old,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;/span&gt;&lt;span&gt; (t_infomask2 &amp;amp; x&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;8000&amp;#39;&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;HEAP_ONLY&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END AS&lt;/span&gt;&lt;span&gt; hot_new&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;hot_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_flags | t_xmin | t_xmax | t_ctid | hot_old |  hot_new&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+----------+--------+--------+--------+---------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |        2 |        |        |        |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |        1 | 411764 |      0 | (0,2)  |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |        1 | 411759 |      0 | (0,3)  |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |        1 | 411760 |      0 | (0,4)  |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 |        0 |        |        |        |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 |        0 |        |        |        |         |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 |        1 | 411763 | 411764 | (0,2)  |         | HEAP_ONLY&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(7 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;HOT_UPDATED&lt;/code&gt; on lp 7, no &lt;code&gt;HEAP_ONLY&lt;/code&gt; on lp 2. This is cold update. The new tuple landed at line pointer 2 (the slot freed by VACUUM a moment ago) and lp 7 was stamped dead with &lt;code&gt;t_ctid&lt;/code&gt; pointing forward. PostgreSQL also had to insert a new entry into &lt;code&gt;idx_hot_name&lt;/code&gt; (the indexed value changed), and because it&#39;s cold, it added new entries into &lt;code&gt;idx_hot_status&lt;/code&gt; and the primary key index even though neither of those values changed.&lt;/p&gt;
&lt;p&gt;The second common failure mode is space. Even if you only touch unindexed columns, completely full page leaves PostgreSQL no choice but to put the new tuple on a different page. And a cross-page update always means index maintenance, because the &lt;code&gt;ctid&lt;/code&gt; changes.&lt;/p&gt;
&lt;h2 id=&quot;fillfactor-making-room-for-hot&quot;&gt;fillfactor: making room for HOT&lt;a class=&quot;zola-anchor&quot; href=&quot;#fillfactor-making-room-for-hot&quot; aria-label=&quot;Anchor link for: fillfactor-making-room-for-hot&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;HOT updates need free space on the actual page. By default, PostgreSQL writes pages with a &lt;code&gt;fillfactor&lt;/code&gt; of 100, packing them full during inserts. The first UPDATE on a full page has to allocate the new tuple elsewhere, and HOT is off the table.&lt;/p&gt;
&lt;p&gt;Lowering the &lt;code&gt;fillfactor&lt;/code&gt; reserves space specifically for updates.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; hot_ff_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    counter integer DEFAULT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;/span&gt;&lt;span&gt; (fillfactor &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 80&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;code&gt;fillfactor&lt;/code&gt; of 80 leaves 20% of the page empty during inserts, giving HOT chains room to grow.&lt;/p&gt;
&lt;div class=&quot;tip&quot;&gt;
The right &lt;code&gt;fillfactor&lt;/code&gt; depends on your workload. Read-heavy or insert-only tables: leave it at 100. Wasting 20% of storage for updates that never happen is pointless. Frequent updates on non-indexed columns: 80-90 is a common choice. Wide rows with heavy churn: 50-70. The trade-off is storage. A lower &lt;code&gt;fillfactor&lt;/code&gt; means more pages, which means more I/O for sequential scans.
&lt;/div&gt;
&lt;p&gt;You can measure HOT success rates through &lt;code&gt;pg_stat_user_tables&lt;/code&gt;.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    relname,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    n_tup_upd,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    n_tup_hot_upd,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    round&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;/span&gt;&lt;span&gt; n_tup_hot_upd &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; NULLIF&lt;/span&gt;&lt;span&gt;(n_tup_upd, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; hot_pct&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_stat_user_tables&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; n_tup_upd &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span&gt; n_tup_upd &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DESC&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A dropping &lt;code&gt;hot_pct&lt;/code&gt; on an update-heavy table is signal worth investigating. The usual suspects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pages are full.&lt;/strong&gt; Lower the fillfactor or make sure VACUUM runs frequently enough to reclaim space before updates need it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Indexed columns are being updated.&lt;/strong&gt; ORMs love to send &lt;code&gt;UPDATE users SET name = &#39;alice&#39;, email = &#39;alice@example.com&#39;, updated_at = now()&lt;/code&gt; when only &lt;code&gt;updated_at&lt;/code&gt; changed. If &lt;code&gt;name&lt;/code&gt; or &lt;code&gt;email&lt;/code&gt; are indexed, that&#39;s a cold update every time. Look for &quot;dirty tracking&quot; features in your ORM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Too many indexes.&lt;/strong&gt; Every index adds a column to the &quot;no indexed columns can change&quot; condition. Review whether all of them are actually used. An unused index doesn&#39;t just waste space, it prevents HOT updates on every column it covers.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;pd-prune-xid-revisited&quot;&gt;pd_prune_xid revisited&lt;a class=&quot;zola-anchor&quot; href=&quot;#pd-prune-xid-revisited&quot; aria-label=&quot;Anchor link for: pd-prune-xid-revisited&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Back to the page header from &lt;a href=&quot;/posts/inside-the-8kb-page/&quot;&gt;Inside the 8KB Page&lt;/a&gt;. After a HOT update, &lt;code&gt;pd_prune_xid&lt;/code&gt; gets stamped with the &lt;code&gt;t_xmax&lt;/code&gt; of the dead tuple:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; hot_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Alice&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE name =&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;ALICE&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; prune_xid &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;hot_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; prune_xid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    411764&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;The &lt;code&gt;name&lt;/code&gt; update here is cold because &lt;code&gt;name&lt;/code&gt; is indexed. Cold updates seed &lt;code&gt;pd_prune_xid&lt;/code&gt; too, since dead tuples need pruning either way. Once set, the field tracks the &lt;em&gt;oldest&lt;/em&gt; unpruned &lt;code&gt;t_xmax&lt;/code&gt; on the page, so newer dead tuples don&#39;t bump it forward.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;That &lt;code&gt;prune_xid&lt;/code&gt; of 411764 is the &lt;code&gt;t_xmax&lt;/code&gt; of the oldest dead tuple still waiting to be pruned (the &lt;code&gt;ALICE&lt;/code&gt; tuple at lp 7, killed by this latest update). The next time any backend touches this page, it compares &lt;code&gt;pd_prune_xid&lt;/code&gt; against &lt;code&gt;RecentGlobalXmin&lt;/code&gt;. If the dead tuples are guaranteed invisible to everyone &lt;em&gt;and&lt;/em&gt; the page is full enough to be worth compacting, pruning fires.&lt;/p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;&lt;code&gt;RecentGlobalXmin&lt;/code&gt; is the oldest snapshot &lt;code&gt;xmin&lt;/code&gt; across every active transaction, replication slot, and prepared transaction on the cluster. It&#39;s the horizon below which &quot;definitely invisible to everyone&quot; is safe to assume.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;This is why pruning is described as &quot;opportunistic&quot; or &quot;lazy&quot;. PostgreSQL doesn&#39;t schedule it, doesn&#39;t run it in the background, and doesn&#39;t bother with pages that have plenty of room. It checks two cheap conditions on every page access and cleans up on the spot when both hold. On a busy table, pages get pruned constantly during regular SELECTs and UPDATEs. On a half-empty page, dead tuples linger until either the page fills up or VACUUM runs.&lt;/p&gt;
&lt;div class=&quot;warning&quot;&gt;
Long-running transactions are the enemy of page pruning. If a transaction started an hour ago and is still open, &lt;code&gt;RecentGlobalXmin&lt;/code&gt; stays pinned to its snapshot. No tuples created after that point can be pruned, no matter how dead they are. Same root cause that blocks VACUUM, and one of the reasons long-running transactions in PostgreSQL are so dangerous.
&lt;/div&gt;
&lt;h2 id=&quot;hot-in-one-paragraph&quot;&gt;HOT in one paragraph&lt;a class=&quot;zola-anchor&quot; href=&quot;#hot-in-one-paragraph&quot; aria-label=&quot;Anchor link for: hot-in-one-paragraph&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;When PostgreSQL gets an UPDATE, it checks two things: is there room on the same page, and are all the changed columns unindexed? If yes to both, the update goes HOT:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The new tuple lands on the same page with the &lt;code&gt;HEAP_ONLY&lt;/code&gt; flag.&lt;/li&gt;
&lt;li&gt;The old version is stamped &lt;code&gt;HOT_UPDATED&lt;/code&gt;, with &lt;code&gt;t_ctid&lt;/code&gt; pointing forward to the new one.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pd_prune_xid&lt;/code&gt; is set, flagging the page as having dead versions to clean up.&lt;/li&gt;
&lt;li&gt;No index entry is created or touched.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Later, when any backend reads the page, it checks two things: are the dead versions invisible to everyone, and is the page full enough to be worth compacting? When both hold, pruning fires: intermediate chain members become &lt;code&gt;LP_UNUSED&lt;/code&gt;, the original line pointer turns into an &lt;code&gt;LP_REDIRECT&lt;/code&gt; that still satisfies every index reference, and the page is defragmented. The reclaimed space is ready for the next HOT update.&lt;/p&gt;
&lt;p&gt;With the right &lt;code&gt;fillfactor&lt;/code&gt;, this loop runs forever, and VACUUM stays largely out of the picture.&lt;/p&gt;
&lt;h2 id=&quot;what-comes-next&quot;&gt;What comes next&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-comes-next&quot; aria-label=&quot;Anchor link for: what-comes-next&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Page pruning is lightweight cleanup that happens during normal reads and writes. It handles dead tuples in HOT chains and reclaims their space on the page. But it has limits. It cannot touch index entries. It can flag dead tuples from cold updates but can&#39;t fully reclaim them, since the index entries still need to come out. It cannot set visibility map bits or update the free space map. And it cannot reach across pages.&lt;/p&gt;
&lt;p&gt;For all of that, you need VACUUM. And as we covered in &lt;a href=&quot;/posts/vacuum-is-lie/&quot;&gt;VACUUM Is a Lie (About Your Indexes)&lt;/a&gt;, even VACUUM has its own blind spots once indexes enter the picture.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>PostgreSQL MVCC, Byte by Byte</title>
        <published>2026-04-17T14:45:00+00:00</published>
        <updated>2026-04-17T14:45:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/postgresql-mvcc-byte-by-byte/"/>
        <id>https://boringsql.com/posts/postgresql-mvcc-byte-by-byte/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/postgresql-mvcc-byte-by-byte/">&lt;p&gt;You run &lt;code&gt;SELECT * FROM orders&lt;/code&gt; in one psql session and see 50 million rows. A colleague in another session runs the same query at the same moment and sees 49,999,999. Neither of you is wrong, and neither is seeing stale data. You are both reading the same 8KB heap pages, the same bytes on disk.&lt;/p&gt;
&lt;p&gt;This is the promise of PostgreSQL&#39;s MVCC (Multi-Version Concurrency Control), and it&#39;s the reason readers never block writers and writers never block readers. It is also one of the most misunderstood pieces of the storage engine. People know &quot;there are multiple versions of a row&quot; and leave it at that.&lt;/p&gt;
&lt;p&gt;The answer lives in eight bytes on every single tuple.&lt;/p&gt;
&lt;h2 id=&quot;xmin-and-xmax-the-only-two-xids-that-matter&quot;&gt;xmin and xmax: the only two XIDs that matter&lt;a class=&quot;zola-anchor&quot; href=&quot;#xmin-and-xmax-the-only-two-xids-that-matter&quot; aria-label=&quot;Anchor link for: xmin-and-xmax-the-only-two-xids-that-matter&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;If you&#39;ve read &lt;a href=&quot;/posts/inside-the-8kb-page/&quot;&gt;Inside the 8KB Page&lt;/a&gt;, you know that every tuple starts with 23-byte header. The first eight bytes of that header are two 32-bit transaction IDs: &lt;code&gt;t_xmin&lt;/code&gt; (the transaction that inserted this version) and &lt;code&gt;t_xmax&lt;/code&gt; (the transaction that deleted or updated it, or 0 if it&#39;s still live).&lt;/p&gt;
&lt;p&gt;That&#39;s the core of MVCC at the storage level. PostgreSQL does not keep a separate &quot;current version&quot; table. It does not mark rows as latest. Every tuple carries its own two-field timestamp, and when your query reads a page, PostgreSQL has to decide, tuple by tuple, whether &lt;em&gt;your&lt;/em&gt; transaction is allowed to see it.&lt;/p&gt;
&lt;p&gt;A minimal demo:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; mvcc_demo&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;, val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;alpha&amp;#39;&lt;/span&gt;&lt;span&gt;), (&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;beta&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Peek at the raw page with &lt;code&gt;pageinspect&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;mvcc_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    100 |      0 | (0,1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    100 |      0 | (0,2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two tuples. Both stamped with &lt;code&gt;t_xmin = 100&lt;/code&gt; (the transaction that ran the INSERT) and &lt;code&gt;t_xmax = 0&lt;/code&gt; (nobody has deleted them). At this moment, every session on the database will see these rows, because everyone&#39;s snapshot agrees that transaction 100 has committed.&lt;/p&gt;
&lt;p&gt;Now open two concurrent sessions. Session A runs an UPDATE without committing:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alpha-new&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- do not commit yet&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Peek at the page again:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, t_xmin, t_xmax, t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;mvcc_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | t_xmin | t_xmax | t_ctid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+--------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |    100 |    101 | (0,3)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |    100 |      0 | (0,2)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |    101 |      0 | (0,3)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(3 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One UPDATE, three tuples. The old version of id=1 is still there at line pointer 1 with &lt;code&gt;t_xmax = 101&lt;/code&gt; stamped on it, and a new version lives at line pointer 3 with &lt;code&gt;t_xmin = 101&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Session A hasn&#39;t committed. Transaction 101 is still in flight. Session B, running &lt;code&gt;SELECT * FROM mvcc_demo&lt;/code&gt; right now, still sees the original &lt;code&gt;alpha&lt;/code&gt;, not &lt;code&gt;alpha-new&lt;/code&gt;. The three tuples live on the page, but Session B&#39;s snapshot says XID 101 is in flight and ignores anything it did. The visibility decision happens on the fly, every time tuple is touched.&lt;/p&gt;
&lt;p&gt;This is the part that makes MVCC counterintuitive: &lt;strong&gt;the bytes on disk don&#39;t change depending on who&#39;s asking.&lt;/strong&gt; What changes is the visibility verdict the planner applies when reading them.&lt;/p&gt;
&lt;h2 id=&quot;snapshots&quot;&gt;Snapshots&lt;a class=&quot;zola-anchor&quot; href=&quot;#snapshots&quot; aria-label=&quot;Anchor link for: snapshots&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;pg_current_snapshot()&lt;/code&gt; is the cleanest way to see what your session is actually holding.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; pg_current_snapshot();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; pg_current_snapshot&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 101:103:101&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s &lt;code&gt;xmin:xmax:xip_list&lt;/code&gt;, and it&#39;s the whole snapshot:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;xmin&lt;/strong&gt;: the lowest XID that might still be in flight. Everything below has already resolved (committed or aborted). You can trust its t_xmin/t_xmax stamps without further checks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;xmax&lt;/strong&gt;: the first not-yet-assigned XID. Anything at or above this value doesn&#39;t exist yet. Tuples stamped with it must be ignored.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;xip_list&lt;/strong&gt;: the XIDs between xmin and xmax that are &lt;em&gt;still&lt;/em&gt; running. These are the &quot;in-flight&quot; transactions whose writes must not be visible to you.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;PostgreSQL applies this test tuple by tuple. If your snapshot considers &lt;code&gt;t_xmin&lt;/code&gt; aborted or still in flight, the tuple doesn&#39;t exist for you and PostgreSQL skips it. If &lt;code&gt;t_xmin&lt;/code&gt; is committed, &lt;code&gt;t_xmax&lt;/code&gt; decides: zero means the tuple is live, a committed &lt;code&gt;t_xmax&lt;/code&gt; means someone has deleted it and you can&#39;t see it, and an in-flight or aborted &lt;code&gt;t_xmax&lt;/code&gt; means the deletion hasn&#39;t reached your snapshot yet.&lt;/p&gt;
&lt;p&gt;Same page. Same bytes. Different sessions have different snapshots, so they land on different outcomes for the same tuple.&lt;/p&gt;
&lt;div class=&quot;visualizer-banner&quot;&gt;
    &lt;div class=&quot;visualizer-banner__preview&quot; style=&quot;grid-template-columns: 1fr; gap: 3px; width: 54px; padding: 0.4rem;&quot;&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(22,163,74,0.45);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(234,88,12,0.45);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(22,163,74,0.45);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:rgba(107,114,128,0.35); border:1px dashed rgba(107,114,128,0.6);&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;visualizer-banner__content&quot;&gt;
        &lt;strong&gt;Interactive MVCC Visualizer&lt;/strong&gt;
        &lt;p&gt;Drive two concurrent sessions against the same heap page. Watch xmin and xmax stamps change, flip between READ COMMITTED and REPEATABLE READ, trace visibility rules tuple by tuple, and run VACUUM when the dead versions pile up.&lt;/p&gt;
        &lt;a href=&quot;/visualizers/mvcc/&quot; class=&quot;visualizer-banner__button&quot;&gt;Open Visualizer&lt;/a&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;read-committed-vs-repeatable-read&quot;&gt;READ COMMITTED vs REPEATABLE READ&lt;a class=&quot;zola-anchor&quot; href=&quot;#read-committed-vs-repeatable-read&quot; aria-label=&quot;Anchor link for: read-committed-vs-repeatable-read&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The difference between PostgreSQL&#39;s two most-used isolation levels boils down to one question: when is the snapshot captured?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;READ COMMITTED&lt;/strong&gt; (the default) captures a &lt;em&gt;fresh&lt;/em&gt; snapshot at the start of every statement. If another session commits between your first and second SELECT, your second SELECT sees the change. The world moves forward under your transaction, statement by statement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;REPEATABLE READ&lt;/strong&gt; captures a single snapshot when the transaction begins and reuses it for every subsequent statement. The world is frozen from your transaction&#39;s perspective. Other sessions can commit thousand changes; your query keeps returning what was visible at BEGIN.&lt;/p&gt;
&lt;p&gt;The bytes on the page are identical in both cases. The only difference is which snapshot your transaction is walking around with.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A, READ COMMITTED (default)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;  &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- &amp;#39;alpha&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B, in another terminal:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alpha-new&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- (auto-commits)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- back in session A:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;  &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- &amp;#39;alpha-new&amp;#39; new statement, new snapshot&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;COMMIT&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now repeat with REPEATABLE READ:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session A, REPEATABLE READ&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN ISOLATION LEVEL REPEATABLE READ&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;  &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- &amp;#39;alpha-new&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- session B:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;/span&gt;&lt;span&gt; val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;alpha-newer&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- (auto-commits)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Back in session A:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; val &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; mvcc_demo &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;;  &lt;/span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- still &amp;#39;alpha-new&amp;#39;  same snapshot as BEGIN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;COMMIT&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The visualizer exposes this directly: there&#39;s an isolation-level selector on each session. Under REPEATABLE READ, the snapshot is captured at BEGIN and persists. Under READ COMMITTED, it refreshes every time you run SELECT. Watch how the visibility badges on each tuple flip accordingly.&lt;/p&gt;
&lt;h2 id=&quot;every-update-leaves-a-dead-tuple-behind&quot;&gt;Every UPDATE leaves a dead tuple behind&lt;a class=&quot;zola-anchor&quot; href=&quot;#every-update-leaves-a-dead-tuple-behind&quot; aria-label=&quot;Anchor link for: every-update-leaves-a-dead-tuple-behind&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Every UPDATE in PostgreSQL creates a new tuple version. The old version doesn&#39;t vanish. It gets stamped with &lt;code&gt;t_xmax&lt;/code&gt; and sits on the page, taking up space, until VACUUM comes through and reclaims it.&lt;/p&gt;
&lt;p&gt;On a busy table with lots of updates, dead tuples can accumulate faster than VACUUM clears them. This is &quot;bloat,&quot; and it&#39;s one of the most common reasons teams think Postgres needs re-tuning. The MVCC contract (&quot;never block, always give a consistent view&quot;) is paid for in disk space.&lt;/p&gt;
&lt;p&gt;You can see the dead tuples accumulate with &lt;code&gt;pgstattuple&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pgstattuple;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- After lots of updates&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; table_len, tuple_count, dead_tuple_count, dead_tuple_percent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pgstattuple(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;mvcc_demo&amp;#39;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; table_len | tuple_count | dead_tuple_count | dead_tuple_percent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------+-------------+------------------+--------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      8192 |           2 |                3 |              42.15&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three dead tuples, two live ones, 42% of the page wasted. That 42% stays wasted until VACUUM runs, or until page-level pruning kicks in during the next query that touches this page and notices the dead space.&lt;/p&gt;
&lt;h2 id=&quot;the-xmin-horizon&quot;&gt;The xmin horizon&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-xmin-horizon&quot; aria-label=&quot;Anchor link for: the-xmin-horizon&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;VACUUM can only reclaim a dead tuple when no running transaction might still need to see it. If Session B started a REPEATABLE READ transaction five minutes ago and has been sitting idle, its snapshot still considers the pre-update version of id=1 to be the live one. VACUUM cannot touch it without breaking that session.&lt;/p&gt;
&lt;p&gt;So VACUUM finds the oldest active transaction on the system and refuses to clean anything newer. A long-running REPEATABLE READ transaction (say, an analytics query that takes an hour) effectively pins every tuple version produced during that hour. The table keeps bloating. Autovacuum runs, finds nothing it&#39;s allowed to clean, and goes home.&lt;/p&gt;
&lt;p&gt;The long-running-transaction problem isn&#39;t an MVCC bug. It&#39;s MVCC working as designed. The price of &quot;readers never block&quot; is that readers can block cleanup. If you&#39;ve ever checked &lt;code&gt;pg_stat_activity&lt;/code&gt; on a misbehaving production database and spotted a 14-hour-old &lt;code&gt;idle in transaction&lt;/code&gt;, you know this shape.&lt;/p&gt;
&lt;p&gt;The visualizer shows this cleanly: start a REPEATABLE READ transaction in Session B, have Session A run a bunch of UPDATEs and COMMITs, then hit VACUUM. The reclaim count won&#39;t include tuple versions Session B can still see.&lt;/p&gt;
&lt;h2 id=&quot;hint-bits-why-select-can-dirty-a-page&quot;&gt;Hint bits: why SELECT can dirty a page&lt;a class=&quot;zola-anchor&quot; href=&quot;#hint-bits-why-select-can-dirty-a-page&quot; aria-label=&quot;Anchor link for: hint-bits-why-select-can-dirty-a-page&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The first SELECT that touches a page after new writes can cause the page to be written back to disk. Not because the SELECT modified any data, but because it set &lt;strong&gt;hint bits&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;When PostgreSQL encounters a tuple with &lt;code&gt;t_xmin = 101&lt;/code&gt; and needs to know whether 101 committed, it doesn&#39;t magically know. It has to look up 101 in &lt;code&gt;pg_xact&lt;/code&gt; (formerly &lt;code&gt;pg_clog&lt;/code&gt;), the commit log. Once it finds the answer, it caches that answer in the tuple&#39;s &lt;code&gt;t_infomask&lt;/code&gt; bits (&lt;code&gt;HEAP_XMIN_COMMITTED&lt;/code&gt; or &lt;code&gt;HEAP_XMIN_INVALID&lt;/code&gt;). Future readers skip the pg_xact lookup entirely.&lt;/p&gt;
&lt;p&gt;Setting those bits is a write. The page becomes dirty. Eventually it gets flushed. Your innocent SELECT ends up triggering I/O.&lt;/p&gt;
&lt;p&gt;This is why &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; on a cold table sometimes shows &lt;code&gt;dirtied&lt;/code&gt; buffers even when the plan contains nothing but reads. It&#39;s also why the &quot;first query after bulk load&quot; pattern has that mysterious slow run: you&#39;re paying the one-time cost of setting hint bits acros thousands of freshly written pages. See &lt;a href=&quot;/posts/explain-buffers/&quot;&gt;Understanding EXPLAIN Buffers&lt;/a&gt; for more on how these counters show up.&lt;/p&gt;
&lt;h2 id=&quot;the-mvcc-contract-in-one-paragraph&quot;&gt;The MVCC contract in one paragraph&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-mvcc-contract-in-one-paragraph&quot; aria-label=&quot;Anchor link for: the-mvcc-contract-in-one-paragraph&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Every tuple carries &lt;code&gt;t_xmin&lt;/code&gt; and &lt;code&gt;t_xmax&lt;/code&gt;. Every transaction carries a snapshot of &lt;code&gt;(xmin, xmax, xip_list)&lt;/code&gt;. Visibility is a two-stage lookup that compares the two. UPDATE and DELETE don&#39;t modify bytes in place. They stamp &lt;code&gt;t_xmax&lt;/code&gt; on the old version and append a new one. VACUUM cleans up dead versions, but only the ones no live transaction could still need. Long-running transactions block VACUUM. Every SELECT can dirty a page the first time it sees new data, because it caches commit status in hint bits.&lt;/p&gt;
&lt;p&gt;Eight bytes of XIDs per tuple, plus a three-number snapshot per transaction, plus one visibility function. That&#39;s the whole mechanism, but the consequences spread through every piece of PostgreSQL operations, from bloat monitoring to replication to autovacuum tuning.&lt;/p&gt;
&lt;p&gt;For the full byte-level tour (hint-bit encodings, the visibility map, freezing, XID wraparound), the storage series covers these in detail. The next stop is &lt;a href=&quot;/posts/hot-updates/&quot;&gt;HOT updates and page pruning&lt;/a&gt; -- how PostgreSQL stops every UPDATE from also touching every index. If you&#39;ve never watched MVCC happen, the visualizer is the fastest way to build the intuition. Run the two sessions against each other, flip the isolation level, then come back to this post.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Inside PostgreSQL&#39;s 8KB Page</title>
        <published>2026-02-19T21:52:00+00:00</published>
        <updated>2026-02-19T21:52:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/inside-the-8kb-page/"/>
        <id>https://boringsql.com/posts/inside-the-8kb-page/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/inside-the-8kb-page/">&lt;p&gt;If you read previous &lt;a href=&quot;/posts/introduction-to-buffers/&quot;&gt;post about buffers&lt;/a&gt;, you already know PostgreSQL might not necessarily care about your rows. You might be inserting a user profile, or retrieving payment details, but all that Postgres works with are blocks of data. 8KB blocks, to be precise. You want to retrieve one tiny row? PostgreSQL hauls an entire 8,192-byte page off the disk just to give it to you. You update a single boolean flag? Same thing. The 8KB page is THE atomic unit of I/O.&lt;/p&gt;
&lt;p&gt;But knowing those pages exist isn&#39;t enough. To understand why the database behaves the way it does, you need to understand how it works. Every time you execute &lt;code&gt;INSERT&lt;/code&gt;, PostgreSQL needs to figure out how to fit it into one of those 8,192-byte pages.&lt;/p&gt;
&lt;p&gt;The buffer pool caches them, Write-Ahead Log (WAL) protects them, and VACUUM cleans them. The deep dive into the PostgreSQL storage internals starts by understanding what happens inside those 8KB pages. Pages that are used by PostgreSQL to organize all data - tables, indexes, sequences, &lt;a href=&quot;/posts/postgresql-toast/&quot;&gt;TOAST relations&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-8kb&quot;&gt;The 8KB&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-8kb&quot; aria-label=&quot;Anchor link for: the-8kb&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;div class=&quot;sidenote&quot;&gt;In case of Oracle, the default block size is set at database creation (&lt;code&gt;DB_BLOCK_SIZE&lt;/code&gt;), though tablespaces with non-standard block sizes can be created separately.&lt;/div&gt;Before looking inside we actually need to discuss why 8KB in first place? The answer might be surprising - it&#39;s a setting that survived for over 40 years and nobody found a good reason to change it. Plus PostgreSQL isn&#39;t the only one who thinks that way. Oracle and SQL Server use the same exact number.
&lt;p&gt;The 8KB page size can be traced down to original Berkley POSTGRES project created in mid-1980s. In those times Unix systems typically used 4KB or 8KB virtual memory pages, and disk sectors were 512 bytes. Choosing 8KB meant a single database page mapped cleanly to OS memory pages and aligned well with filesystem I/O.&lt;/p&gt;
&lt;p&gt;And the math still works today. Modern Linux kernels manage memory in 4KB virtual memory pages. SSDs now use 4KB physical sectors instead of 512 bytes. The default filesystem block size on ext4 and XFS is 4KB. PostgreSQL&#39;s 8KB page still maps to two OS pages, two disk sectors, two filesystem blocks. The hardware changed underneath, but the alignment is still there.&lt;/p&gt;
&lt;p&gt;But the choice isn&#39;t just about hardware alignment. It&#39;s a tradeoff between two opposing forces. Make the page size &lt;strong&gt;too small&lt;/strong&gt; and you going to increase the overhead (page metadata being good example). Make it &lt;strong&gt;too large&lt;/strong&gt; and you waste space and increase I/O requirements when you need a single narrow row.&lt;/p&gt;
&lt;p&gt;The different situation is outside OLTP world. DuckDB, designed for analytical columnar workloads, uses 256KB blocks. When you&#39;re scanning millions of rows sequentially, the overhead-per-page penalty barely matters - you want big chunks to maximize throughput.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
Is PostgreSQL 8KB page size fixed? Technically no. PostgreSQL supports 1, 2, 4, 8, 16, or 32KB pages via &lt;code&gt;--with-blocksize&lt;/code&gt; at compile time. Some analytical workloads with wide rows benefit from 16KB or 32KB pages. But you&#39;ll need to rebuild everything from scratch, and unless you have a very specific reason and understand the downstream implications you &lt;strong&gt;almost certainly don&#39;t want to. The default works.&lt;/strong&gt;
&lt;/div&gt;
&lt;p&gt;You can confirm the page size on any running instance.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SHOW block_size;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; block_size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&quot;pageinspect&quot;&gt;pageinspect&lt;a class=&quot;zola-anchor&quot; href=&quot;#pageinspect&quot; aria-label=&quot;Anchor link for: pageinspect&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;PostgreSQL comes with an extension called &lt;code&gt;pageinspect&lt;/code&gt; that lets you read raw page contents from SQL. It is part of the contrib modules and available on most installations. Let&#39;s enable it and create a small test table:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;/span&gt;&lt;span&gt; pageinspect;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; page_demo&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    title &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMPTZ DEFAULT&lt;/span&gt;&lt;span&gt; CURRENT_TIMESTAMP,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    value numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; page_demo (title, created_at, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;value&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Ergonomic standing desk, oak finish&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2026-01-15 09:23:45+00&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;549&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;99&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Wireless mechanical keyboard&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2026-01-15 10:05:12+00&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;189&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Desk lamp&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2026-01-16 14:31:22+00&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;45&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We have three rows. PostgreSQL has written them into a single heap page. Let&#39;s look inside.&lt;/p&gt;
&lt;h2 id=&quot;reading-a-raw-page-header&quot;&gt;Reading a raw page header&lt;a class=&quot;zola-anchor&quot; href=&quot;#reading-a-raw-page-header&quot; aria-label=&quot;Anchor link for: reading-a-raw-page-header&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;page_header&lt;/code&gt; function takes a raw page and returns the parsed header fields:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-[ RECORD 1 ]---------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;lsn       | F/1F9ED410&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;checksum  | 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;flags     | 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;lower     | 36&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;upper     | 7976&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;special   | 8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;pagesize  | 8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;version   | 4&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;prune_xid | 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every one of those fields lives in the first 24 bytes of the page. Together they form the &lt;strong&gt;page header&lt;/strong&gt;, and they tell PostgreSQL everything it needs to know about the page before touching any actual data.&lt;/p&gt;
&lt;h2 id=&quot;the-page-header-24-bytes-of-metadata&quot;&gt;The page header: 24 bytes of metadata&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-page-header-24-bytes-of-metadata&quot; aria-label=&quot;Anchor link for: the-page-header-24-bytes-of-metadata&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The first two fields are about safety, making sure the page survives crashes and silent corruption.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pd_lsn (8 bytes)&lt;/strong&gt; the Log Sequence Number of the last WAL record that modified this page. During crash recovery, PostgreSQL compares this LSN against the WAL stream. If the WAL record&#39;s LSN is less than or equal to the page&#39;s LSN, the page is already up to date and the record is skipped. If it is greater, the record must be replayed. This single field is what makes crash recovery work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pd_checksum (2 bytes)&lt;/strong&gt; a checksum of the page contents. This is only active if the cluster was initialized with &lt;code&gt;initdb --data-checksums&lt;/code&gt; (or checksums were enabled later with &lt;code&gt;pg_checksums&lt;/code&gt;). When enabled, PostgreSQL verifies the checksum every time it reads a page from disk. A mismatch means silent data corruption, the kind that would otherwise go undetected until your data is wrong in ways nobody can explain.&lt;/p&gt;
&lt;p&gt;Are checksums enabled on your cluster? Before PostgreSQL 17, they were off by default. Check with &lt;code&gt;SHOW data_checksums;&lt;/code&gt;. If you see &lt;code&gt;off&lt;/code&gt; on a production database, that&#39;s worth fixing. You can enable them retroactively with &lt;code&gt;pg_checksums&lt;/code&gt;, though it requires a full shutdown.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;&lt;strong&gt;Magic of &lt;code&gt;PD_ALL_VISIBLE&lt;/code&gt;&lt;/strong&gt;&lt;p&gt;
Indexes know what your data is, but not who is allowed to see it (in case a row was recently deleted or updated). Postgres has to do extra work to double-check the main table (the &quot;heap&quot;) to ensure a row is visible.&lt;/p&gt;
&lt;p&gt;When a page is marked PD_ALL_VISIBLE, it guarantees every row on that page is old enough to be seen by everyone. This lets Postgres skip the expensive heap check entirely and serve data straight from the index. A speed boost you may know as an Index-Only Scan.
&lt;/p&gt;
&lt;/strong&gt;&lt;/div&gt;
&lt;p&gt;The next fields are the page&#39;s spatial ma. They tell PostgreSQL where things are and where there&#39;s room for more.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pd_flags (2 bytes)&lt;/strong&gt; bit flags that describe the page state. The important ones are: &lt;code&gt;PD_HAS_FREE_LINES&lt;/code&gt; (are there any unused line pointers?), &lt;code&gt;PD_PAGE_FULL&lt;/code&gt; (not enough free space for new tuple) and &lt;code&gt;PD_ALL_VISIBLE&lt;/code&gt; (all tuples on page are visible to everyone).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pd_lower&lt;/strong&gt; and &lt;strong&gt;pd_upper&lt;/strong&gt; (2 bytes each) define the free space gap. &lt;code&gt;pd_lower&lt;/code&gt; marks where the line pointer array ends -- in our output it is 36, that is 24 bytes of header plus 3 line pointers at 4 bytes each: 24 + (3 x 4) = 36. &lt;code&gt;pd_upper&lt;/code&gt; marks where tuple data begins -- our value is 7976, meaning the three tuples together occupy bytes 7976 through 8191. Everything between these two offsets is free space. As you insert rows, these two values creep toward each other until there&#39;s no room left.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pd_special (2 bytes)&lt;/strong&gt; the byte offset to the &quot;special space&quot; at the end of the page. For heap (table) pages, this equals the page size (8192), meaning there is no special space. For index pages, this area contains index-specific metadata.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pd_pagesize_version (2 bytes)&lt;/strong&gt; encodes both the page size and the layout version number. The version is currently 4 for all modern PostgreSQL releases.&lt;/p&gt;
&lt;p&gt;Finally, one field dedicated to cleanup.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;&lt;strong&gt;The Mini-Cleanup (Page-Level Pruning)&lt;/strong&gt;
&lt;p&gt;Instead of waiting for a heavy VACUUM, Postgres can do garbage collection on the fly. When a normal query reads a page, it checks `pd_prune_xid`. If that transaction ID is older than all currently running transactions, the query instantly reclaims the dead space itself.&lt;/p&gt;
&lt;p&gt;This is one of the cases where SELECT might modify data pages.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;pd_prune_xid (4 bytes)&lt;/strong&gt; the oldest transaction ID whose dead tuples have not yet been pruned from this page. When a new transaction accesses the page and finds that &lt;code&gt;prune_xid&lt;/code&gt; is older than the global horizon, it triggers page-level pruning. A lightweight cleanup that reclaims space without a full VACUUM.&lt;/p&gt;
&lt;h2 id=&quot;what-is-a-line-pointer&quot;&gt;What is a line pointer?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-is-a-line-pointer&quot; aria-label=&quot;Anchor link for: what-is-a-line-pointer&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;If you&#39;re paying attention, we just mentioned line pointers in &lt;code&gt;pd_lower&lt;/code&gt; description. If the page header discussed above is the general metadata, the &lt;strong&gt;line pointers&lt;/strong&gt; (internally represented as &lt;code&gt;ItemIdData&lt;/code&gt;) are the page table of contents.&lt;/p&gt;
&lt;p&gt;Every time you insert a row, PostgreSQL doesn&#39;t just drop the raw data into the page. It actually splits the job into two steps.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It puts the bulky, unpredictable row data (the tuple) at the very bottom of the page (more on this in the next section).&lt;/li&gt;
&lt;li&gt;It adds a tiny, fixed 4-byte line pointer right after the page header.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This pointer acts as a direct map. It holds exact byte offset and length of the tuple it points to. It allows PostgreSQL to only look at the offset and read the required number of bytes to get the tuple.&lt;/p&gt;
&lt;p&gt;The line pointer array starts immediately after the 24-byte header and grows downward. Let&#39;s look at ours.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lp, lp_off, lp_flags, lp_len&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lp | lp_off | lp_flags | lp_len&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+--------+----------+--------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |   8112 |        1 |     79&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 |   8032 |        1 |     77&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |   7976 |        1 |     53&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each line pointer is 4 bytes and contains three pieces of information:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;lp&lt;/strong&gt; the ordinal position in the array (1-based). This, combined with the page number, forms the &lt;strong&gt;ctid&lt;/strong&gt; - the physical address of a tuple. Row 1 on page 0 has ctid &lt;code&gt;(0,1)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;lp_off&lt;/strong&gt; the byte offset within the page where the actual tuple data begins. Notice the offsets decrease: 8112, 8032, 7976. Tuples are packed from the bottom up.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;sidenote&quot;&gt;
Line pointers are the reason PostgreSQL can move tuples around within a page (during defragmentation) or perform &lt;strong&gt;&lt;a href=&quot;/posts/hot-updates/&quot;&gt;HOT (Heap-Only Tuple) updates&lt;/a&gt;&lt;/strong&gt; without invalidating index entries. An index stores a ctid like &lt;code&gt;(0, 2)&lt;/code&gt;, which means &quot;page 0, line pointer 2&quot;. Because the index points to the pointer rather than the physical byte offset, Postgres can shuffle data or redirect pointers under the hood while the index reference remains perfectly valid.
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;lp_flags&lt;/strong&gt; the state of the pointer. The values are: 0 = &lt;code&gt;LP_UNUSED&lt;/code&gt; (available for reuse), 1 = &lt;code&gt;LP_NORMAL&lt;/code&gt; (points to a live tuple), 2 = &lt;code&gt;LP_REDIRECT&lt;/code&gt; (points to another line pointer, used after HOT pruning), 3 = &lt;code&gt;LP_DEAD&lt;/code&gt; (the tuple has been determined dead but the pointer persists until cleanup).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;lp_len&lt;/strong&gt; the total length of the tuple in bytes, including the 23-byte tuple header, null bitmap, alignment padding, and actual column data.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-anatomy-of-a-page&quot;&gt;The anatomy of a page&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-anatomy-of-a-page&quot; aria-label=&quot;Anchor link for: the-anatomy-of-a-page&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Here is how all these pieces fit together inside the 8KB block.&lt;/p&gt;
&lt;img class=&quot;only-light&quot; src=&quot;/images/posts/page-inspector-light.png&quot; alt=&quot;PostgreSQL page inspector&quot;&gt;
&lt;img class=&quot;only-dark&quot; src=&quot;/images/posts/page-inspector-dark.png&quot; alt=&quot;PostgreSQL page inspector&quot;&gt;
&lt;p&gt;We already mentioned it above, but if you review the image carefully you can confirm the important insight. While line pointers are allocated downward from the header (pd_lower increases) the tuple data is stored upwards from the bottom of the page (pd_upper decreases). Free space is the gap between them.&lt;/p&gt;
&lt;p&gt;This is called &lt;strong&gt;Slotted page layout&lt;/strong&gt; and it&#39;s easy way how to prevent unnecessary data fragmentation. By storing predictable line pointers at top, they don&#39;t mix together with bulky and unpredicable tuples.&lt;/p&gt;
&lt;div class=&quot;visualizer-banner&quot;&gt;
    &lt;div class=&quot;visualizer-banner__preview&quot; style=&quot;grid-template-columns: 1fr; gap: 2px; width: 48px; padding: 0.4rem;&quot;&gt;
        &lt;div style=&quot;height:6px; border-radius:2px; background:var(--viz-page-header);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:8px; border-radius:2px; background:var(--viz-page-lp);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:20px; border-radius:2px; background:var(--color-tertiary); background-image:repeating-linear-gradient(45deg,transparent,transparent 2px,var(--color-border) 2px,var(--color-border) 3px);&quot;&gt;&lt;/div&gt;
        &lt;div style=&quot;height:14px; border-radius:2px; background:var(--viz-page-tuple);&quot;&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;visualizer-banner__content&quot;&gt;
        &lt;strong&gt;Interactive Page Visualizer&lt;/strong&gt;
        &lt;p&gt;Watch the opposing growth directions in action. Insert rows and click regions for byte-level details.&lt;/p&gt;
        &lt;a href=&quot;/visualizers/8kb-page/&quot; class=&quot;visualizer-banner__button&quot;&gt;Open Visualizer&lt;/a&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;free-space-the-gap-in-the-middle&quot;&gt;Free space: the gap in the middle&lt;a class=&quot;zola-anchor&quot; href=&quot;#free-space-the-gap-in-the-middle&quot; aria-label=&quot;Anchor link for: free-space-the-gap-in-the-middle&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The free space on a page is the region between pd_lower and pd_upper. For our page:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lower, upper, upper &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span&gt; lower &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; free_space&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lower | upper | free_space&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    36 |  7976 |       7940&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With only three small rows, we have 7,940 bytes of free space. Almost the entire page is still available. Let&#39;s see what happens when we add more data:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; page_demo (title, created_at, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;value&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;Generated item &amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; i, &lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2026-01-15 00:00:00+00&amp;#39;&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;/span&gt;&lt;span&gt; (i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; hours&amp;#39;&lt;/span&gt;&lt;span&gt;)::interval, &lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 11&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;11&lt;/span&gt;&lt;span&gt;)::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; lower, upper, upper &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span&gt; lower &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; free_space&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; lower | upper | free_space&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------+-------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    76 |  7336 |       7260&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can observe&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pd_lower&lt;/code&gt; moved from 36 to 76. Why? With 10 new line pointers at 4 bytes each we moved by 40 bytes.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pd_upper&lt;/code&gt; dropped from 7,976 to 7,336. The 10 new tuples consumed 640 bytes of the data space.&lt;/li&gt;
&lt;li&gt;And the free space shrank to 7,260 bytes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;how-fast-does-a-page-fill-up&quot;&gt;How fast does a page fill up?&lt;a class=&quot;zola-anchor&quot; href=&quot;#how-fast-does-a-page-fill-up&quot; aria-label=&quot;Anchor link for: how-fast-does-a-page-fill-up&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;If we going to think about the page capacity, let&#39;s consider each row in this page costs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;4 bytes&lt;/strong&gt; for the line pointer&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;23 bytes&lt;/strong&gt; for the tuple header (transaction metadata, null bitmap offset, info mask)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alignment padding&lt;/strong&gt; to the nearest 8-byte boundary after the header (1 byte of padding, bringing the header to 24 bytes)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Actual column data&lt;/strong&gt;: 4 bytes for the integer, variable bytes for the text, variable bytes for the numeric&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For our new schema, each tuple is a bit larger than before. We saw earlier that our first 3 tuples took up exactly 216 bytes, which averages exactly 72 bytes per tuple. Add the 4-byte line pointer, and each row costs about 76 bytes of page space.&lt;/p&gt;
&lt;p&gt;With 8,192 bytes in a page minus the 24-byte header, we have 8,168 bytes of usable space. At roughly 76 bytes per row, we can theoretically fit approximately 107 rows on a single page.&lt;/p&gt;
&lt;p&gt;Let&#39;s test it. We already have 13 rows (3 original plus 10 generated). Let&#39;s insert more and check:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; page_demo (title, created_at, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;value&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;Generated item &amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; i,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2026-01-15 00:00:00+00&amp;#39;&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;/span&gt;&lt;span&gt; (i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; hours&amp;#39;&lt;/span&gt;&lt;span&gt;)::interval,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 11&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;11&lt;/span&gt;&lt;span&gt;)::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;11&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;150&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; tuples_on_page_0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; heap_page_items(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Total of 119 tuples fit on page 0 before PostgreSQL had to start using page 1. A bit more than our estimate of 107 -- the shorter generated titles take less space than &quot;Ergonomic standing desk, oak finish&quot;.&lt;/p&gt;
&lt;p&gt;The exact number always depends on column values, but this gives you a practical sense of capacity. Because this schema includes a variable-length text column, the row size flexes, but we still hit a very predictable ceiling right around 110-120 rows per 8KB block.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
If you want to estimate how many pages a table will need, the rough formula is: &lt;code&gt;rows * average_row_size / 8192&lt;/code&gt;. But remember that &quot;average_row_size&quot; includes the 23-byte tuple header, alignment padding, and the 4-byte line pointer.
&lt;p&gt;PostgreSQL&#39;s &lt;code&gt;pg_column_size()&lt;/code&gt; function can help you measure actual row sizes.&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id=&quot;spanning-multiple-pages&quot;&gt;Spanning multiple pages&lt;a class=&quot;zola-anchor&quot; href=&quot;#spanning-multiple-pages&quot; aria-label=&quot;Anchor link for: spanning-multiple-pages&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;With 160 rows in our table, we have started storing the tuples into a second page. Let&#39;s verify using the system catalog:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; relpages, reltuples&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;sidenote&quot;&gt;The &lt;code&gt;relpages&lt;/code&gt; and &lt;code&gt;reltuples&lt;/code&gt; values are estimates updated by ANALYZE and autovacuum. After bulk inserts, run &lt;code&gt;ANALYZE page_demo;&lt;/code&gt; to refresh them.&lt;/div&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;relpages | reltuples&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        2 |       160&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two pages. Let&#39;s insert substantially more data to see a real multi-page table:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; page_demo (title, created_at, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;value&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;Generated row &amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;/span&gt;&lt;span&gt; i, &lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    CURRENT_TIMESTAMP &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;+&lt;/span&gt;&lt;span&gt; (i &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; minutes&amp;#39;&lt;/span&gt;&lt;span&gt;)::interval,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1000&lt;/span&gt;&lt;span&gt;)::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;500&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE page_demo;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; relpages, reltuples&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_class&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relname &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; relpages | reltuples&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        6 |       660&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Six pages now. Each page is an independent 8KB block with its own header. Let&#39;s confirm by reading the headers of the first two:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS page&lt;/span&gt;&lt;span&gt;, lower, upper, upper &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span&gt; lower &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;/span&gt;&lt;span&gt; free_space&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, lower, upper, upper &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span&gt; lower&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; page | lower | upper | free_space&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------+-------+-------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    0 |   500 |   552 |         52&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    1 |   504 |   512 |          8&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both pages are nearly full, with only 52/8 bytes of free space remaining. Which is too little for another row to fit there.&lt;/p&gt;
&lt;h2 id=&quot;the-space-that-isn-t-there&quot;&gt;The space that isn&#39;t there&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-space-that-isn-t-there&quot; aria-label=&quot;Anchor link for: the-space-that-isn-t-there&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;You may have noticed that &lt;code&gt;pd_special&lt;/code&gt; equals 8192 for our heap pages. The same size as the entire page size. I.e. there&#39;s no special space allocated.&lt;/p&gt;
&lt;p&gt;This applies to the heap pages. But not all pages are heap pages. Index pages use the special space at the end of the page to store index-specific metadata. For a B-tree index, it includes pointers to sibling pages (for range scans), the tree level, and flags about the page type (leaf, internal, root, deleted).&lt;/p&gt;
&lt;p&gt;Let&#39;s peek at the primary key index that was automatically created for our table:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT type&lt;/span&gt;&lt;span&gt;, live_items, dead_items, avg_item_size,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       page_size, free_size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; bt_page_stats(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo_pkey&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; type | live_items | dead_items | avg_item_size | page_size | free_size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------+------------+------------+---------------+-----------+-----------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; l    |        367 |          0 |            16 |      8192 |       808&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; is &lt;code&gt;l&lt;/code&gt; for leaf page. And if we check its page header:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; special &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo_pkey&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; special&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    8176&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The special space starts at byte 8176, giving us 16 bytes (8192 - 8176) of B-tree metadata at the end of the page. Heap pages have none; index pages rely on it.&lt;/p&gt;
&lt;h2 id=&quot;putting-it-all-together&quot;&gt;Putting it all together&lt;a class=&quot;zola-anchor&quot; href=&quot;#putting-it-all-together&quot; aria-label=&quot;Anchor link for: putting-it-all-together&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Let&#39;s close with a complete view of our page. We know the header, the line pointers, the free space, and the tuple data regions. Here is a summary query that shows all of it at once:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;header&amp;#39;&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;       AS&lt;/span&gt;&lt;span&gt; region,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    0&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;              AS&lt;/span&gt;&lt;span&gt; start_byte,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    23&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;             AS&lt;/span&gt;&lt;span&gt; end_byte,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    24&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;             AS&lt;/span&gt;&lt;span&gt; size_bytes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;line pointers&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    24&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    lower &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    lower &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 24&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;free space&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    lower,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    upper &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    upper &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span&gt; lower&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;tuple data&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    upper,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    special &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    special &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;/span&gt;&lt;span&gt; upper&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;special space&amp;#39;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    special,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    8191&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    8192&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;/span&gt;&lt;span&gt; special&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; page_header(get_raw_page(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;page_demo&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    region     | start_byte | end_byte | size_bytes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------+------------+----------+------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; header        |          0 |       23 |         24&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; line pointers |         24 |      499 |        476&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; free space    |        500 |      551 |         52&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; tuple data    |        552 |     8191 |       7640&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; special space |       8192 |     8191 |          0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(5 rows)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;476 bytes of line pointers means 119 entries (476 / 4). 7,640 bytes of tuple data. 52 bytes of free space. And zero bytes of special space (it&#39;s heap page).&lt;/p&gt;
&lt;p&gt;That is the entire PostgreSQL 8KB page. Twenty-four bytes of header tell PostgreSQL where everything is. Line pointers provide table of contents.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Introduction to Buffers in PostgreSQL</title>
        <published>2026-01-24T16:57:00+00:00</published>
        <updated>2026-09-12T00:00:00+00:00</updated>
        
        <author>
          <name>Radim Marek</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/introduction-to-buffers/"/>
        <id>https://boringsql.com/posts/introduction-to-buffers/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/introduction-to-buffers/">&lt;p&gt;The work around &lt;a href=&quot;/products/regresql/&quot;&gt;RegreSQL&lt;/a&gt; led me to focus a lot on &lt;strong&gt;buffers&lt;/strong&gt;. If you are a casual PostgreSQL user, you have probably heard about adjusting &lt;code&gt;shared_buffers&lt;/code&gt; and followed the good old advice to set it to 1/4 of available RAM. But after we went a little bit too enthusiastic about them on a recent &lt;a rel=&quot;external&quot; href=&quot;https://postgres.fm/episodes/regresql&quot;&gt;Postgres FM episode&lt;/a&gt; I&#39;ve been asked what that&#39;s all about.&lt;/p&gt;
&lt;p&gt;Buffers are one of those topics that easily gets forgotten. And while they are a foundation block of PostgreSQL&#39;s performance architecture, most of us treat them as a black box.&lt;/p&gt;
&lt;h2 id=&quot;the-8kb-page&quot;&gt;The 8KB page&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-8kb-page&quot; aria-label=&quot;Anchor link for: the-8kb-page&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;PostgreSQL stores and retrieves data in 8KB blocks. When reading or writing a single row, the engine operates on the entire 8KB page that contains it. You retrieve or write one row, and the surrounding page comes along with it.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- you can check the block size (which should be almost always 8192 bytes)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;show block_size;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; block_size&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 8192&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every table and index is a collection of these pages. A row might span multiple pages if it&#39;s large enough, but the page remains the atomic unit of I/O.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Want to look inside?&lt;/strong&gt;&lt;br&gt;
If you want to see exactly how PostgreSQL packs your data, headers, and line pointers into these 8,192 bytes, check out the byte-level tour in &lt;a href=&quot;/posts/inside-the-8kb-page/&quot;&gt;Inside PostgreSQL&#39;s 8KB Page&lt;/a&gt;.
&lt;/div&gt;
&lt;h2 id=&quot;postgresql-vs-os&quot;&gt;PostgreSQL vs OS&lt;a class=&quot;zola-anchor&quot; href=&quot;#postgresql-vs-os&quot; aria-label=&quot;Anchor link for: postgresql-vs-os&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Why maintain a dedicated buffer cache when the operating system already caches disk pages?&lt;/p&gt;
&lt;p&gt;PostgreSQL understands the semantics of the data it touches. The operating system only sees files, offsets, and bytes; PostgreSQL sees tables, indexes, and execution plans. When a query runs a large sequential scan, the OS happily caches every page it encounters, but PostgreSQL knows the scan is a transient bulk read and diverts it into a small ring buffer to protect the main pool from eviction.&lt;/p&gt;
&lt;p&gt;The second requirement is ACID durability: PostgreSQL must guarantee that write-ahead log (WAL) records reach disk before a modified data page is written. The kernel page cache does not enforce that ordering.&lt;/p&gt;
&lt;h2 id=&quot;shared-buffers&quot;&gt;shared_buffers&lt;a class=&quot;zola-anchor&quot; href=&quot;#shared-buffers&quot; aria-label=&quot;Anchor link for: shared-buffers&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;shared_buffers&lt;/code&gt; parameter controls the size of the shared memory pool accessible to all backends. When a backend needs a page, it first checks shared buffers. If the page is present, it is a cache hit and requires no disk I/O. On a miss, PostgreSQL loads the page from disk (or the OS cache) into shared buffers for subsequent queries.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;WAL has its own buffer area (&lt;strong&gt;wal_buffers&lt;/strong&gt;). This separation exists because WAL writes are sequential and must be persisted before the corresponding data change can be considered committed. The default is 3% of shared_buffers, capped at 16MB (one WAL segment).&lt;/div&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;show shared_buffers;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; shared_buffers&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;----------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 128MB&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default value 128MB is very conservative and is part of making sure the PostgreSQL default installation will work pretty much on any system (including those with limited RAM). But in your regular environment this value should typically be much higher.&lt;/p&gt;
&lt;p&gt;The value of 128MB there refers to the actual buffered content. If you consider a single page being 8KB, you can imagine this as 16,384 individual slots for storing the data.&lt;/p&gt;
&lt;div class=&quot;visualizer-banner&quot;&gt;
  &lt;div class=&quot;visualizer-banner__preview&quot;&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer pinned&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer empty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer pinned&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer empty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer pinned&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer pinned&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer empty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer pinned&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer empty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer pinned&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer dirty&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;mini-buffer clean&quot;&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;visualizer-banner__content&quot;&gt;
    &lt;strong&gt;Interactive Visualizer&lt;/strong&gt;
    &lt;p&gt;Explore how PostgreSQL&#39;s buffer pool actually works. Watch the clock sweep algorithm evict cold pages, see buffers transition between clean, dirty, and pinned states, and understand how the hash table enables O(1) lookups.&lt;/p&gt;
    &lt;a href=&quot;/visualizers/shared-buffers/&quot; class=&quot;visualizer-banner__button&quot;&gt;Open Visualizer&lt;/a&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Beyond the data pages, PostgreSQL tracks metadata and maintains lookup indices in shared memory across three components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;buffer blocks&lt;/strong&gt;: the actual 8KB pages where data lives.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;buffer descriptors&lt;/strong&gt;: an array of ~64-byte structures, one per buffer slot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;buffer mapping hash table&lt;/strong&gt;: maps page identifiers to buffer slot indices.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each descriptor tracks which page occupies the slot (the buffer tag), state flags (dirty, valid, I/O in progress), and two counters: pin count and usage count.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;O(1) constant time, regardless of buffer pool size.&lt;/div&gt;When a backend requests page N of a table, it hashes the page tag and jumps directly to the hash bucket, avoiding a scan across every slot in the pool.
&lt;h2 id=&quot;pin-and-usage-counts&quot;&gt;Pin and usage counts&lt;a class=&quot;zola-anchor&quot; href=&quot;#pin-and-usage-counts&quot; aria-label=&quot;Anchor link for: pin-and-usage-counts&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;What happens to each buffer slot is ruled by two counters: pin count and usage count.&lt;/p&gt;
&lt;p&gt;Pin count tracks active references. When a backend is actively reading or modifying a page, it pins the buffer so it cannot be evicted. When the backend finishes, it unpins the buffer.&lt;/p&gt;
&lt;p&gt;Usage count tracks how recently and frequently a buffer was accessed. Each access increments the count (capped at 5). During eviction, the clock sweep decrements this value. Buffers with higher counts survive longer, while those at 0 get evicted.&lt;/p&gt;
&lt;p&gt;The usage counter prevents a single large read from wiping out frequently accessed pages. A sequential scan over a cold table touches each page once, leaving its usage count at 1, while actively queried pages accumulate counts up to 5 and survive longer under eviction pressure.&lt;/p&gt;
&lt;h2 id=&quot;the-clock-sweep-algorithm&quot;&gt;The clock sweep algorithm&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-clock-sweep-algorithm&quot; aria-label=&quot;Anchor link for: the-clock-sweep-algorithm&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;When PostgreSQL needs to load a page and every buffer slot is occupied, it runs the clock sweep to find a candidate for eviction.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;A traditional LRU linked list would require locks on every cache hit, creating severe lock contention under concurrent read workloads.&lt;/div&gt;The clock sweep avoids that bottleneck by sweeping circularly through the array of buffer descriptors:
&lt;ol&gt;
&lt;li&gt;If the buffer is pinned, skip it.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;usagecount &amp;gt; 0&lt;/code&gt;, decrement it by 1 and advance to the next descriptor.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;usagecount == 0&lt;/code&gt; and the buffer is unpinned, choose this buffer for eviction.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Cold pages with a usage count of 1 are reclaimed on the first or second pass. Frequently queried pages with higher counts survive multiple sweeps.&lt;/p&gt;
&lt;h2 id=&quot;dirty-buffers-and-the-background-writer&quot;&gt;Dirty buffers and the background writer&lt;a class=&quot;zola-anchor&quot; href=&quot;#dirty-buffers-and-the-background-writer&quot; aria-label=&quot;Anchor link for: dirty-buffers-and-the-background-writer&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;div class=&quot;sidenote&quot;&gt;Every modification is logged to WAL before the buffer in shared memory is dirtied.&lt;/div&gt;When a backend modifies a page, the buffer becomes dirty. Writing every modification to disk immediately would be slow, especially when the same page is updated repeatedly in rapid succession.
&lt;p&gt;Instead, dirty buffers accumulate in shared memory and are flushed through three paths:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Checkpoints&lt;/strong&gt;: a periodic background process writes all dirty buffers to disk and fsyncs them, creating a point where data files are consistent with WAL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The background writer&lt;/strong&gt;: continuously scans for dirty buffers and writes them out ahead of eviction. When a backend needs a buffer slot, it finds already-clean buffers and avoids waiting on write I/O.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backend allocation&lt;/strong&gt;: if the clock sweep selects a dirty buffer that neither checkpoint nor background writer has flushed, the backend must write the page out synchronously before reusing the slot.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The goal is keeping enough clean buffers available so backends never stall on synchronous writes during eviction.&lt;/p&gt;
&lt;h2 id=&quot;watching-the-buffer-pool-work&quot;&gt;Watching the buffer pool work&lt;a class=&quot;zola-anchor&quot; href=&quot;#watching-the-buffer-pool-work&quot; aria-label=&quot;Anchor link for: watching-the-buffer-pool-work&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;pg_buffercache&lt;/code&gt; extension exposes one row per buffer slot, including page identity, usage count, and dirty status. On PostgreSQL 17 with the default 128MB &lt;code&gt;shared_buffers&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;/span&gt;&lt;span&gt; EXTENSION pg_buffercache;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 500 rows, small enough that every page fits in the buffer pool&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; demo_orders&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;, payload &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WITH&lt;/span&gt;&lt;span&gt; (autovacuum_enabled &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= off&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; demo_orders&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; i, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;x&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;500&lt;/span&gt;&lt;span&gt;) i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- the state of the table&amp;#39;s pages inside shared buffers&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; usagecount, isdirty, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_buffercache&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relfilenode &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;demo_orders&amp;#39;&lt;/span&gt;&lt;span&gt;::regclass::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;/span&gt;&lt;span&gt; relforknumber &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; usagecount | isdirty | count&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;------------+---------+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;          5&lt;/span&gt;&lt;span&gt; | t       |     &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;9&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All 9 heap pages are resident and marked dirty. Pinning during the insert drove each page&#39;s usage count to the maximum of 5. The &lt;code&gt;relforknumber = 0&lt;/code&gt; filter restricts the query to the heap fork; omitting it also pulls in the table&#39;s free space map.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CHECKPOINT&lt;/code&gt; flushes the dirty pages to disk while leaving them cached:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CHECKPOINT&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; usagecount, isdirty, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_buffercache&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relfilenode &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;demo_orders&amp;#39;&lt;/span&gt;&lt;span&gt;::regclass::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;/span&gt;&lt;span&gt; relforknumber &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; usagecount | isdirty | count&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;------------+---------+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;          5&lt;/span&gt;&lt;span&gt; | f       |     &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;9&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dirty flag flips to false. A read-only query marks them dirty again:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; demo_orders;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; usagecount, isdirty, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_buffercache&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relfilenode &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;demo_orders&amp;#39;&lt;/span&gt;&lt;span&gt;::regclass::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;/span&gt;&lt;span&gt; relforknumber &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; usagecount | isdirty | count&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;------------+---------+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;          5&lt;/span&gt;&lt;span&gt; | t       |     &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;9&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The scan set commit-status hint bits on the new tuples. Writing hint bits modifies the page header in shared memory, which is why &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; can report &lt;code&gt;dirtied&lt;/code&gt; buffers on pure &lt;code&gt;SELECT&lt;/code&gt; queries.&lt;/p&gt;
&lt;p&gt;Loading a table significantly larger than &lt;code&gt;shared_buffers&lt;/code&gt; forces the clock sweep through multiple passes, decrementing usage counts to zero and reclaiming the buffers:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;/span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; filler&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;/span&gt;&lt;span&gt;, payload &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WITH&lt;/span&gt;&lt;span&gt; (autovacuum_enabled &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= off&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; filler&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; i, &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;z&amp;#39;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;200&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3600000&lt;/span&gt;&lt;span&gt;) i;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 827MB insert through a 128MB pool&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_buffercache&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relfilenode &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;demo_orders&amp;#39;&lt;/span&gt;&lt;span&gt;::regclass::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;/span&gt;&lt;span&gt; relforknumber &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; count&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;     0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- reading the table loads pages back from disk, usagecount starts at 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; demo_orders;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span&gt; usagecount, isdirty, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; pg_buffercache&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;/span&gt;&lt;span&gt; relfilenode &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;demo_orders&amp;#39;&lt;/span&gt;&lt;span&gt;::regclass::&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;/span&gt;&lt;span&gt; relforknumber &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; usagecount | isdirty | count&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;------------+---------+-------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;          1&lt;/span&gt;&lt;span&gt; | f       |     &lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;9&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;demo_orders&lt;/code&gt; was evicted once the clock sweep decremented its usage count to zero. Reading the table again loads the pages with &lt;code&gt;usagecount = 1&lt;/code&gt;. Because the eviction flush had already written the hint bits to disk, this read leaves the buffers clean.&lt;/p&gt;
&lt;h2 id=&quot;ring-buffers&quot;&gt;Ring buffers&lt;a class=&quot;zola-anchor&quot; href=&quot;#ring-buffers&quot; aria-label=&quot;Anchor link for: ring-buffers&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;As mentioned above there are also special types of buffers. We already touched the scenario when a query scans a large table.&lt;/p&gt;
&lt;p&gt;In a naive implementation, the sequential scan would load all data into shared buffers, evicting everything else along the way. Your warmed cache would vanish and subsequent queries would suffer for minutes to come.&lt;/p&gt;
&lt;p&gt;PostgreSQL&#39;s solution for this case is &lt;strong&gt;ring buffers&lt;/strong&gt;. Small, private buffer pools for bulk operations. Instead of using the shared buffer pool, certain operations get their own limited ring.&lt;/p&gt;
&lt;p&gt;The individual cases are:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sequential scans on large tables&lt;/strong&gt; over 1/4 of &lt;code&gt;shared_buffers&lt;/code&gt; use a dedicated 256 KB ring buffer. Pages cycle through this tiny ring and never touch the main cache.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- perform a sequential scan over a large table&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN (ANALYZE, BUFFERS) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; ring_buffer_test;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which would show you &lt;code&gt;Buffers: shared read=127285&lt;/code&gt; instead of &lt;strong&gt;hit&lt;/strong&gt;. The follow-up article &lt;a href=&quot;/posts/explain-buffers/&quot;&gt;Reading Buffer Statistics in EXPLAIN Output&lt;/a&gt; goes through those counters line by line.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bulk writes&lt;/strong&gt; (COPY, CREATE TABLE AS) use a 16MB capped ring buffer, large enough for efficient batching, yet small enough not to pollute the shared buffer pool.&lt;/p&gt;
&lt;p&gt;As &lt;strong&gt;VACUUM&lt;/strong&gt; touches every page and shouldn&#39;t evict the hot data, it uses its own dedicated ring buffer. Historically it was set to 256KB, but since PostgreSQL 16 it can be configured using &lt;code&gt;vacuum_buffer_usage_limit&lt;/code&gt; (the previous two sizes are fixed).&lt;/p&gt;
&lt;h2 id=&quot;local-buffers&quot;&gt;Local buffers&lt;a class=&quot;zola-anchor&quot; href=&quot;#local-buffers&quot; aria-label=&quot;Anchor link for: local-buffers&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The second kind of exception to shared buffer pool, is the session based &lt;strong&gt;temporary tables&lt;/strong&gt;. In this case as the concurrency is out of question, each backend has its own local buffer pool, controlled by &lt;code&gt;temp_buffers&lt;/code&gt; (8MB default).&lt;/p&gt;
&lt;p&gt;Local buffers are faster than shared buffers because they have simpler locking. There is no need for the heavy cross-process coordination required in the main cache.&lt;/p&gt;
&lt;p&gt;While this might seem like a minor implementation detail, it can translate into a powerful optimisation strategy. Many developers tend to default to complex &lt;a href=&quot;/posts/good-cte-bad-cte/&quot;&gt;CTE logic&lt;/a&gt; for intermediate data, but using temporary tables offers a distinct advantage in the form of &lt;strong&gt;lower I/O&lt;/strong&gt; as the changes to temporary tables are not WAL logged and by their nature reduce pollution of shared buffer pools.&lt;/p&gt;
&lt;p&gt;If your workload involves large temporary tables, increasing temp_buffers can help keep those operations purely in RAM. However, remember that this memory is per-connection, so it multiplies across all backends.&lt;/p&gt;
&lt;h2 id=&quot;the-os-page-cache&quot;&gt;The OS Page Cache&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-os-page-cache&quot; aria-label=&quot;Anchor link for: the-os-page-cache&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;PostgreSQL does not bypass the kernel. Every read and write goes through the operating system page cache, creating &lt;strong&gt;double buffering&lt;/strong&gt;: the same 8KB page can exist in both PostgreSQL&#39;s shared buffers and kernel memory simultaneously.&lt;/p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The OS cache acts as a second-tier cache; evicted clean pages often remain in kernel memory.&lt;/div&gt;
&lt;p&gt;When PostgreSQL evicts a clean page, that page usually remains in the OS page cache. If another query requests it shortly after, the kernel serves it from RAM without disk I/O. The kernel also provides &lt;strong&gt;read-ahead&lt;/strong&gt;, prefetching sequential pages before PostgreSQL asks for them.&lt;/p&gt;
&lt;p&gt;This coordination is why standard guidance recommends setting &lt;code&gt;shared_buffers&lt;/code&gt; to roughly 25% of RAM on dedicated servers, leaving the remainder for the OS page cache and per-backend memory.&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- typical starting point for a dedicated database server&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER SYSTEM SET&lt;/span&gt;&lt;span&gt; shared_buffers &lt;/span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;8GB&amp;#39;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;sidenote&quot;&gt;This parameter allocates no memory; it is purely a cost hint for the planner.&lt;/div&gt;
&lt;p&gt;The query planner needs an estimate of total available cache (shared buffers plus OS page cache) when calculating plan costs. That estimate is set by &lt;code&gt;effective_cache_size&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot; &gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SHOW effective_cache_size;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A higher value encourages index scans over sequential scans by assuming requested pages are likely already cached in RAM.&lt;/p&gt;
&lt;h2 id=&quot;where-to-go-next&quot;&gt;Where to go next&lt;a class=&quot;zola-anchor&quot; href=&quot;#where-to-go-next&quot; aria-label=&quot;Anchor link for: where-to-go-next&quot;&gt;&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The same buffer mechanics appear at the query level. &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; reports exact counts for pages hit in shared memory, read from disk or the OS cache, and dirtied by writes. How to inspect and tune those query-level counters is covered in &lt;a href=&quot;/posts/explain-buffers/&quot;&gt;Reading Buffer Statistics in EXPLAIN Output&lt;/a&gt;.&lt;/p&gt;
</content>
        
    </entry>
</feed>
