<?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 - postgresql</title>
    <subtitle>Learn practical SQL &amp; PostgreSQL techniques. Build rock-solid data systems with &#x27;boring&#x27; database solutions that deliver reliability without the drama.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://boringsql.com/tags/postgresql/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://boringsql.com"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-07-12T20:15:00+00:00</updated>
    <id>https://boringsql.com/tags/postgresql/atom.xml</id>
    <entry xml:lang="en">
        <title>The tests passed. The plan didn&#x27;t.</title>
        <published>2026-07-12T20:15:00+00:00</published>
        <updated>2026-07-12T20:15:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/regresql20/"/>
        <id>https://boringsql.com/posts/regresql20/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/regresql20/">&lt;p&gt;&lt;strong&gt;TL;DR&lt;&#x2F;strong&gt; - &lt;em&gt;RegreSQL 1.0 tested that your queries return the right rows. 2.0 tests that they return them the right way, and it does the checking against production&#x27;s real statistics instead of your empty dev database, which lies.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A migration cleanup dropped an index nobody thought was load-bearing. Every test passed: same rows, same order, green. Three days later the API started timing out on a query that hadn&#x27;t changed a character, because the planner had quietly switched it from an index scan to a sequential scan over a table that had kept growing.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;a href=&quot;&#x2F;posts&#x2F;regresql-testing-queries&#x2F;&quot;&gt;first version of RegreSQL&lt;&#x2F;a&gt; would have passed that change too. It tests what your queries &lt;em&gt;return&lt;&#x2F;em&gt;: run them, diff the rows against a committed expected file, go red when the output changes. That catches the query that now returns the wrong rows. It says nothing about the query that returns the right rows the wrong way, which is most of what takes a database down.&lt;&#x2F;p&gt;
&lt;p&gt;Version 2.0 tests that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;test-the-plan-not-just-the-rows&quot;&gt;Test the plan, not just the rows&lt;a class=&quot;zola-anchor&quot; href=&quot;#test-the-plan-not-just-the-rows&quot; aria-label=&quot;Anchor link for: test-the-plan-not-just-the-rows&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Here is that failure on a laptop. An &lt;code&gt;orders&lt;&#x2F;code&gt; table, an index on &lt;code&gt;customer_id&lt;&#x2F;code&gt;, and a query that reads one customer&#x27;s orders:&lt;&#x2F;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;-- orders-by-customer.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;select&lt;&#x2F;span&gt;&lt;span&gt; id, total&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  from&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; where&lt;&#x2F;span&gt;&lt;span&gt; customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; :cid&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;order by&lt;&#x2F;span&gt;&lt;span&gt; id;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Baseline it and run the tests. Green:&lt;&#x2F;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;$ regresql test&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ✓ 2 passing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now drop the index the way that migration did, and run the same tests again:&lt;&#x2F;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;$ regresql test&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ✓ 1 passing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ✗ 1 failing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FAILING:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  orders-by-customer.1.buffers (3898 &amp;gt; 109 * 102%, +3476.1%)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Expected buffers: 109&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Actual buffers:   3898 (+3476.1%)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Cost (info):      7962.41 (baseline: 421.03)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️ Table &amp;#39;orders&amp;#39;: Bitmap Heap Scan → Seq Scan&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Here is the whole loop, start to failure, as it actually runs:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;images&#x2F;posts&#x2F;regresql-demo.svg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;img src=&quot;&#x2F;images&#x2F;posts&#x2F;regresql-demo.svg&quot; style=&quot;display:block;max-width:680px;width:100%;margin:1.5em auto&quot; alt=&quot;Animated terminal recording: regresql init, baseline --analyze, and regresql test passing with 2 passing; then a migration drops orders_customer_id_idx and the next regresql test fails with orders-by-customer.1.buffers reading 3898 vs an expected 109 (+3476.1%) and table &#x27;orders&#x27; flipping from a Bitmap Heap Scan to a Seq Scan&quot;&gt;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The output check still passes; the rows didn&#x27;t change. The plan check catches what the output check can&#x27;t: the same query, returning the same result, now runs a sequential scan instead of the bitmap index scan it used before, reading thirty-five times the buffers. That failure blocks the merge. And the diff names the table and the exact change, in the same shape as the row diffs you already read in code review. Plan drift stops being something you find in production and becomes something you review in a pull request.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;an-empty-database-is-a-liar&quot;&gt;An empty database is a liar&lt;a class=&quot;zola-anchor&quot; href=&quot;#an-empty-database-is-a-liar&quot; aria-label=&quot;Anchor link for: an-empty-database-is-a-liar&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;That demo caught the regression because the table was big enough for the index to matter. On your dev database, it usually isn&#x27;t. Run &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; there and it says every query is fine, and it isn&#x27;t lying on purpose: at a few hundred rows the cheapest plan really is a sequential scan, and it picks it. Production&#x27;s planner reads different statistics and picks a different plan. A green suite on small data proves your queries are correct on small data, and nothing about the plan production will run. That gap, and why code review can&#x27;t see it, is a story of its own.&lt;&#x2F;p&gt;
&lt;p&gt;You can&#x27;t copy production data to your laptop. You don&#x27;t need to. You need what the planner reads: the row counts, the histograms, the most-common-values. PostgreSQL 18 dumps and loads those on their own (&lt;code&gt;pg_dump --statistics-only&lt;&#x2F;code&gt;), and RegreSQL 2.0 injects them so &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt;, against ten rows on your laptop, sees the real table&#x27;s distribution:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;regresql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --stats&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; production-stats.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now the plan you baseline is the plan production would pick, and a plan regression fails on your laptop before it reaches a server. That is the difference between testing your query and testing it against reality.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;gate-on-what-happened-not-what-was-guessed&quot;&gt;Gate on what happened, not what was guessed&lt;a class=&quot;zola-anchor&quot; href=&quot;#gate-on-what-happened-not-what-was-guessed&quot; aria-label=&quot;Anchor link for: gate-on-what-happened-not-what-was-guessed&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Cost estimates are the planner&#x27;s opinion. Opinions drift, and they drift most exactly when a plan is about to go wrong, because a bad plan is usually a bad &lt;em&gt;estimate&lt;&#x2F;em&gt; first. Gating a test on estimated cost means trusting the number that breaks first to tell you when it broke.&lt;&#x2F;p&gt;
&lt;p&gt;So 2.0 gates on measured behavior instead. &lt;code&gt;regresql baseline --analyze&lt;&#x2F;code&gt; runs the query for real and records what happened: buffers actually read, whether a sort or hash spilled to disk, how many rows actually flowed through each node, and the cardinality error, the ratio between what the planner predicted and what it got (the &quot;q-error&quot;). A query whose estimates were off by 2x and are now off by 200x has a plan that is one data change away from collapse, and the row counts say so long before the cost does. The baseline stores actuals; the test compares actuals; the estimate is never on the witness stand.&lt;&#x2F;p&gt;
&lt;p&gt;Wall-clock time is the actual everyone wants and the one that lies most, because it moves with cache state, whatever else is on the box, and luck. 2.0 measures it and treats it as evidence, not a verdict: interleaved runs, a per-query median, and a noise threshold derived by permuting the samples, so a query counts as slower only when it beats its own measurement noise. Anything that can&#x27;t clear the threshold goes in an unstable bucket instead of failing the build. Timing advises; it never gates the exit code.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;knowing-when-you-can-t-tell&quot;&gt;Knowing when you can&#x27;t tell&lt;a class=&quot;zola-anchor&quot; href=&quot;#knowing-when-you-can-t-tell&quot; aria-label=&quot;Anchor link for: knowing-when-you-can-t-tell&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Here is the part that took the longest to get right, and the part I am most sure of.&lt;&#x2F;p&gt;
&lt;p&gt;Point RegreSQL at the same corpus on two builds of PostgreSQL and it will show you every query that plans differently between them. Early on, one query looked like a clean regression: the newer build read nearly three times the buffers, deterministically, run after run. It had every mark of a real find.&lt;&#x2F;p&gt;
&lt;p&gt;It was not one. Both builds had &lt;code&gt;ANALYZE&lt;&#x2F;code&gt;&#x27;d their own copy of the data, and the query sat on a knife-edge join-order decision that a few unlucky sampled rows tip one way or the other. Inject one build&#x27;s exact statistics into the other and they plan identically. Re-&lt;code&gt;ANALYZE&lt;&#x2F;code&gt; and the &quot;regression&quot; moves to the other build. It was sampling noise, and a tool with less doubt would have reported it as a regression.&lt;&#x2F;p&gt;
&lt;p&gt;So 2.0 will not make a cross-version claim on statistics it does not trust. It injects identical statistics into both builds, so any remaining difference is code and not luck, and it re-&lt;code&gt;ANALYZE&lt;&#x2F;code&gt;s the baseline several times to throw out queries whose plan is a coin-flip to begin with. A difference is reported only when it survives both. On that investigation the honest output was zero regressions, and zero was the correct answer.&lt;&#x2F;p&gt;
&lt;p&gt;A tool that only ever says &quot;found something&quot; is a tool you eventually stop believing. In a field full of confident dashboards, &quot;I can&#x27;t tell you that from this data&quot; is the more useful sentence, and it is a first-class answer here, not an apology.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;testing-the-planner-itself&quot;&gt;Testing the planner itself&lt;a class=&quot;zola-anchor&quot; href=&quot;#testing-the-planner-itself&quot; aria-label=&quot;Anchor link for: testing-the-planner-itself&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Chase these problems far enough and you end up at PostgreSQL&#x27;s own test suite. &lt;code&gt;pg_regress&lt;&#x2F;code&gt;, the harness that has guarded the database for decades, checks one thing: does the query return the expected text. It says nothing about the plan, the cost, or the cardinality error, because it folds all of them into a single &lt;code&gt;.out&lt;&#x2F;code&gt; text file that either matches or does not. It is correctness-only by construction, and nothing in the standard toolchain measures whether a planner change made real queries better or worse.&lt;&#x2F;p&gt;
&lt;p&gt;Everything above, separating results from plan-shape from measured actuals from cardinality error into layers that can each be judged on their own, is what you would need to answer that question. Which is why 2.0 also has the pieces that only make sense pointed at PostgreSQL rather than at your application:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cross-version scoreboards&lt;&#x2F;strong&gt; with the trust filter above, so a planner patch can be measured against a corpus without drowning in &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; noise.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;A metamorphic check&lt;&#x2F;strong&gt;: flip an optimization that is supposed to preserve results (eager aggregation, memoize, incremental sort) and confirm the rows do not move. When they move, you have found a wrong-results bug in the optimizer, with no second database to compare against.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Determinism admission&lt;&#x2F;strong&gt;, which keeps only the queries whose result is stable across different plans, so an ambiguous &lt;code&gt;LIMIT&lt;&#x2F;code&gt; over ties never masquerades as a regression.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This is a longer road, and an open one. But the same machinery that tells you your application query got slower can tell a PostgreSQL developer whether their patch made a thousand real queries slower, and that is a thing the project cannot currently see per-change. RegreSQL started as a way to test the queries you write. It turns out the honest way to do that is most of the way to a harness for the planner underneath them.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;leave-evidence&quot;&gt;Leave evidence&lt;a class=&quot;zola-anchor&quot; href=&quot;#leave-evidence&quot; aria-label=&quot;Anchor link for: leave-evidence&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;None of this is useful if it only runs on your laptop. &lt;code&gt;regresql test&lt;&#x2F;code&gt; exits non-zero on a failure, emits JUnit, GitHub Actions, or JSON, and reads &lt;code&gt;DATABASE_URL&lt;&#x2F;code&gt; so the same project runs against a CI database without editing committed config. A plan flip fails the build the way a wrong-rows change does, and the run leaves a machine-readable record of what was checked against which snapshot. The check that advised you in the editor is the same check that blocks the merge.&lt;&#x2F;p&gt;
&lt;p&gt;You can go further and name the tables where a sequential scan is never acceptable, and have that fail the build outright. But which tables those are, and which of these signals is a release blocker versus a shrug, is a judgment the planner doesn&#x27;t have and never will. That part is a longer story, and a separate one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-verified-means&quot;&gt;What &quot;verified&quot; means&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-verified-means&quot; aria-label=&quot;Anchor link for: what-verified-means&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;RegreSQL 2.0 is careful about the word. It does not promise your database is &lt;em&gt;safe&lt;&#x2F;em&gt;. It verifies what it can measure and says so plainly when it can&#x27;t, which on the day it mattered meant reporting nothing at all. A green RegreSQL run is not &quot;nothing will go wrong.&quot; It is &quot;these queries return what they returned, plan the way they planned, against the statistics that actually apply.&quot; That is a smaller promise than most tools make, and one you can keep.&lt;&#x2F;p&gt;
&lt;p&gt;It runs on your laptop, against a database you already have, no production access:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;regresql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; init postgres:&#x2F;&#x2F;localhost&#x2F;yourdb&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;From there the SQL passes through the harness the way your code already passes through the type checker. The &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringSQL&#x2F;regresql&quot;&gt;getting-started guide&lt;&#x2F;a&gt; walks the whole loop, from zero to a plan check in CI, in about ten minutes.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Same rows, different SUM</title>
        <published>2026-06-28T21:25:00+00:00</published>
        <updated>2026-06-28T21:25:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/same-rows-different-sum/"/>
        <id>https://boringsql.com/posts/same-rows-different-sum/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/same-rows-different-sum/">&lt;p&gt;Everyone knows not to store money as a &lt;code&gt;double precision&lt;&#x2F;code&gt;. One can hope. The rule is so well drilled that it has stopped being interesting, and it is also not where the trouble usually starts. The float is already in the schema before anyone weighs in on it: a measurement column someone later sums for a report, telemetry that drifts into a finance dashboard, a third-party feed ingested as &lt;code&gt;double precision&lt;&#x2F;code&gt; because that is how it arrived.&lt;&#x2F;p&gt;
&lt;p&gt;Here is the part the rule does not warn you about. Take a table of five million floating-point readings, sum the column, and run it three times in a row. Nothing else touches the table. Same connection, same data, same statement.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;        sum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2500519211.7874823&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       sum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2500519211.787477&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        sum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2500519211.7874575&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three runs, three different totals. No &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, no concurrent writer, no random seed. The rows did not change between runs, and the query is the same character for character. Yet the answer is not.&lt;&#x2F;p&gt;
&lt;p&gt;This is not a Postgres bug, and it is not specific to Postgres. It is what happens when floating-point arithmetic meets parallel aggregation, and it has been generating &quot;my dashboard total changed and I have no idea why&quot; tickets for as long as databases have parallelized. The non-determinism does not wait for you to opt into bad practice; it shows up the moment a parallel plan runs over whatever floats you happen to have.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-schema&quot;&gt;The schema&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-schema&quot; aria-label=&quot;Anchor link for: the-schema&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;One column of &lt;code&gt;double precision&lt;&#x2F;code&gt;, five million rows.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; measurements&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id      &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;double precision NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; measurements (reading)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5000000&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE measurements;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Five million rows is enough that Postgres parallelizes the sum on its own. No settings forced, defaults all the way:&lt;&#x2F;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 (COSTS &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OFF&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                     QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Finalize Aggregate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Gather&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Workers Planned: 2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Partial Aggregate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               -&amp;gt;  Parallel Seq Scan on measurements&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two workers each sum part of the table in parallel, and a final step folds the partial sums together. Everything that follows is in that split.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;floating-point-does-not-associate&quot;&gt;Floating point does not associate&lt;a class=&quot;zola-anchor&quot; href=&quot;#floating-point-does-not-associate&quot; aria-label=&quot;Anchor link for: floating-point-does-not-associate&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;On paper, &lt;code&gt;(a + b) + c&lt;&#x2F;code&gt; and &lt;code&gt;a + (b + c)&lt;&#x2F;code&gt; are the same number. Postgres is not doing arithmetic on paper. Floating-point addition is not associative: change the grouping and you can change the answer, because every intermediate result is rounded to fit 64 bits. Move the parentheses and you move which roundings happen.&lt;&#x2F;p&gt;
&lt;p&gt;The textbook demonstration takes three values and one subtraction:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;.0e20::float8 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;+&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;::float8 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;.0e20::float8;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The answer should be &lt;code&gt;1&lt;&#x2F;code&gt;. Read the expression left to right and watch where it goes wrong:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1.0e20 + 1.0&lt;&#x2F;code&gt; should be &lt;code&gt;100000000000000000001&lt;&#x2F;code&gt;. But a double only carries about 16 significant digits, and that number needs 21. The trailing &lt;code&gt;1&lt;&#x2F;code&gt; falls off the end, below the smallest digit the number can represent. So the addition rounds back to exactly &lt;code&gt;1.0e20&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;1.0e20 - 1.0e20&lt;&#x2F;code&gt; is then exactly &lt;code&gt;0&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The &lt;code&gt;1&lt;&#x2F;code&gt; never made it past the first step. It was swallowed by the addition, before the subtraction even ran. This is &lt;em&gt;absorption&lt;&#x2F;em&gt;: a value too small relative to its neighbour to leave a mark. It is not a database phenomenon. The same expression evaluates to &lt;code&gt;0&lt;&#x2F;code&gt; in C, Python, JavaScript, and every other language that uses IEEE 754 doubles.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;A &lt;code&gt;double precision&lt;&#x2F;code&gt; number keeps 52 bits for the mantissa, the digits that carry precision. This is around 15 to 17 decimal digits, no more. When the running total becomes big and the next number is small, the small one does not fit into those bits, so the rounding throws its tail away. Sum five million numbers and the total is, for most of its life, much bigger than any single number you add to it.&lt;&#x2F;div&gt;
&lt;p&gt;So the sum of a column depends on the &lt;em&gt;order&lt;&#x2F;em&gt; in which the rows are added. Not on which rows, on the order. That is the seam parallel query pries open.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-parallelism-surfaces-it&quot;&gt;Why parallelism surfaces it&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-parallelism-surfaces-it&quot; aria-label=&quot;Anchor link for: why-parallelism-surfaces-it&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The plan spells out exactly how. &lt;code&gt;Parallel Seq Scan&lt;&#x2F;code&gt; does not split the table into fixed slices upfront. It hands every worker a shared pointer into the same block iterator, and each worker grabs the next block, processes it, and comes back for another. Each worker runs its own &lt;code&gt;Partial Aggregate&lt;&#x2F;code&gt;, summing only the rows it happened to grab, and by default the leader process pitches in as a third one (&lt;code&gt;parallel_leader_participation&lt;&#x2F;code&gt;). &lt;code&gt;Gather&lt;&#x2F;code&gt; collects those partial sums in whatever order they arrive, and &lt;code&gt;Finalize Aggregate&lt;&#x2F;code&gt; folds them into a single total.&lt;&#x2F;p&gt;
&lt;p&gt;Two things vary from run to run, and neither is under your control:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Which rows each process sees.&lt;&#x2F;strong&gt; Because the blocks are pulled from a shared iterator on demand, the split is a race. A worker that gets a few microseconds ahead, for any reason the OS scheduler decides, grabs the next block, so the same worker ends up with a different set of rows on every run. The division is never guaranteed to be the same twice.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;The order the partial sums come back.&lt;&#x2F;strong&gt; &lt;code&gt;Finalize Aggregate&lt;&#x2F;code&gt; folds the partials into its total in arrival order. With only two values this would not matter, since &lt;code&gt;a + b&lt;&#x2F;code&gt; equals &lt;code&gt;b + a&lt;&#x2F;code&gt;. But there are three of them, the two workers and the leader, and combining three floats is once again an associativity question: which two get added first decides the rounding.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Both feed directly into the one thing float addition is sensitive to: grouping. Different row split, different partial sums; different arrival order, different final grouping of the additions. The total lands a few bits away from where it landed last time. Across five million readings that shows up in the sixth decimal place, exactly where the three runs above disagreed.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;sum(float8)&lt;&#x2F;code&gt; is marked parallel-safe, which is why the planner is free to do this. It is safe in the sense that it will not crash or corrupt anything. It is not safe in the sense people assume, that the answer is a function of the input.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;&lt;strong&gt;This is not unique to &lt;code&gt;SUM&lt;&#x2F;code&gt;.&lt;&#x2F;strong&gt; &lt;code&gt;AVG&lt;&#x2F;code&gt;, &lt;code&gt;STDDEV&lt;&#x2F;code&gt;, &lt;code&gt;VARIANCE&lt;&#x2F;code&gt;, and any other floating-point aggregate that accumulates across rows inherit the same order sensitivity. &lt;code&gt;SUM&lt;&#x2F;code&gt; is just the easiest to demonstrate and the one most likely to land in a number a human reads.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;it-was-never-really-parallelism&quot;&gt;It was never really parallelism&lt;a class=&quot;zola-anchor&quot; href=&quot;#it-was-never-really-parallelism&quot; aria-label=&quot;Anchor link for: it-was-never-really-parallelism&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;It is tempting to file this under &quot;parallel query is flaky&quot; and turn it off. That misses the point. Parallelism does not cause the non-determinism, it only &lt;em&gt;exposes&lt;&#x2F;em&gt; an order-dependence that was there the whole time.&lt;&#x2F;p&gt;
&lt;p&gt;Turn parallelism off completely and the sum becomes stable:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; max_parallel_workers_per_gather &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.787516&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.787516&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.787516&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Stable, but only because a serial sequential scan visits the heap in the same physical order every time, so the additions group the same way every time. The order is fixed, so the rounding is fixed. Change the order and the serial sum moves too. We can force different orders without any parallelism at all, using an ordered-set aggregate:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; max_parallel_workers_per_gather &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;;     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- serial, so ONLY order varies&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; id)           &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.787516&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading)      &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.7876067&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DESC&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.7875423&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Same rows, same single thread, three summation orders, three answers. Parallelism is one way to shuffle the order, but a plain &lt;code&gt;ORDER BY&lt;&#x2F;code&gt; inside the aggregate does it just as well. The dependence is on order, full stop.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;deterministic-is-not-the-same-as-correct&quot;&gt;Deterministic is not the same as correct&lt;a class=&quot;zola-anchor&quot; href=&quot;#deterministic-is-not-the-same-as-correct&quot; aria-label=&quot;Anchor link for: deterministic-is-not-the-same-as-correct&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;There is a subtler trap hiding in the &quot;turn it off and it is stable&quot; result. A stable answer is not necessarily the right answer. To see the true total, sum the same values as &lt;code&gt;numeric&lt;&#x2F;code&gt;, which adds in exact decimal with no rounding until the very end:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;              sum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2500519211.787503050078417764&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That is the exact sum of the stored values. Now line everything up:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;How the sum was computed&lt;&#x2F;th&gt;&lt;th&gt;Result&lt;&#x2F;th&gt;&lt;th&gt;Off by&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Exact (&lt;code&gt;numeric&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;2500519211.787503050…&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;reference&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Serial &lt;code&gt;float8&lt;&#x2F;code&gt;, physical order&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;2500519211.787516&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;~1.3e-5&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Parallel &lt;code&gt;float8&lt;&#x2F;code&gt;, run A&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;2500519211.7874823&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;~2.1e-5&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Parallel &lt;code&gt;float8&lt;&#x2F;code&gt;, run B&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;2500519211.787477&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;~2.6e-5&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Ordered by value ascending&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;2500519211.7876067&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;~1.0e-4&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Every floating-point total is wrong, including the &quot;stable&quot; serial one. They differ from the exact value in the fifth and sixth decimal places. Parallelism did not introduce the error. It removed the one comforting property the serial sum had, which was getting &lt;em&gt;the same wrong answer every time&lt;&#x2F;em&gt;. That sameness is what people mistake for correctness.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Summing smallest-to-largest is the classic textbook advice for reducing floating-point error, and here ascending order happens to land further from the exact value than physical order does. Order changes the error; it does not reliably shrink it. If you need a smaller error there are compensated-summation algorithms (Kahan and friends), but inside a SQL aggregate you do not get to choose them.&lt;&#x2F;div&gt;
&lt;h2 id=&quot;where-it-actually-bites&quot;&gt;Where it actually bites&lt;a class=&quot;zola-anchor&quot; href=&quot;#where-it-actually-bites&quot; aria-label=&quot;Anchor link for: where-it-actually-bites&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;In the sixth decimal of a five-million-row random sum, nobody cares. The problem is the places where someone does.&lt;&#x2F;p&gt;
&lt;p&gt;The obvious one is reconciliation. A finance team sums a revenue column stored as &lt;code&gt;double precision&lt;&#x2F;code&gt;, exports it, and the number disagrees with the same query run an hour later, or with the same logic in a different tool running a different parallel degree. Nothing in the data changed, so the hunt goes to caching, to replicas, to &quot;a dirty read somewhere&quot; long before anyone suspects the aggregate itself.&lt;&#x2F;p&gt;
&lt;p&gt;The same thing wrecks regression tests, just less visibly. Assert that a float total equals an exact literal, or compare two pipelines for byte-identical sums, and the test will flap. It passes on a small fixture that runs serially, then fails in CI on a larger one that crosses the parallel threshold, with no code change in between. The test is asserting a property the type does not have, and it will keep flapping until someone notices that.&lt;&#x2F;p&gt;
&lt;p&gt;And anywhere a float sum feeds an equality check, a join, or a &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; key, you are building on sand. Two totals that &quot;should&quot; be equal can differ in their last bits, and now your join silently drops rows.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;postgres-already-knows-this-is-dangerous&quot;&gt;Postgres already knows this is dangerous&lt;a class=&quot;zola-anchor&quot; href=&quot;#postgres-already-knows-this-is-dangerous&quot; aria-label=&quot;Anchor link for: postgres-already-knows-this-is-dangerous&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The most convincing evidence that this is a real, known hazard and not a corner case is that Postgres deliberately gives up an optimization because of it.&lt;&#x2F;p&gt;
&lt;p&gt;Window functions can compute a moving sum efficiently using an &lt;em&gt;inverse transition function&lt;&#x2F;em&gt;: as the frame slides forward, subtract the value leaving the window instead of re-summing the whole frame. It works for &lt;code&gt;numeric&lt;&#x2F;code&gt; and integers. For &lt;code&gt;float4&lt;&#x2F;code&gt; and &lt;code&gt;float8&lt;&#x2F;code&gt;, Postgres refuses to do it. From the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;xaggr.html&quot;&gt;documentation on user-defined aggregates&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;An example of an aggregate for which adding an inverse transition function seems easy at first, yet where this requirement cannot be met is &lt;code&gt;sum&lt;&#x2F;code&gt; over &lt;code&gt;float4&lt;&#x2F;code&gt; or &lt;code&gt;float8&lt;&#x2F;code&gt; inputs.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The docs then show why, with the same absorption we saw earlier. If a window holds &lt;code&gt;1e20&lt;&#x2F;code&gt; and &lt;code&gt;1&lt;&#x2F;code&gt;, subtracting &lt;code&gt;1e20&lt;&#x2F;code&gt; as it leaves the frame yields &lt;code&gt;0&lt;&#x2F;code&gt;, not the &lt;code&gt;1&lt;&#x2F;code&gt; that should remain. So the built-in float sum simply does not use the inverse optimization, and a moving &lt;code&gt;sum(float8)&lt;&#x2F;code&gt; recomputes the frame to stay correct:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; n, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;sum&lt;&#x2F;span&gt;&lt;span&gt;(x) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OVER&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; n &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ROWS BETWEEN&lt;&#x2F;span&gt;&lt;span&gt; CURRENT &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ROW AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FOLLOWING&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;.0e20::float8), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;::float8)) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; v(n, x);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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 | sum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---+-------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1 | 1e+20&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2 |     1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The second row is &lt;code&gt;1&lt;&#x2F;code&gt;, correct, because Postgres declined the shortcut that would have made it &lt;code&gt;0&lt;&#x2F;code&gt;. The engine is quietly working around the imprecision of float sums on your behalf. The non-determinism in the parallel case is the same hazard showing up somewhere the engine cannot paper over it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-to-do-about-it&quot;&gt;What to do about it&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-to-do-about-it&quot; aria-label=&quot;Anchor link for: what-to-do-about-it&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The fix is almost always the type, not the query.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;numeric&lt;&#x2F;code&gt; for anything anyone reconciles, compares, or reads as a figure.&lt;&#x2F;strong&gt; Money, totals on reports, anything that has to match another system or itself across runs. &lt;code&gt;numeric&lt;&#x2F;code&gt; sums in exact decimal and is order-independent: same rows, same answer, parallel or not, in any order.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; max_parallel_workers_per_gather &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 4&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.787503050078417764&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; sum&lt;&#x2F;span&gt;&lt;span&gt;(reading::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; measurements;  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2500519211.787503050078417764&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The cast above stabilizes a single query, but the durable fix is storing the column as &lt;code&gt;numeric&lt;&#x2F;code&gt; in the first place, so the exact values are what live on disk.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;numeric&lt;&#x2F;code&gt; is not free. It is arbitrary-precision arithmetic done in software, so it is slower to compute and takes more space on disk than a hardware float the CPU adds in a single instruction. For anything that has to reconcile, that is the price of correctness, and it is almost always worth paying. For a column nobody ever sums into a figure a person reads, it usually is not.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;If the data is genuinely floating point&lt;&#x2F;strong&gt; (sensor readings, scientific measurements, anything where the input is already an approximation), then accept that aggregates over it are approximate and do not assert exact equality on them. Round to a sensible number of decimals before comparing or displaying, and never use a raw float sum as a join or grouping key.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Pinning &lt;code&gt;max_parallel_workers_per_gather = 0&lt;&#x2F;code&gt; makes a float sum repeatable, but does not make it correct,&lt;&#x2F;strong&gt; and it costs you parallelism on every other query in the session. It is a way to get a stable wrong answer, not a fix. Reach for the type instead.&lt;&#x2F;p&gt;
&lt;p&gt;None of this is Postgres-specific. Any database that parallelizes aggregation, and they all do, has the same behavior over the same IEEE 754 floats. The trigger that makes it visible is simply crossing whatever threshold turns parallelism on, which is why it so often shows up when a table grows past a certain size and &quot;nothing else changed.&quot;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The NULL in your NOT IN</title>
        <published>2026-06-14T23:30:00+00:00</published>
        <updated>2026-06-14T23:30:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/not-in-null/"/>
        <id>https://boringsql.com/posts/not-in-null/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/not-in-null/">&lt;p&gt;A &lt;code&gt;NOT IN&lt;&#x2F;code&gt; query can return the wrong answer without telling you. It is valid SQL, it runs without an error, and it hands back a perfectly well-formed result set that happens to be empty when it should not be. No warning, no hint, nothing in the logs: just zero rows where you expected hundreds, and a database that considers it correct.&lt;&#x2F;p&gt;
&lt;p&gt;Almost always the cause is a single &lt;code&gt;NULL&lt;&#x2F;code&gt; sitting somewhere you forgot to look, combined with two keywords you have typed a thousand times: &lt;code&gt;NOT IN&lt;&#x2F;code&gt;. None of it is a Postgres bug. This is exactly what the SQL standard mandates, implemented faithfully. That is precisely what makes it so easy to walk into, and why the planner could not safely optimize around it for the better part of Postgres&#x27;s history. It comes down to one &lt;code&gt;if&lt;&#x2F;code&gt; statement in the parser.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sample-schema&quot;&gt;Sample schema&lt;a class=&quot;zola-anchor&quot; href=&quot;#sample-schema&quot; aria-label=&quot;Anchor link for: sample-schema&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Nothing elaborate. A table of products, one of which has no category assigned yet, and a table of archived categories that happens to contain a &lt;code&gt;NULL&lt;&#x2F;code&gt;:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; products&lt;&#x2F;span&gt;&lt;span&gt; (id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;, category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;20&lt;&#x2F;span&gt;&lt;span&gt;), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;4&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; archived&lt;&#x2F;span&gt;&lt;span&gt; (category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; archived &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;20&lt;&#x2F;span&gt;&lt;span&gt;), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;NULL&lt;&#x2F;code&gt; in &lt;code&gt;archived&lt;&#x2F;code&gt; is not contrived. The moment a column is nullable (and most are, by default), a &lt;code&gt;NULL&lt;&#x2F;code&gt; can find its way into any subquery you point a &lt;code&gt;NOT IN&lt;&#x2F;code&gt; at. That is the whole point: this is not an exotic data condition, it is the ordinary one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-query-that-returns-nothing&quot;&gt;The query that returns nothing&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-query-that-returns-nothing&quot; aria-label=&quot;Anchor link for: the-query-that-returns-nothing&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Here is the request you have written a hundred times: give me the products whose category is &lt;em&gt;not&lt;&#x2F;em&gt; archived.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; id, category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; products&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; archived);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You expect products 1 and 4 (category 10, which is not in the archived set). What comes back is:&lt;&#x2F;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; id | category_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+-------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(0 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every row gone. Not a subset, not an off-by-one: all of them. Drop the &lt;code&gt;NULL&lt;&#x2F;code&gt; from &lt;code&gt;archived&lt;&#x2F;code&gt; and the same query behaves:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; id, category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; products&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; archived&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;                          WHERE&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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 | category_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+-------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |          10&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 |          10&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;To understand why a single &lt;code&gt;NULL&lt;&#x2F;code&gt; empties the entire result, we have to stop thinking of &lt;code&gt;NOT IN&lt;&#x2F;code&gt; as a single thing and watch the parser take it apart.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;in-is-an-or-not-in-is-an-and&quot;&gt;IN is an OR, NOT IN is an AND&lt;a class=&quot;zola-anchor&quot; href=&quot;#in-is-an-or-not-in-is-an-and&quot; aria-label=&quot;Anchor link for: in-is-an-or-not-in-is-an-and&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;IN&lt;&#x2F;code&gt; is not a primitive operator. It is shorthand that the parser rewrites into a chain of equality comparisons joined by &lt;code&gt;OR&lt;&#x2F;code&gt;:&lt;&#x2F;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;x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (a, b, c)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- becomes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OR&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; b &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OR&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; c&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;NOT IN&lt;&#x2F;code&gt; is the logical negation of that, and by De Morgan&#x27;s law negating an &lt;code&gt;OR&lt;&#x2F;code&gt; of equalities gives you an &lt;code&gt;AND&lt;&#x2F;code&gt; of inequalities:&lt;&#x2F;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;x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT IN&lt;&#x2F;span&gt;&lt;span&gt; (a, b, c)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- becomes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AND&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; b &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AND&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; c&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is not an analogy. It is literally the expression Postgres builds, and you can read it straight off an &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt;. The literal-list forms collapse into array operators whose names give the whole game away:&lt;&#x2F;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 (COSTS &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OFF&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--  Filter: (category_id = ANY (&amp;#39;{1,2,3}&amp;#39;::integer[]))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN (COSTS &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OFF&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--  Filter: (category_id &amp;lt;&amp;gt; ALL (&amp;#39;{1,2,3}&amp;#39;::integer[]))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;IN&lt;&#x2F;code&gt; is &lt;code&gt;= ANY&lt;&#x2F;code&gt;: equal to &lt;em&gt;any&lt;&#x2F;em&gt; element, an &lt;code&gt;OR&lt;&#x2F;code&gt;. &lt;code&gt;NOT IN&lt;&#x2F;code&gt; is &lt;code&gt;&amp;lt;&amp;gt; ALL&lt;&#x2F;code&gt;: different from &lt;em&gt;all&lt;&#x2F;em&gt; elements, an &lt;code&gt;AND&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The actual node types matter here, because they are what you end up staring at when you dump a parse tree or read a normalized &lt;a href=&quot;&#x2F;posts&#x2F;pg-stat-statements&#x2F;&quot;&gt;&lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; entry. A literal list compiles to a single &lt;code&gt;ScalarArrayOpExpr&lt;&#x2F;code&gt;: the scalar on the left, the array on the right, and a &lt;code&gt;useOr&lt;&#x2F;code&gt; flag that is the entire difference between &lt;code&gt;= ANY&lt;&#x2F;code&gt; and &lt;code&gt;&amp;lt;&amp;gt; ALL&lt;&#x2F;code&gt;. The subquery forms are a different node altogether, a &lt;code&gt;SubLink&lt;&#x2F;code&gt;. Recognising those two names on sight tells you immediately which path the planner is on.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;If &quot;&lt;code&gt;IN&lt;&#x2F;code&gt; and &lt;code&gt;= ANY&lt;&#x2F;code&gt; are the same operator&quot; is news: they compile to the same parse node and the same plan, with the spellings diverging only in plan-cache churn and selectivity estimates. The &lt;code&gt;NOT IN&lt;&#x2F;code&gt; case in front of you here is the one corner where the choice is not cosmetic but a matter of correctness.&lt;&#x2F;div&gt;
&lt;h2 id=&quot;three-valued-logic-does-the-rest&quot;&gt;Three-valued logic does the rest&lt;a class=&quot;zola-anchor&quot; href=&quot;#three-valued-logic-does-the-rest&quot; aria-label=&quot;Anchor link for: three-valued-logic-does-the-rest&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;SQL does not have two truth values, it has three: &lt;strong&gt;true&lt;&#x2F;strong&gt;, &lt;strong&gt;false&lt;&#x2F;strong&gt;, and &lt;strong&gt;unknown&lt;&#x2F;strong&gt;. Any comparison against &lt;code&gt;NULL&lt;&#x2F;code&gt; yields &lt;code&gt;unknown&lt;&#x2F;code&gt;, because &lt;code&gt;NULL&lt;&#x2F;code&gt; means &quot;no value here&quot; and you cannot ask whether an absent value is different from 20:&lt;&#x2F;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;-- not false. unknown (displayed as a blank)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&amp;gt; NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now walk the &lt;code&gt;NOT IN&lt;&#x2F;code&gt; expansion for product 1 (category 10) against the archived set of 20 and &lt;code&gt;NULL&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;div style=&quot;text-align:center&quot;&gt;
&lt;a href=&quot;&#x2F;images&#x2F;posts&#x2F;not-in-light.svg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; class=&quot;only-light&quot;&gt;&lt;img src=&quot;&#x2F;images&#x2F;posts&#x2F;not-in-light.svg&quot; style=&quot;display:inline-block;max-width:280px;margin:2em auto&quot; alt=&quot;Evaluation tree for 10 NOT IN (20, NULL): it expands to 10 &lt;&gt; 20 AND 10 &lt;&gt; NULL, which evaluate to true and unknown; the NULL poisons its branch, and AND carries the unknown to the root, so the row is dropped&quot;&gt;&lt;&#x2F;a&gt;
&lt;a href=&quot;&#x2F;images&#x2F;posts&#x2F;not-in-dark.svg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; class=&quot;only-dark&quot;&gt;&lt;img src=&quot;&#x2F;images&#x2F;posts&#x2F;not-in-dark.svg&quot; style=&quot;display:inline-block;max-width:280px;margin:2em auto&quot; alt=&quot;Evaluation tree for 10 NOT IN (20, NULL): it expands to 10 &lt;&gt; 20 AND 10 &lt;&gt; NULL, which evaluate to true and unknown; the NULL poisons its branch, and AND carries the unknown to the root, so the row is dropped&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;&lt;code&gt;true AND unknown&lt;&#x2F;code&gt; is &lt;code&gt;unknown&lt;&#x2F;code&gt;, not &lt;code&gt;true&lt;&#x2F;code&gt;. A &lt;code&gt;WHERE&lt;&#x2F;code&gt; clause keeps a row only when its predicate evaluates to &lt;strong&gt;true&lt;&#x2F;strong&gt;. Both &lt;code&gt;false&lt;&#x2F;code&gt; and &lt;code&gt;unknown&lt;&#x2F;code&gt; cause the row to be discarded. So product 1 is dropped. Run the same arithmetic for product 4 and you land on &lt;code&gt;unknown&lt;&#x2F;code&gt; again.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;&lt;strong&gt;The mechanism in one sentence:&lt;&#x2F;strong&gt; the instant a single &lt;code&gt;NULL&lt;&#x2F;code&gt; enters the right-hand side, the trailing &lt;code&gt;AND unknown&lt;&#x2F;code&gt; term can never be &lt;code&gt;true&lt;&#x2F;code&gt;, so the whole &lt;code&gt;NOT IN&lt;&#x2F;code&gt; can never be &lt;code&gt;true&lt;&#x2F;code&gt;, so &lt;strong&gt;every&lt;&#x2F;strong&gt; row is discarded, regardless of how many million rows you have or what they contain.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;nulls-on-the-left-side-too&quot;&gt;NULLs on the left side too&lt;a class=&quot;zola-anchor&quot; href=&quot;#nulls-on-the-left-side-too&quot; aria-label=&quot;Anchor link for: nulls-on-the-left-side-too&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Keeping &lt;code&gt;NULL&lt;&#x2F;code&gt;s out of the subquery is not enough. The same &lt;code&gt;unknown&lt;&#x2F;code&gt; arises from &lt;code&gt;NULL&lt;&#x2F;code&gt;s on the &lt;em&gt;left&lt;&#x2F;em&gt;: product 3 (whose &lt;code&gt;category_id&lt;&#x2F;code&gt; is &lt;code&gt;NULL&lt;&#x2F;code&gt;) evaluates to &lt;code&gt;unknown AND unknown&lt;&#x2F;code&gt;, so it is dropped even against a spotless right-hand set. &lt;code&gt;IN&lt;&#x2F;code&gt; and &lt;code&gt;NOT IN&lt;&#x2F;code&gt; are not complements: a row can fail both tests simultaneously. There is a &lt;code&gt;NULL&lt;&#x2F;code&gt;-shaped gap between them that belongs to neither.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-seam-in-the-source&quot;&gt;The seam, in the source&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-seam-in-the-source&quot; aria-label=&quot;Anchor link for: the-seam-in-the-source&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;All of this reduces to one branch in one function. Open &lt;code&gt;src&#x2F;backend&#x2F;parser&#x2F;parse_expr.c&lt;&#x2F;code&gt; and find &lt;code&gt;transformAExprIn&lt;&#x2F;code&gt;, the routine that turns both &lt;code&gt;IN&lt;&#x2F;code&gt; and &lt;code&gt;NOT IN&lt;&#x2F;code&gt; list expressions into something the planner can chew on. The very first thing it decides is whether it is building an &lt;code&gt;OR&lt;&#x2F;code&gt; or an &lt;code&gt;AND&lt;&#x2F;code&gt;:&lt;&#x2F;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: #6A737D;&quot;&gt;&#x2F;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * If the operator is &amp;lt;&amp;gt;, combine with AND not OR.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; *&#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;if&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;strcmp&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;strVal&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;linitial&lt;&#x2F;span&gt;&lt;span&gt;(a&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;)),&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;&amp;lt;&amp;gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    useOr &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; false&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;else&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    useOr &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; true&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That is the entire fork. &lt;code&gt;IN&lt;&#x2F;code&gt; arrives carrying the operator &lt;code&gt;=&lt;&#x2F;code&gt; and gets &lt;code&gt;useOr = true&lt;&#x2F;code&gt;; &lt;code&gt;NOT IN&lt;&#x2F;code&gt; arrives carrying &lt;code&gt;&amp;lt;&amp;gt;&lt;&#x2F;code&gt; and gets &lt;code&gt;useOr = false&lt;&#x2F;code&gt;. The flag rides all the way down to where the boolean tree is finally assembled, several hundred lines later:&lt;&#x2F;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&gt;result &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; (Node &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; makeBoolExpr&lt;&#x2F;span&gt;&lt;span&gt;(useOr &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt; OR_EXPR &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span&gt; AND_EXPR,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;                               list_make2&lt;&#x2F;span&gt;&lt;span&gt;(result, cmp),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                               a&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;location&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;OR_EXPR&lt;&#x2F;code&gt; for &lt;code&gt;IN&lt;&#x2F;code&gt;, &lt;code&gt;AND_EXPR&lt;&#x2F;code&gt; for &lt;code&gt;NOT IN&lt;&#x2F;code&gt;. There is no special-casing of &lt;code&gt;NULL&lt;&#x2F;code&gt; anywhere in this function, and there does not need to be: the three-valued behavior is an emergent property of having chosen &lt;code&gt;AND&lt;&#x2F;code&gt;. The parser does the obvious, correct thing, and the &lt;code&gt;NULL&lt;&#x2F;code&gt; semantics fall straight out of standard boolean logic. The &quot;bug&quot;, if you insist on the word, belongs to the SQL standard, which Postgres implements faithfully.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;a-grammar-asymmetry-list-vs-subquery&quot;&gt;A grammar asymmetry: list vs. subquery&lt;a class=&quot;zola-anchor&quot; href=&quot;#a-grammar-asymmetry-list-vs-subquery&quot; aria-label=&quot;Anchor link for: a-grammar-asymmetry-list-vs-subquery&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;A list and a subquery are built differently. The list form is the &lt;code&gt;&amp;lt;&amp;gt; ALL&lt;&#x2F;code&gt; chain of inequalities you just saw. The subquery form is not a &lt;code&gt;&amp;lt;&amp;gt; ALL&lt;&#x2F;code&gt; at all: it becomes &lt;code&gt;NOT (foo = ANY (subquery))&lt;&#x2F;code&gt;. Different shapes, same truth table, a &lt;code&gt;NULL&lt;&#x2F;code&gt; in the comparison makes the result &lt;code&gt;unknown&lt;&#x2F;code&gt;, and &lt;code&gt;unknown&lt;&#x2F;code&gt; loses. That &lt;code&gt;NOT (... = ANY ...)&lt;&#x2F;code&gt; shape is the one the planner sees.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-the-planner-won-t-save-you&quot;&gt;Why the planner won&#x27;t save you&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-the-planner-won-t-save-you&quot; aria-label=&quot;Anchor link for: why-the-planner-won-t-save-you&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The correctness problem has a plan-shape twin. Watch what the planner does with three sibling queries on a larger schema of 200,000 &lt;code&gt;orders&lt;&#x2F;code&gt; and 1,000 &lt;code&gt;vip&lt;&#x2F;code&gt; rows.&lt;&#x2F;p&gt;
&lt;p&gt;First, the positive case, &lt;code&gt;IN&lt;&#x2F;code&gt; against a subquery:&lt;&#x2F;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 (COSTS &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OFF&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; vip);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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; Hash Semi Join&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Hash Cond: (orders.customer_id = vip.customer_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Seq Scan on orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Hash&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Seq Scan on vip&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A clean &lt;strong&gt;semi-join&lt;&#x2F;strong&gt;. The planner promotes the subquery to a first-class relation, picks a hash join, and is free to reorder it against the rest of the query. Now the natural mirror, orders whose customer is &lt;em&gt;not&lt;&#x2F;em&gt; a VIP, written first with &lt;code&gt;NOT EXISTS&lt;&#x2F;code&gt;:&lt;&#x2F;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 (COSTS &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OFF&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders o&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE NOT EXISTS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;&#x2F;span&gt;&lt;span&gt; vip v &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; v&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                  QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Hash Anti Join&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Hash Cond: (o.customer_id = v.customer_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Seq Scan on orders o&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Hash&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Seq Scan on vip v&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A &lt;strong&gt;hash anti-join&lt;&#x2F;strong&gt;, the efficient and symmetric counterpart to the semi-join. Now the same intent expressed with &lt;code&gt;NOT IN&lt;&#x2F;code&gt;:&lt;&#x2F;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 (COSTS &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OFF&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; vip);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                          QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Seq Scan on orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (NOT (ANY (customer_id = (hashed SubPlan 1).col1)))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   SubPlan 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Seq Scan on vip&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No join at all. The subquery collapses into an opaque &lt;code&gt;SubPlan&lt;&#x2F;code&gt; filter bolted onto a sequential scan, the &lt;code&gt;NOT (... = ANY ...)&lt;&#x2F;code&gt; shape from the grammar is right there in the filter.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The wording of that filter line is version-specific. PostgreSQL 16 prints the terser &lt;code&gt;Filter: (NOT (hashed SubPlan 1))&lt;&#x2F;code&gt;; a later version changed &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; to expose the inner comparison, which is the &lt;code&gt;NOT (ANY (... = (hashed SubPlan 1).col1))&lt;&#x2F;code&gt; form shown here on 18.4. The plan underneath is the same opaque subplan in every released version.&lt;&#x2F;div&gt;
&lt;p&gt;The difference between those last two plans is structural, not a tuning detail. A &lt;code&gt;Hash Anti Join&lt;&#x2F;code&gt; builds one hash table from the inner relation and streams the outer relation through it exactly once, and when the inner side outgrows &lt;code&gt;work_mem&lt;&#x2F;code&gt; it partitions into batches and spills to disk without changing the result. A &lt;code&gt;SubPlan&lt;&#x2F;code&gt; gives the planner none of that. Even the &lt;code&gt;hashed&lt;&#x2F;code&gt; variant you see here is evaluated in place as a filter on the scan: it cannot be reordered during the global join search, it cannot have the outer query&#x27;s join clauses pushed into it to drive an index scan, and it has no multi-batch spill logic to fall back on if the hashed set turns out larger than the estimate. The subquery stops being a relation the optimizer can plan and becomes an opaque function the optimizer has to call, once per outer row. This is the same optimization-fence effect as &lt;a href=&quot;&#x2F;posts&#x2F;good-cte-bad-cte&#x2F;&quot;&gt;an uninlined CTE&lt;&#x2F;a&gt;: the planner simply cannot see inside. On small inputs nobody notices; on large ones it is the difference between milliseconds and minutes.&lt;&#x2F;p&gt;
&lt;p&gt;The reason the planner is stuck with that shape is precisely the &lt;code&gt;NULL&lt;&#x2F;code&gt; semantics from earlier. An anti-join keeps a row when it finds &lt;em&gt;no match&lt;&#x2F;em&gt;. But &lt;code&gt;NOT IN&lt;&#x2F;code&gt; must &lt;em&gt;discard&lt;&#x2F;em&gt; a row when the comparison goes &lt;code&gt;unknown&lt;&#x2F;code&gt;, and &quot;unknown&quot; is not the same as &quot;no match&quot;. Those two behaviors diverge exactly when a &lt;code&gt;NULL&lt;&#x2F;code&gt; is in play, so the planner historically could not prove the rewrite safe and never attempted it.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;&lt;strong&gt;Declaring the columns &lt;code&gt;NOT NULL&lt;&#x2F;code&gt; does not rescue you on released Postgres.&lt;&#x2F;strong&gt; I constrained both the outer and inner columns &lt;code&gt;NOT NULL&lt;&#x2F;code&gt; on 18.4 and re-ran the query, and the plan was still the opaque &lt;code&gt;SubPlan&lt;&#x2F;code&gt; filter. The released planner does not even look. So &lt;code&gt;NOT IN (subquery)&lt;&#x2F;code&gt; has been a correctness trap for as long as Postgres has existed, and a planner pessimization ever since anti-joins arrived in 8.4 (2009) and every other negated subquery learned to use them — neither of which the released optimizer rescues you from.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;the-fix-is-landing-in-postgresql-19&quot;&gt;The fix is landing in PostgreSQL 19&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-fix-is-landing-in-postgresql-19&quot; aria-label=&quot;Anchor link for: the-fix-is-landing-in-postgresql-19&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;That last paragraph is finally going out of date. This is a performance fix, not a semantic one: it rescues the case that was already &lt;em&gt;correct&lt;&#x2F;em&gt; but badly &lt;em&gt;planned&lt;&#x2F;em&gt; (&lt;code&gt;NOT NULL&lt;&#x2F;code&gt; columns stuck behind the opaque &lt;code&gt;SubPlan&lt;&#x2F;code&gt; from the previous section) and leaves nullable columns exactly where the SQL standard puts them. Tracing this through a checkout of the development branch, I found a function that exists in no released Postgres: &lt;code&gt;sublink_testexpr_is_not_nullable&lt;&#x2F;code&gt;, in &lt;code&gt;src&#x2F;backend&#x2F;optimizer&#x2F;plan&#x2F;subselect.c&lt;&#x2F;code&gt;. It guards a brand-new branch inside &lt;code&gt;convert_ANY_sublink_to_join&lt;&#x2F;code&gt;:&lt;&#x2F;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: #6A737D;&quot;&gt;&#x2F;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * Per SQL spec, NOT IN is not ordinarily equivalent to an anti-join, so&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * that by default we have to fail when under_not.  However, if we can&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * prove that neither the outer query&amp;#39;s expressions nor the sub-select&amp;#39;s&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * output columns can be NULL, and further that the operator itself cannot&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * return NULL for non-null inputs, then the logic is identical and it&amp;#39;s&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * safe to convert NOT IN to an anti-join.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; *&#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;if&lt;&#x2F;span&gt;&lt;span&gt; (under_not &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;amp;&amp;amp;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;!&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;sublink_testexpr_is_not_nullable&lt;&#x2F;span&gt;&lt;span&gt;(root, sublink)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;     !&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;query_outputs_are_not_nullable&lt;&#x2F;span&gt;&lt;span&gt;(subselect)))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    return&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;git blame&lt;&#x2F;code&gt; dates it to commit &lt;code&gt;383eb21ebff&lt;&#x2F;code&gt;, &lt;em&gt;&quot;Convert NOT IN sublinks to anti-joins when safe&quot;&lt;&#x2F;em&gt;, merged in March 2026 by Richard Guo. It lives on &lt;code&gt;master&lt;&#x2F;code&gt;, bound for &lt;strong&gt;PostgreSQL 19&lt;&#x2F;strong&gt;. It appears in no &lt;code&gt;REL_18&lt;&#x2F;code&gt; tag, which is exactly why my 18.4 box still produced the &lt;code&gt;SubPlan&lt;&#x2F;code&gt; even with &lt;code&gt;NOT NULL&lt;&#x2F;code&gt; columns. The commit message states the bargain plainly:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;if we can prove that neither side of the comparison can yield NULL values, and further that the operator itself cannot return NULL for non-null inputs, the behavior of NOT IN and anti-join becomes identical.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The proof has to establish three things:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Both operands provably non-&lt;code&gt;NULL&lt;&#x2F;code&gt;.&lt;&#x2F;strong&gt; Established from schema &lt;code&gt;NOT NULL&lt;&#x2F;code&gt; constraints (via a NOT-NULL-attnums hash table), from outer-join nullability tracking (so a &lt;code&gt;Var&lt;&#x2F;code&gt; from the nullable side of an outer join does not qualify), and from qual clauses that force a &lt;code&gt;Var&lt;&#x2F;code&gt; non-null.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;The subquery&#x27;s output columns provably non-&lt;code&gt;NULL&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;, the &lt;code&gt;query_outputs_are_not_nullable&lt;&#x2F;code&gt; half of the guard.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;NULL&lt;&#x2F;code&gt;-safe operator.&lt;&#x2F;strong&gt; The operator must belong to a B-tree or Hash operator family. That is a proxy for &quot;behaves like a normal boolean comparison and won&#x27;t return &lt;code&gt;NULL&lt;&#x2F;code&gt; on non-null inputs&quot;, because an operator that &lt;em&gt;did&lt;&#x2F;em&gt; return &lt;code&gt;NULL&lt;&#x2F;code&gt; there would break the very index it claims to support.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;When all three hold, the &lt;code&gt;NULL&lt;&#x2F;code&gt;-handling mismatch evaporates and &lt;code&gt;NOT IN&lt;&#x2F;code&gt; is finally allowed to become a &lt;code&gt;JOIN_ANTI&lt;&#x2F;code&gt;:&lt;&#x2F;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&gt;result&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt;jointype &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; under_not &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;?&lt;&#x2F;span&gt;&lt;span&gt; JOIN_ANTI &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;:&lt;&#x2F;span&gt;&lt;span&gt; JOIN_SEMI;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It lifts a planner limitation that has stood since anti-joins themselves arrived in 8.4, but only when the planner can &lt;em&gt;prove&lt;&#x2F;em&gt; no &lt;code&gt;NULL&lt;&#x2F;code&gt; can reach the comparison. If your column is nullable, you are exactly where you have always been, in PostgreSQL 19 as in 9.x. The semantics never changed; the optimizer merely learned to recognize the cases where &lt;code&gt;NULL&lt;&#x2F;code&gt; is provably absent.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;decision-matrix&quot;&gt;Decision matrix&lt;a class=&quot;zola-anchor&quot; href=&quot;#decision-matrix&quot; aria-label=&quot;Anchor link for: decision-matrix&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;You wrote&lt;&#x2F;th&gt;&lt;th&gt;Internal shape&lt;&#x2F;th&gt;&lt;th&gt;NULL on right →&lt;&#x2F;th&gt;&lt;th&gt;NULL on left →&lt;&#x2F;th&gt;&lt;th&gt;Planner (≤ PG 18)&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;IN (1,2,3)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;= ANY&lt;&#x2F;code&gt;, an &lt;code&gt;OR&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;absorbed, no harm&lt;&#x2F;td&gt;&lt;td&gt;row dropped (no match)&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;ScalarArrayOpExpr&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NOT IN (1,2,3)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;&amp;gt; ALL&lt;&#x2F;code&gt;, an &lt;code&gt;AND&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;all rows dropped&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;row dropped&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;ScalarArrayOpExpr&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;IN (subquery)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;= ANY&lt;&#x2F;code&gt; sublink&lt;&#x2F;td&gt;&lt;td&gt;absorbed&lt;&#x2F;td&gt;&lt;td&gt;row dropped&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;Semi Join&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NOT IN (subquery)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;NOT (= ANY)&lt;&#x2F;code&gt; sublink&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;all rows dropped&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;row dropped&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;opaque &lt;code&gt;SubPlan&lt;&#x2F;code&gt; filter ¹&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NOT EXISTS (...)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;anti-join sublink&lt;&#x2F;td&gt;&lt;td&gt;row kept&lt;&#x2F;td&gt;&lt;td&gt;row kept&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;Anti Join&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;¹ PostgreSQL 19 promotes this to an Anti Join when both the outer expression and the subquery output column are provably &lt;code&gt;NOT NULL&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-to-do-instead&quot;&gt;What to do instead&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-to-do-instead&quot; aria-label=&quot;Anchor link for: what-to-do-instead&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Don&#x27;t wait for PostgreSQL 19, and don&#x27;t lean on it once it ships: it only fires on provably &lt;code&gt;NOT NULL&lt;&#x2F;code&gt; columns. Every portable fix comes down to one rule: keep &lt;code&gt;NULL&lt;&#x2F;code&gt; away from &lt;code&gt;NOT IN&lt;&#x2F;code&gt;. In practice that means changing the query, and there are three moves, in order of preference.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Default to &lt;code&gt;NOT EXISTS&lt;&#x2F;code&gt;.&lt;&#x2F;strong&gt; Make this the habit and you never hit the trap again. Same anti-join semantics and the same plan as the working &lt;code&gt;NOT IN&lt;&#x2F;code&gt; would have wanted, on every version, nullable columns or not:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; id, category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; products p&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE NOT EXISTS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;&#x2F;span&gt;&lt;span&gt; archived a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;category_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; p&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;category_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It keeps product 3, the &lt;code&gt;NULL&lt;&#x2F;code&gt; category, because &lt;code&gt;NOT EXISTS&lt;&#x2F;code&gt; asks whether a matching archived row exists, not whether &lt;code&gt;category_id = NULL&lt;&#x2F;code&gt; is true. That is usually the answer you wanted. If you do want the &lt;code&gt;NULL&lt;&#x2F;code&gt;-category rows gone, add &lt;code&gt;AND p.category_id IS NOT NULL&lt;&#x2F;code&gt; and you have decided it on purpose.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Filter the &lt;code&gt;NULL&lt;&#x2F;code&gt;s out of the subquery&lt;&#x2F;strong&gt; when you have to keep the &lt;code&gt;NOT IN&lt;&#x2F;code&gt; (a generated query you can only edit inside the parentheses, say):&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; products&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; archived &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This covers the right-hand &lt;code&gt;NULL&lt;&#x2F;code&gt; and nothing else: product 3 still disappears, because the left-hand &lt;code&gt;NULL&lt;&#x2F;code&gt; is untouched. Use it only when you cannot reach for &lt;code&gt;NOT EXISTS&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;EXCEPT&lt;&#x2F;code&gt; for whole-set comparisons.&lt;&#x2F;strong&gt; It matches rows with &lt;code&gt;IS NOT DISTINCT FROM&lt;&#x2F;code&gt;, so two &lt;code&gt;NULL&lt;&#x2F;code&gt;s count as equal and the three-valued trap never fires. But that same rule means a &lt;code&gt;NULL&lt;&#x2F;code&gt; in &lt;code&gt;archived&lt;&#x2F;code&gt; removes the &lt;code&gt;NULL&lt;&#x2F;code&gt; rows from &lt;code&gt;products&lt;&#x2F;code&gt;:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; products&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;EXCEPT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; archived;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- returns: {10}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- product 3 (NULL category) is dropped because archived also has a NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;So &lt;code&gt;NOT EXISTS&lt;&#x2F;code&gt; keeps product 3 and &lt;code&gt;EXCEPT&lt;&#x2F;code&gt; drops it. Pick the one whose answer matches what you&#x27;re after.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;EDIT (2026-06-15): I originally said this stood for about 25 years, which mixed up two things. The correctness trap is as old as &lt;code&gt;NOT IN&lt;&#x2F;code&gt;; the planner pessimization only goes back to 8.4 (2009), when anti-joins arrived. Reworded to keep the two separate.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>pg_stat_statements: everything it can&#x27;t</title>
        <published>2026-06-03T08:35:00+00:00</published>
        <updated>2026-06-03T08:35:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/pg-stat-statements-part-2/"/>
        <id>https://boringsql.com/posts/pg-stat-statements-part-2/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/pg-stat-statements-part-2/">&lt;p&gt;&lt;a href=&quot;&#x2F;posts&#x2F;pg-stat-statements&#x2F;&quot;&gt;Part one&lt;&#x2F;a&gt; made the core case: &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; counts, it doesn&#x27;t record. It walked through how the queryid jumble fragments one logical query into many rows, how the first-seen text freezes your per-request tags, and how the averages bury the p99 that actually pages you. All of that was about data the extension &lt;em&gt;has&lt;&#x2F;em&gt; and distorts.&lt;&#x2F;p&gt;
&lt;p&gt;This part is about the rest: the entries it silently throws away, the query text that can vanish all at once, the plans and replicas it never records, and the knobs that bite. It ends where part one started, with the question the whole investigation was really about: is this the query store Postgres is missing, or just the floor you&#x27;d build one on?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-table-fills-up-and-evicts-your-tail&quot;&gt;The table fills up and evicts your tail&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-table-fills-up-and-evicts-your-tail&quot; aria-label=&quot;Anchor link for: the-table-fills-up-and-evicts-your-tail&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt; defaults to 5000. It&#x27;s a hard cap on entries, set when the server starts (changing it needs a restart, because the hash table is sized in shared memory up front). When the 5001st distinct shape arrives, Postgres doesn&#x27;t grow the table. It evicts, throwing out the least-executed entries to make room:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;If more distinct statements than that are observed, information about the least-executed statements is discarded.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;On a healthy app with a few hundred steady shapes, 5000 is plenty and you never think about it. But remember the &lt;a href=&quot;&#x2F;posts&#x2F;pg-stat-statements&#x2F;&quot;&gt;row explosion&lt;&#x2F;a&gt; from part one. An ORM that splinters one query into hundreds of shapes, or a pre-18 app building dynamic &lt;code&gt;IN&lt;&#x2F;code&gt; lists, can chew through thousands of entries an hour. Once that starts, the view becomes a sliding window over recent noise. Your steady, important queries get evicted to make room for thousands of one-offs, then rebuilt with fresh counters and a fresh first-seen text when they run again. The stats you were trusting reset themselves, and the view never says a word about it.&lt;&#x2F;p&gt;
&lt;p&gt;One place does say something. The companion view &lt;code&gt;pg_stat_statements_info&lt;&#x2F;code&gt; has exactly two columns, and both matter:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; dealloc, stats_reset &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_statements_info;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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; dealloc |          stats_reset&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------+-------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   18342 | 2026-05-01 03:00:11.482+00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;dealloc&lt;&#x2F;code&gt;: Total number of times &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; entries about the least-executed statements were deallocated because more distinct statements than &lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt; were observed.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;When &lt;code&gt;dealloc&lt;&#x2F;code&gt; climbs steadily between resets, your table is too small for how many shapes your workload produces. You&#x27;re thrashing, and your stats are lossy. The fix is either to raise &lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt; and pay the shared memory, or to cut the cardinality at the source by normalizing shapes, moving to PG 18, or switching to array binding. Few people watch &lt;code&gt;dealloc&lt;&#x2F;code&gt;, so a thrashing table quietly drops part of the workload it&#x27;s meant to report, and nobody notices until the numbers stop adding up.&lt;&#x2F;p&gt;
&lt;p&gt;The second column, &lt;code&gt;stats_reset&lt;&#x2F;code&gt;, is the time of the last global reset of the whole view, and it isn&#x27;t decoration. It&#x27;s the validity stamp on every diff you compute. If you snapshot the view hourly to get usable averages, &lt;code&gt;stats_reset&lt;&#x2F;code&gt; tells you whether a given window is even legitimate: if it moved forward between your two snapshots, a reset happened inside the window and every delta across it is meaningless.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Watch &lt;code&gt;dealloc&lt;&#x2F;code&gt; and &lt;code&gt;stats_reset&lt;&#x2F;code&gt;.&lt;&#x2F;strong&gt;&lt;br&gt;
A steadily climbing &lt;code&gt;dealloc&lt;&#x2F;code&gt; means the table is too small and your tail is being evicted, so the stats are lossy. A &lt;code&gt;stats_reset&lt;&#x2F;code&gt; that moved between two snapshots means a reset landed inside your window, so any diff across it is meaningless. Almost no monitoring setup checks either.
&lt;&#x2F;div&gt;
&lt;h3 id=&quot;when-the-query-text-vanishes&quot;&gt;When the query text vanishes&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-the-query-text-vanishes&quot; aria-label=&quot;Anchor link for: when-the-query-text-vanishes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;There&#x27;s a worse failure mode hiding in the external query-text file. Because representative texts pile up on disk and the file can grow large (long queries, a high &lt;code&gt;max&lt;&#x2F;code&gt;, lots of churn), the extension has a last resort:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; may choose to discard the query texts, whereupon all existing entries in the &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; view will show null &lt;code&gt;query&lt;&#x2F;code&gt; fields, though the statistics associated with each &lt;code&gt;queryid&lt;&#x2F;code&gt; are preserved. If this happens, consider reducing &lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;So under pressure it keeps the counters and throws away the text. Your whole view goes &lt;code&gt;query = NULL&lt;&#x2F;code&gt; while the numbers keep marching on, and you&#x27;re left with a list of anonymous queryids and no idea what they used to be. The docs leave this vague, but the source is specific enough to alert on. Two thresholds are at work.&lt;&#x2F;p&gt;
&lt;p&gt;The first is the garbage-collection trigger. As entries get evicted and rebuilt, their old texts leave dead space in the file. The extension rewrites the file to drop that dead space once it&#x27;s more than about 50% bloat, specifically when the file on disk grows past &lt;code&gt;mean_query_len × pg_stat_statements.max × 2&lt;&#x2F;code&gt;. With the default &lt;code&gt;max&lt;&#x2F;code&gt; of 5000 and, say, a 1 KB average query, that&#x27;s a rewrite around the 10 MB mark. That part is normal housekeeping. It&#x27;s not the failure.&lt;&#x2F;p&gt;
&lt;p&gt;The failure is when that rewrite itself fails, from an I&#x2F;O error or because it can&#x27;t allocate room to build the replacement file. At that point the file&#x27;s contents can&#x27;t be trusted, so the extension marks every entry&#x27;s text as invalid (internally, &lt;code&gt;query_len = -1&lt;&#x2F;code&gt;) and the whole &lt;code&gt;query&lt;&#x2F;code&gt; column reads &lt;code&gt;NULL&lt;&#x2F;code&gt;. The counters survive. The texts are gone all at once.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;On PostgreSQL 13 and earlier, &lt;code&gt;stats_temp_directory&lt;&#x2F;code&gt; could relocate that file; the GUC was removed in 14.&lt;&#x2F;div&gt;
&lt;p&gt;The practical version: the file lives at &lt;code&gt;$PGDATA&#x2F;pg_stat_tmp&#x2F;pgss_query_texts.stat&lt;&#x2F;code&gt;. Alert on its size against &lt;code&gt;mean_query_len × max × 2&lt;&#x2F;code&gt;. If you&#x27;re routinely near that line you&#x27;re rewriting the file constantly, and you&#x27;re one bad &lt;code&gt;write()&lt;&#x2F;code&gt; away from a blank view. Because the trigger scales with your average query length, the cluster most at risk is the one with both lots of entries and very long query texts, which is the giant generated &lt;code&gt;IN&lt;&#x2F;code&gt;-list app all over again. The fix the manual suggests, lowering &lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt;, sounds backwards but is right: a smaller table means a smaller file, less bloat to collect, and less to go wrong.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-never-records-at-all&quot;&gt;What it never records at all&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-it-never-records-at-all&quot; aria-label=&quot;Anchor link for: what-it-never-records-at-all&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Everything so far has been about data the extension records but degrades, by normalizing, freezing, averaging, or evicting it. This section is about absence: things &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; never captures at all, because they sit outside what it models.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Plans.&lt;&#x2F;strong&gt; This is the big one. The extension records that a query ran and how long it took. It doesn&#x27;t record &lt;em&gt;how&lt;&#x2F;em&gt; it ran. No plan tree, no per-node costs, no join order, no index choice. A query whose plan flipped from an Index Scan to a Seq Scan overnight, the single most common cause of a sudden slowdown, shows up only as a &lt;code&gt;mean_exec_time&lt;&#x2F;code&gt; that went up. The reason is gone. For plans you need &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt;, &lt;code&gt;auto_explain&lt;&#x2F;code&gt;, or a plan-capturing hook. &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; sits at the wrong layer to ever see one.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Actual versus estimated rows.&lt;&#x2F;strong&gt; With no plan there&#x27;s no per-node &lt;code&gt;actual_rows&lt;&#x2F;code&gt; to compare against the planner&#x27;s estimate. The estimation error, which is the leading sign of &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;&quot;&gt;stale statistics&lt;&#x2F;a&gt; and the thing that predicts a future plan flip, is invisible here. The &lt;code&gt;rows&lt;&#x2F;code&gt; column is total rows returned across all calls, not an estimate measured against reality.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Parameter values.&lt;&#x2F;strong&gt; The constants were jumbled out. You can&#x27;t recover which &lt;code&gt;id&lt;&#x2F;code&gt; was slow, only that the shape &lt;code&gt;WHERE id = $1&lt;&#x2F;code&gt; was slow on average.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Failed queries.&lt;&#x2F;strong&gt; This one surprises people. Stats are updated at the end of a successful phase, and only then. A query that errors out mid-execution isn&#x27;t counted. The manual lays out the asymmetry exactly:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;plans&lt;&#x2F;code&gt; and &lt;code&gt;calls&lt;&#x2F;code&gt; aren&#x27;t always expected to match because planning and execution statistics are updated at their respective end phase, and only for successful operations. For example, if a statement is successfully planned but fails during the execution phase, only its planning statistics will be updated.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;So the query that runs 30 seconds and then dies on a statement timeout, exactly the one you most want to find, contributes nothing to &lt;code&gt;calls&lt;&#x2F;code&gt; or &lt;code&gt;total_exec_time&lt;&#x2F;code&gt;. It planned, so it might bump &lt;code&gt;plans&lt;&#x2F;code&gt; if &lt;code&gt;track_planning&lt;&#x2F;code&gt; is on, and that&#x27;s the only trace it leaves. Some of your worst queries are invisible precisely because they&#x27;re your worst queries.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;There&#x27;s no error counter either. A statement that fails leaves no row of its own, so you can&#x27;t ask &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; how often a given shape errors, only how often it succeeded.&lt;&#x2F;div&gt;
&lt;p&gt;&lt;strong&gt;Spills, parallel workers, per-node buffers.&lt;&#x2F;strong&gt; Sort and hash spills to disk, &lt;code&gt;workers_planned&lt;&#x2F;code&gt; versus &lt;code&gt;workers_launched&lt;&#x2F;code&gt;, the shared&#x2F;local&#x2F;temp buffer split per plan node, all of that lives in &lt;a href=&quot;&#x2F;posts&#x2F;explain-buffers&#x2F;&quot;&gt;&lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; output, not in the aggregated &lt;code&gt;shared_blks_*&lt;&#x2F;code&gt; totals. The view gives you a sum of blocks touched. It can&#x27;t tell you where in the plan they were touched, or whether a sort spilled.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;it-only-sees-one-node&quot;&gt;It only sees one node&lt;a class=&quot;zola-anchor&quot; href=&quot;#it-only-sees-one-node&quot; aria-label=&quot;Anchor link for: it-only-sees-one-node&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; is per-instance. Its hash table lives in this server&#x27;s shared memory and counts the queries this server ran. It has no idea other nodes exist.&lt;&#x2F;p&gt;
&lt;p&gt;In a single-primary world that was fine. In a modern deployment (a primary with read replicas, an Aurora reader fleet, a Patroni cluster, Citus) it&#x27;s a serious blind spot. Read traffic is the majority of the work for most apps, and it runs on the replicas. Each replica has its own independent &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt;, with its own counters, its own eviction, its own first-seen texts. Snapshot only the primary, which is what most monitoring setups do, and you see the write path plus whatever reads happen to land on the primary, while the bulk of the read workload goes unmeasured.&lt;&#x2F;p&gt;
&lt;p&gt;Merging the nodes isn&#x27;t clean either. You&#x27;d want to add up &lt;code&gt;calls&lt;&#x2F;code&gt; for &quot;the same query&quot; across nodes, but the queryid is platform- and version-sensitive, so on a mixed fleet (mid-upgrade, different architectures) the same query can carry different ids on different nodes. Merging a cluster&#x27;s workload under one canonical fingerprint, with per-node drill-down, is real work that lives above the extension. The extension only ever hands you one node&#x27;s local tally.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-knobs-and-what-they-cost&quot;&gt;The knobs, and what they cost&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-knobs-and-what-they-cost&quot; aria-label=&quot;Anchor link for: the-knobs-and-what-they-cost&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;A quick tour of the settings, because the defaults hide a few traps.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;GUC&lt;&#x2F;th&gt;&lt;th&gt;Default&lt;&#x2F;th&gt;&lt;th&gt;What to know&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;5000&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Hard cap on entries; restart to change. Watch &lt;code&gt;pg_stat_statements_info.dealloc&lt;&#x2F;code&gt;.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pg_stat_statements.track&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;top&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;top&lt;&#x2F;code&gt; tracks client-issued statements only. Queries &lt;em&gt;inside&lt;&#x2F;em&gt; PL&#x2F;pgSQL functions stay invisible until you set &lt;code&gt;all&lt;&#x2F;code&gt;.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pg_stat_statements.track_utility&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;on&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Counts non-DML (&lt;code&gt;CREATE&lt;&#x2F;code&gt;, &lt;code&gt;VACUUM&lt;&#x2F;code&gt;, and so on). Can flood the table with one-off DDL on schema-churning systems.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pg_stat_statements.track_planning&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;off&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Adds planning-time stats, but the manual warns of &quot;a noticeable performance penalty&quot; from contention when many connections run identical shapes and fight over one entry&#x27;s spinlock. Off for a reason.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pg_stat_statements.save&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;on&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Persists stats across clean restarts. A &lt;em&gt;crash&lt;&#x2F;em&gt; still loses everything since the last save.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Three that aren&#x27;t obvious from the table:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;track = &#x27;top&#x27;&lt;&#x2F;code&gt; hides function internals.&lt;&#x2F;strong&gt; If your hot logic lives in PL&#x2F;pgSQL or stored procedures, the default shows you the &lt;code&gt;SELECT do_the_thing()&lt;&#x2F;code&gt; wrapper and nothing about the queries it runs inside. You have to opt into &lt;code&gt;all&lt;&#x2F;code&gt; and pay for the extra entries.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;track_utility&lt;&#x2F;code&gt; counts your transaction verbs.&lt;&#x2F;strong&gt; &lt;code&gt;BEGIN&lt;&#x2F;code&gt;, &lt;code&gt;COMMIT&lt;&#x2F;code&gt;, and &lt;code&gt;ROLLBACK&lt;&#x2F;code&gt; are utility statements, so with the default &lt;code&gt;track_utility = on&lt;&#x2F;code&gt; each one gets its own entry. They normalize to a single shape apiece, so it&#x27;s a handful of rows, not a flood, but an ORM that opens and closes a transaction around every query turns &lt;code&gt;COMMIT&lt;&#x2F;code&gt; into one of your highest-&lt;code&gt;calls&lt;&#x2F;code&gt; rows: a content-free verb sitting at the top of any ranking by call count, ahead of the queries you actually want to see. If your top-by-calls view is mostly transaction control, that&#x27;s the signal to turn &lt;code&gt;track_utility&lt;&#x2F;code&gt; off.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;I&#x2F;O timings need a second switch.&lt;&#x2F;strong&gt; The &lt;code&gt;shared_blk_read_time&lt;&#x2F;code&gt; and &lt;code&gt;shared_blk_write_time&lt;&#x2F;code&gt; columns are only populated when the server-wide &lt;code&gt;track_io_timing&lt;&#x2F;code&gt; is on, and it&#x27;s off by default. Leave it off and those columns read zero, making every query look like it did no I&#x2F;O, which is a quietly misleading kind of empty.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why-track-planning-in-particular-hurts&quot;&gt;Why &lt;code&gt;track_planning&lt;&#x2F;code&gt; in particular hurts&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-track-planning-in-particular-hurts&quot; aria-label=&quot;Anchor link for: why-track-planning-in-particular-hurts&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The warning on &lt;code&gt;track_planning&lt;&#x2F;code&gt; is worth unpacking, because the obvious objection is the one the manual seems to skip. If a hot query gets &lt;em&gt;executed&lt;&#x2F;em&gt; by thousands of backends billions of times and that&#x27;s fine, why would &lt;em&gt;planning&lt;&#x2F;em&gt; the same shape be the thing that tips over?&lt;&#x2F;p&gt;
&lt;p&gt;The answer is in how the counters get written. Every entry has its own spinlock (&lt;code&gt;entry-&amp;gt;mutex&lt;&#x2F;code&gt;), and updating the entry means grabbing that lock. The update happens once per phase. The extension writes the entry at the end of planning and again at the end of execution, in two separate critical sections. With &lt;code&gt;track_planning&lt;&#x2F;code&gt; off, a query touches its entry&#x27;s spinlock once per call, at exec-end. With it on, it touches the same lock twice, once at plan-end and once at exec-end. For a shape that lots of connections run at once, that single entry&#x27;s lock is already the hottest in the system, and doubling the grabs roughly doubles the contention on it.&lt;&#x2F;p&gt;
&lt;p&gt;So it isn&#x27;t that planning is contended and execution is free. Execution-side contention on that per-entry lock is itself a well-known bottleneck for ultra-hot identical queries. It&#x27;s that &lt;code&gt;track_planning&lt;&#x2F;code&gt; adds a second grab of the already-hot lock to every call, and piles that extra traffic onto exactly the entries that were most contended to begin with (one shape, run everywhere). That&#x27;s why a setting that sounds harmless, &quot;also record planning time,&quot; ships off by default, and why you should leave it off on any workload dominated by a handful of extremely hot shapes.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;mind-the-column-renames-pg-17&quot;&gt;Mind the column renames (PG 17)&lt;a class=&quot;zola-anchor&quot; href=&quot;#mind-the-column-renames-pg-17&quot; aria-label=&quot;Anchor link for: mind-the-column-renames-pg-17&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;PostgreSQL 17 renamed the I&#x2F;O timing columns.&lt;&#x2F;strong&gt;&lt;br&gt;
&lt;code&gt;blk_read_time&lt;&#x2F;code&gt; became &lt;code&gt;shared_blk_read_time&lt;&#x2F;code&gt; and &lt;code&gt;blk_write_time&lt;&#x2F;code&gt; became &lt;code&gt;shared_blk_write_time&lt;&#x2F;code&gt;, with new &lt;code&gt;local_blk_*&lt;&#x2F;code&gt; columns added alongside. A query selecting the old names errors after the upgrade; a &lt;code&gt;SELECT *&lt;&#x2F;code&gt; dashboard silently shifts. See the &lt;a href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;17&#x2F;release-17.html&quot;&gt;PG 17 release notes&lt;&#x2F;a&gt;.
&lt;&#x2F;div&gt;
&lt;p&gt;If you have dashboards or scrapers older than PostgreSQL 17, they may have broken on the upgrade without saying so. The same release also added the &lt;code&gt;stats_since&lt;&#x2F;code&gt; and &lt;code&gt;minmax_stats_since&lt;&#x2F;code&gt; timestamps and a &lt;code&gt;minmax_only&lt;&#x2F;code&gt; argument to &lt;code&gt;pg_stat_statements_reset()&lt;&#x2F;code&gt;. The view&#x27;s shape isn&#x27;t a stable contract across majors, so pin your column lists and re-check them on every upgrade.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;who-can-see-what&quot;&gt;Who can see what&lt;a class=&quot;zola-anchor&quot; href=&quot;#who-can-see-what&quot; aria-label=&quot;Anchor link for: who-can-see-what&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;One security corner to know before you wire this into a shared dashboard. The statistics are visible to everyone, but the SQL text and queryid of &lt;em&gt;other users&#x27;&lt;&#x2F;em&gt; queries are not:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;For security reasons, only superusers and roles with privileges of the &lt;code&gt;pg_read_all_stats&lt;&#x2F;code&gt; role are allowed to see the SQL text and &lt;code&gt;queryid&lt;&#x2F;code&gt; of queries executed by other users. Other users can see the statistics, however, if the view has been installed in their database.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;So a low-privilege monitoring role sees rows full of &lt;code&gt;&amp;lt;insufficient privilege&amp;gt;&lt;&#x2F;code&gt; where the query text should be. The fix is to grant &lt;code&gt;pg_read_all_stats&lt;&#x2F;code&gt;, a built-in role, to the monitoring user. Not superuser. If your &quot;the dashboard shows no query text&quot; mystery has a boring cause, this is usually it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;so-what-is-it-good-for-and-where-does-it-leave-you&quot;&gt;So what is it good for, and where does it leave you?&lt;a class=&quot;zola-anchor&quot; href=&quot;#so-what-is-it-good-for-and-where-does-it-leave-you&quot; aria-label=&quot;Anchor link for: so-what-is-it-good-for-and-where-does-it-leave-you&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;None of this is an argument against &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt;. It&#x27;s still the first thing to turn on, and for its real job, ranking your query shapes by total time, calls, or I&#x2F;O on one node since the last reset, nothing else is this cheap or this immediate. Reach for it first, every time. Just be clear about what you&#x27;re holding:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;It counts, it doesn&#x27;t record. Cumulative totals, no time dimension. Snapshot and diff it yourself if you want history.&lt;&#x2F;li&gt;
&lt;li&gt;It gives you an average, not a spread. Great for &quot;which shape dominates total time,&quot; useless for &quot;what&#x27;s my p99.&quot;&lt;&#x2F;li&gt;
&lt;li&gt;It&#x27;s shape-fragmented. ORMs explode one query into many entries, and pulling them back together is a job for a normalizer on top.&lt;&#x2F;li&gt;
&lt;li&gt;It keeps first-seen text. Static comment tags survive, dynamic per-request tags don&#x27;t, so it can never be an APM join target.&lt;&#x2F;li&gt;
&lt;li&gt;It&#x27;s lossy under pressure. A full table evicts your tail. Watch &lt;code&gt;dealloc&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;It&#x27;s plan-blind. It tells you a query is slow, never why. No plan, no estimate error, no spills.&lt;&#x2F;li&gt;
&lt;li&gt;It&#x27;s single-node. The replica fleet that serves most of your reads is invisible from the primary.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;So &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; answers one question, &lt;em&gt;what is slow?&lt;&#x2F;em&gt;, and stops. The next questions are outside its model: &lt;em&gt;why&lt;&#x2F;em&gt; it got slow (a plan flip, stats drift), &lt;em&gt;who&lt;&#x2F;em&gt; is calling it, &lt;em&gt;where&lt;&#x2F;em&gt; across the cluster, and &lt;em&gt;what would happen&lt;&#x2F;em&gt; if you changed something. That isn&#x27;t a missing feature; it&#x27;s what a pile of aggregated counters fundamentally can&#x27;t do.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The per-execution detail isn&#x27;t truly gone, it&#x27;s in the logs. &lt;code&gt;log_min_duration_statement = 0&lt;&#x2F;code&gt; or &lt;code&gt;auto_explain&lt;&#x2F;code&gt; records every call with its parameters, tags, and timing, which is where the full distribution and the dynamic tags live. The catch is cost: you can&#x27;t log every query on a busy system, which is the whole reason the cheap aggregate exists.&lt;&#x2F;div&gt;
&lt;p&gt;That answers the question we opened on. Postgres doesn&#x27;t ship the query store you came looking for, and &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; isn&#x27;t it. It&#x27;s the floor you&#x27;d build one on, not the thing itself.&lt;&#x2F;p&gt;
&lt;p&gt;So stop reading &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; as a picture of your workload. Read it for what it is: a cheap, lossy box of counters that tells you where to point the real instruments next.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>pg_stat_statements: everything it tells you</title>
        <published>2026-06-02T22:00:00+00:00</published>
        <updated>2026-06-02T22:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/pg-stat-statements/"/>
        <id>https://boringsql.com/posts/pg-stat-statements/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/pg-stat-statements/">&lt;p&gt;If not first, &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; is one of the most used extensions in the PostgreSQL ecosystem. It ships in &lt;strong&gt;contrib&lt;&#x2F;strong&gt; and costs almost nothing to use. Most of us turn to it to answer the question: &lt;em&gt;what is the database actually doing?&lt;&#x2F;em&gt; It&#x27;s genuinely useful. You can use it to get a snapshot of what happened in a given timeframe, and make a faster decision about what to fix.&lt;&#x2F;p&gt;
&lt;p&gt;Coming from other database engines, you might reach for it expecting something a bit more, a query store. The built-in feature that keeps normalized queries and their plan history. Except &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; is not this. This article is going to deep dive into what the extension really provides.&lt;&#x2F;p&gt;
&lt;p&gt;Because once you lean on it, you might start noticing the rough edges. The gap comes from what &quot;a query store&quot; might come with and what it actually tells you.&lt;&#x2F;p&gt;
&lt;p&gt;The very same query might show up in many separate rows. Discrepancies between what your monitoring says and what &lt;code&gt;mean_exec_time&lt;&#x2F;code&gt; shows. Missing queries. Numbers that changed overnight.&lt;&#x2F;p&gt;
&lt;p&gt;None of that is a bug. It all follows from what &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; really is: a fixed-size hash table of running counters, kept in shared memory, keyed by a hash of your parse tree. It counts; it does not record. Hold that one idea in your head and every surprising thing on the list above stops being surprising.&lt;&#x2F;p&gt;
&lt;p&gt;Everything below is reproducible. Paste this into a scratch database:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customers&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name text NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; customers(id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status text NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; customers (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Customer &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2000&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; i;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders (customer_id, amount, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1999&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[&amp;#39;pending&amp;#39;,&amp;#39;shipped&amp;#39;,&amp;#39;delivered&amp;#39;,&amp;#39;cancelled&amp;#39;])[floor(random()*4+1)::int],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;       &amp;#39;2024-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;date +&lt;&#x2F;span&gt;&lt;span&gt; (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 700&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And turn the extension on:&lt;&#x2F;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;-- in postgresql.conf config&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- shared_preload_libraries = &amp;#39;pg_stat_statements&amp;#39;  (requires restart)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; EXTENSION pg_stat_statements;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;what-it-actually-stores&quot;&gt;What it actually stores&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-it-actually-stores&quot; aria-label=&quot;Anchor link for: what-it-actually-stores&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;We&#x27;ve just turned it on, so start with what it puts in front of you.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_stat_statements;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You get one row per distinct query &lt;em&gt;shape&lt;&#x2F;em&gt; and a set of counters behind it: &lt;code&gt;calls&lt;&#x2F;code&gt;, &lt;code&gt;total_exec_time&lt;&#x2F;code&gt;, &lt;code&gt;rows&lt;&#x2F;code&gt;, &lt;code&gt;shared_blks_hit&lt;&#x2F;code&gt;, &lt;code&gt;shared_blks_read&lt;&#x2F;code&gt;, and a lot more.&lt;&#x2F;p&gt;
&lt;p&gt;While there is a &lt;code&gt;query&lt;&#x2F;code&gt; column, you might think it keeps your SQL. But it does not. If you look carefully query text is normalized, with the literals stripped out (&lt;code&gt;WHERE id = 42&lt;&#x2F;code&gt; is stored as &lt;code&gt;WHERE id = $1&lt;&#x2F;code&gt;), and there is exactly one row per shape no matter how many times it ran. The counters only add up. A statement runs, the counters for its shape tick up, and that individual execution, its values, its timing, its plan, is gone. Each row is an &lt;strong&gt;entry&lt;&#x2F;strong&gt;: a running total per shape, not a record of executions. Most of the limitations in this article follow from that.&lt;&#x2F;p&gt;
&lt;p&gt;First, where the data lives. The counters sit in shared memory allocated at startup, and as we said, it&#x27;s a fixed hash map. The number of tracked statements is capped (&lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt;, default &lt;strong&gt;5000&lt;&#x2F;strong&gt;). The query text lives in a file on disk, &lt;code&gt;$PGDATA&#x2F;pg_stat_tmp&#x2F;pgss_query_texts.stat&lt;&#x2F;code&gt;, which the view joins against the in-memory counters when you read it.&lt;&#x2F;p&gt;
&lt;p&gt;Each entry in that map is keyed by &lt;code&gt;queryid&lt;&#x2F;code&gt;, and that&#x27;s where the trouble starts.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-queryid&quot;&gt;The queryid&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-queryid&quot; aria-label=&quot;Anchor link for: the-queryid&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;div class=&quot;sidenote&quot;&gt;The jumble moved into core in PostgreSQL 14; before that it lived inside &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; itself. It&#x27;s gated by the &lt;code&gt;compute_query_id&lt;&#x2F;code&gt; setting, whose default &lt;code&gt;auto&lt;&#x2F;code&gt; turns it on whenever a module that needs it, such as this one, is loaded.&lt;&#x2F;div&gt;
The `queryid` is computed by Postgres core, not by the extension. When Postgres parses and analyzes a statement, it walks the resulting tree and hashes the parts that define the query&#x27;s structure into single 64-bit number, leaving the constant values out. The Postgres source calls this step the *query jumble*; `pg_stat_statements` just reads the result and uses it as the key for each row.
&lt;p&gt;Leaving the constants out is the whole trick. &lt;code&gt;WHERE id = 42&lt;&#x2F;code&gt; and &lt;code&gt;WHERE id = 99&lt;&#x2F;code&gt; jumble to the same number, with the literal shown as a &lt;code&gt;$n&lt;&#x2F;code&gt; placeholder, so one entry can stand in for millions of executions that differ only in their values.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 42&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 99&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 12345&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; queryid, query, calls&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_statements&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; query &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIKE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;SELECT * FROM orders WHERE id%&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;       queryid        |               query                | calls&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------+------------------------------------+-------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; -8198406948996196422 | SELECT * FROM orders WHERE id = $1 |     3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three executions, one row. Now look at the queryid: it&#x27;s negative. It&#x27;s a signed 64-bit integer, which can easily break a dashboard that stored it in an unsigned column or printed it as a friendly identifier. It&#x27;s a hash, not a sequence number. Treat it as an opaque token that might be negative and move on.&lt;&#x2F;p&gt;
&lt;p&gt;This is also where the surprises start, because the jumble is more sensitive than &quot;it ignores constants&quot; makes it sound.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-queryid-isn-t-stable-across-versions&quot;&gt;The queryid isn&#x27;t stable across versions&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-queryid-isn-t-stable-across-versions&quot; aria-label=&quot;Anchor link for: the-queryid-isn-t-stable-across-versions&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The manual is blunt about it:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;it is not safe to assume that &lt;code&gt;queryid&lt;&#x2F;code&gt; will be stable across major versions of PostgreSQL.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;The hashing process is also sensitive to differences in machine architecture and other facets of the platform.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;If you followed the previous section carefully, you&#x27;ll have spotted that the hash comes from the shape of the parsed tree, not the query text. Upgrade from Postgres 16 to 17, or move to a different CPU architecture, and identical SQL can hash to a different queryid.&lt;&#x2F;p&gt;
&lt;p&gt;Anything that adds up query costs across a fleet by joining on queryid quietly falls apart the moment the fleet isn&#x27;t uniform. The docs call out replicas specifically:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;logical replication schemes do not promise to keep replicas identical in all relevant details, so &lt;code&gt;queryid&lt;&#x2F;code&gt; will not be a useful identifier for accumulating costs across a set of logical replicas.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Don&#x27;t treat &lt;code&gt;queryid&lt;&#x2F;code&gt; as a durable key.&lt;&#x2F;strong&gt;&lt;br&gt;
Within one server it&#x27;s stable across minor versions, and that is the only guarantee. Across a major upgrade, a different CPU architecture, or the replicas in a logical-replication set, the same SQL can hash to a different value. Any dashboard or rollup that joins on &lt;code&gt;queryid&lt;&#x2F;code&gt; across machines is one upgrade away from quietly double-counting or dropping rows.
&lt;&#x2F;div&gt;
&lt;h3 id=&quot;the-queryid-tracks-the-oid-not-the-name&quot;&gt;The queryid tracks the OID, not the name&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-queryid-tracks-the-oid-not-the-name&quot; aria-label=&quot;Anchor link for: the-queryid-tracks-the-oid-not-the-name&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The jumble uses the &lt;strong&gt;OIDs&lt;&#x2F;strong&gt; of objects a query touches, not their names. Two behaviors fall out of that, both straight from the manual, and both catch people off guard:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; will consider two apparently-identical queries to be distinct, if they reference for example a function that was dropped and recreated between the executions of the two queries.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;Conversely, if a table is dropped and recreated between the executions of queries, two apparently-identical queries may be considered the same.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Drop and recreate a table, which happens all the time during migrations, dump-and-restore, or a blue-green cutover, and every query against it gets a fresh queryid, because the table now has a fresh OID. Your old stats for that table are stranded under the old id, looking like queries that simply stopped running. The dead query on your dashboard isn&#x27;t dead; its OID moved.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;&lt;code&gt;search_path&lt;&#x2F;code&gt; hides the same trap. &lt;code&gt;SELECT * FROM orders&lt;&#x2F;code&gt; resolves to &lt;code&gt;tenant_a.orders&lt;&#x2F;code&gt; on one connection and &lt;code&gt;tenant_b.orders&lt;&#x2F;code&gt; on another, so a schema-per-tenant app scatters one logical query across every tenant&#x27;s OID. Correct behaviour, but rolling up &quot;all the reads against &lt;code&gt;orders&lt;&#x2F;code&gt;&quot; means resolving OIDs yourself.&lt;&#x2F;div&gt;
&lt;h3 id=&quot;aliases-and-structure-leak-in-too&quot;&gt;Aliases and structure leak in too&lt;a class=&quot;zola-anchor&quot; href=&quot;#aliases-and-structure-leak-in-too&quot; aria-label=&quot;Anchor link for: aliases-and-structure-leak-in-too&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;blockquote&gt;
&lt;p&gt;if the alias for a table is different for otherwise-similar queries, these queries will be considered distinct.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That last one, alias sensitivity, is what hurts ORM users most.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-orm-row-explosion&quot;&gt;The ORM row explosion&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-orm-row-explosion&quot; aria-label=&quot;Anchor link for: the-orm-row-explosion&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The jumble normalizes constants. It does not normalize &lt;em&gt;structure&lt;&#x2F;em&gt;. Two queries you&#x27;d read as &quot;the same query&quot; but that differ in the parsed tree, say a different column list, a table alias, an extra &lt;code&gt;LIMIT&lt;&#x2F;code&gt;, or a reordered &lt;code&gt;AND&lt;&#x2F;code&gt;, are different shapes with different queryids in different rows.&lt;&#x2F;p&gt;
&lt;p&gt;ORMs generate these variations constantly. Here are four queries an ActiveRecord, Hibernate, or Ecto app might emit for what is really one operation, &quot;load a customer by id&quot;:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name FROM&lt;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;;             &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- queryid A&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;; &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- queryid B  (.select added a column)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;&#x2F;span&gt;&lt;span&gt; customers c &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;;     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- queryid C  (aliased)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name FROM&lt;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; LIMIT&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;;    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- queryid D  (.first)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Four rows. The calls split four ways, and each one looks minor even when the real operation behind them might be your single hottest path. The &lt;code&gt;mean_exec_time&lt;&#x2F;code&gt; is per-row, so a slowdown that hits all four equally gets diluted across all four.&lt;&#x2F;p&gt;
&lt;p&gt;ORMs don&#x27;t stop at four. Every conditional &lt;code&gt;.where&lt;&#x2F;code&gt;, every optional &lt;code&gt;.select&lt;&#x2F;code&gt;, every eager-load is another shape; one logical query spreads across dozens or hundreds of entries. You can see real production snapshots where a single logical query lands under &lt;strong&gt;hundreds&lt;&#x2F;strong&gt; of distinct queryids, each looking minor on its own.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;This is the job of a tool like &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringsql&#x2F;qshape&quot;&gt;qshape&lt;&#x2F;a&gt;, part of the boringSQL stack.&lt;&#x2F;div&gt;
&lt;p&gt;The extension can&#x27;t fix this, and it isn&#x27;t supposed to. The jumble runs before anything understands that &quot;these read different columns from the same table for the same reason.&quot; Clustering the variants back together means re-parsing each entry&#x27;s text, canonicalizing the tree (strip aliases, sort &lt;code&gt;AND&lt;&#x2F;code&gt; predicates, spot column-set supersets), and re-fingerprinting. &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; hands you the raw, fragmented shapes; reassembling them into logical queries is a layer it doesn&#x27;t have.&lt;&#x2F;p&gt;
&lt;p&gt;A downstream re-parse can work because normalization only looks like it threw the information away. The jumble strips one thing, the constant values (&lt;code&gt;= 42&lt;&#x2F;code&gt; becomes &lt;code&gt;= $1&lt;&#x2F;code&gt;), and leaves all the structure in the &lt;code&gt;query&lt;&#x2F;code&gt; text: aliases, column list, the &lt;code&gt;AND&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;OR&lt;&#x2F;code&gt; tree, &lt;code&gt;LIMIT&lt;&#x2F;code&gt;, join order. That structure is everything a canonicalizer needs to see that &lt;code&gt;WHERE a = $1 AND b = $2&lt;&#x2F;code&gt; and &lt;code&gt;WHERE b = $1 AND a = $2&lt;&#x2F;code&gt; are the same predicate, or that &lt;code&gt;c.id&lt;&#x2F;code&gt; and &lt;code&gt;id&lt;&#x2F;code&gt; are the same column. The extension over-normalizes constants and under-normalizes structure.&lt;&#x2F;p&gt;
&lt;p&gt;One thing genuinely is gone, though: anything that was &lt;strong&gt;evicted&lt;&#x2F;strong&gt;. A canonicalizer only sees the rows currently in the view, so if your ORM has flooded the hash table and &lt;code&gt;dealloc&lt;&#x2F;code&gt; has dropped the tail, there&#x27;s nothing left to fingerprint. Keep cardinality under control at the source first, by raising &lt;code&gt;pg_stat_statements.max&lt;&#x2F;code&gt;, getting on PG 18, or switching to array binding (more on that next), or you&#x27;ll just be fingerprinting whichever rows happened to survive, not your real workload.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-in-list-special-case-and-its-pg-18-fix&quot;&gt;The IN-list special case, and its PG 18 fix&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-in-list-special-case-and-its-pg-18-fix&quot; aria-label=&quot;Anchor link for: the-in-list-special-case-and-its-pg-18-fix&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;There&#x27;s one structural variation Postgres historically handled badly: lists of constants.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;4&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;4&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Before PostgreSQL 18, each of those was its own entry, because the length of the list changed the parsed tree. An app that builds &lt;code&gt;IN&lt;&#x2F;code&gt; clauses on the fly, which is most apps, could burn through thousands of entries with a single query shape and evict everything else on the way (more on eviction below).&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 18 finally collapses them. The three statements above, differing only in list length, now land in a single entry:&lt;&#x2F;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;query | SELECT * FROM orders WHERE id IN ($1 &#x2F;*, ... *&#x2F;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;calls | 3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;PostgreSQL 18: &lt;code&gt;IN&lt;&#x2F;code&gt;-lists squashed.&lt;&#x2F;strong&gt;&lt;br&gt;
A constant list that differs only in length now collapses to one entry, shown as &lt;code&gt;IN ($1 &#x2F;*, ... *&#x2F;)&lt;&#x2F;code&gt;. It&#x27;s unconditional, with no GUC to turn on. The limit: it only fires for inline literals, so a driver that binds &lt;code&gt;IN ($1, $2, $3)&lt;&#x2F;code&gt; as parameters still gets a fresh entry for every list length.
&lt;&#x2F;div&gt;
&lt;p&gt;That&#x27;s a real improvement, and a good reason to be on 18. Two things keep it from being the whole answer. Most production clusters are still on 17 or earlier, where the explosion is alive and well. And the parameter-binding drivers it skips, JDBC and friends, are often the worst offenders: they send &lt;code&gt;IN ($1, $2, $3)&lt;&#x2F;code&gt; as bind parameters, so the values were never constants in the parsed tree and there is nothing to squash. &lt;code&gt;IN ($1,$2,$3)&lt;&#x2F;code&gt; and &lt;code&gt;IN ($1,$2,$3,$4)&lt;&#x2F;code&gt; stay separate entries, one new shape per list length.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The squashing briefly sat behind a GUC, &lt;code&gt;query_id_squash_values&lt;&#x2F;code&gt;, during PG 18 development. It was &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;postgres&#x2F;postgres&#x2F;commit&#x2F;9fbd53dea5d513a78ca04834101ca1aa73b63e59&quot;&gt;removed before release&lt;&#x2F;a&gt;, so the behaviour is now always on.&lt;&#x2F;div&gt;
&lt;p&gt;If you&#x27;re on one of those drivers, you&#x27;re stuck regardless of your Postgres version, but there&#x27;s a real fix, and it beats the PG 18 squashing because it works everywhere. Stop sending &lt;code&gt;IN&lt;&#x2F;code&gt;-lists and send an array. Rewrite this:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; ($&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;, ... $n); &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- n params, n distinct shapes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;as this:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; ANY($&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;); &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- one param, one shape, forever&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;= ANY(array)&lt;&#x2F;code&gt; takes a single parameter, one array value, so the parsed tree is identical whether the array holds 3 elements or 3,000. That&#x27;s one queryid, on every Postgres version and every driver, prepared or not, because the array length is never part of the structure the jumble sees. Most drivers and ORMs support array binding directly (&lt;code&gt;setArray&lt;&#x2F;code&gt;, &lt;code&gt;ARRAY[...]&lt;&#x2F;code&gt;, pgx&#x27;s slice binding, ActiveRecord&#x27;s &lt;code&gt;where(id: array)&lt;&#x2F;code&gt;), and the planner uses it for index selection much like an &lt;code&gt;IN&lt;&#x2F;code&gt;-list. For an ORM-heavy team it&#x27;s the highest-leverage way to keep &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; readable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-first-seen-text-is-frozen&quot;&gt;The first-seen text is frozen&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-first-seen-text-is-frozen&quot; aria-label=&quot;Anchor link for: the-first-seen-text-is-frozen&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Every entry shows one query text, and it&#x27;s the first one that created the entry. Not the most recent, not the most common, the first. After that the counters keep climbing on every matching execution, but the text on disk never changes, at least until the entry gets evicted and rebuilt.&lt;&#x2F;p&gt;
&lt;p&gt;Most of the time that&#x27;s fine, because the constants are normalized to &lt;code&gt;$1&lt;&#x2F;code&gt; anyway. It stops being fine the moment your queries carry comments, and modern apps carry a lot of them. The &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;google.github.io&#x2F;sqlcommenter&#x2F;&quot;&gt;sqlcommenter&lt;&#x2F;a&gt; and marginalia conventions tack structured key-value context onto every query:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name FROM&lt;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;*application=&amp;#39;checkout&amp;#39;,controller=&amp;#39;orders&amp;#39;,action=&amp;#39;show&amp;#39;*&#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Comments aren&#x27;t part of the parsed tree, so they don&#x27;t change the queryid. Every execution still maps to one entry. But the text on that entry is whatever showed up first. That splits comment tags into two kinds, and the split is the whole story:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tag class&lt;&#x2F;th&gt;&lt;th&gt;Examples&lt;&#x2F;th&gt;&lt;th&gt;What &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; does with it&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Static&lt;&#x2F;strong&gt;&lt;br&#x2F;&gt;(same value every call from one place)&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;application&lt;&#x2F;code&gt;, &lt;code&gt;controller&lt;&#x2F;code&gt;, &lt;code&gt;action&lt;&#x2F;code&gt;, &lt;code&gt;job&lt;&#x2F;code&gt;, &lt;code&gt;framework&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;Survives.&lt;&#x2F;strong&gt; The first-seen value is representative, because every call from that spot carries the same value. You can scrape it from the &lt;code&gt;query&lt;&#x2F;code&gt; text and trust it.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Dynamic&lt;&#x2F;strong&gt;&lt;br&#x2F;&gt;(a new value every request)&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;traceparent&lt;&#x2F;code&gt;, &lt;code&gt;request_id&lt;&#x2F;code&gt;, &lt;code&gt;trace_id&lt;&#x2F;code&gt;, &lt;code&gt;tenant_id&lt;&#x2F;code&gt;, &lt;code&gt;user_id&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;Lost.&lt;&#x2F;strong&gt; The first request&#x27;s value is frozen into the text forever, and every later per-request value is silently dropped.&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;That second row is why &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; can never be a join target for an APM or tracing tool. You can&#x27;t ask it &quot;show me the queries from the trace that timed out at 14:32,&quot; because it kept exactly one &lt;code&gt;traceparent&lt;&#x2F;code&gt;, the first it ever saw, and threw the rest away. A counter is a sum, and a sum has no room to remember which request contributed what. If you need dynamic tags, you need a per-execution capture path running alongside the extension (auto_explain, or a hook-based harvester), not the extension itself.&lt;&#x2F;p&gt;
&lt;p&gt;A &lt;code&gt;traceparent&lt;&#x2F;code&gt; in the &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; text is a warning sign, not a feature: a per-request value reached the entry first and is now frozen there as if it were static context. If you scrape tags from these texts into a catalog, drop the hex-id-shaped ones.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-averages-lie-to-you&quot;&gt;The averages lie to you&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-averages-lie-to-you&quot; aria-label=&quot;Anchor link for: the-averages-lie-to-you&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;This is the limitation that catches the most people, because the numbers look so trustworthy.&lt;&#x2F;p&gt;
&lt;p&gt;Every counter is cumulative since the last reset. There&#x27;s no time dimension inside the extension at all. The view is one snapshot of running totals, and that has two consequences that feed each other.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;There&#x27;s no history built in.&lt;&#x2F;strong&gt; Say you want to know what got slower this week. The obvious move is to read &lt;code&gt;mean_exec_time&lt;&#x2F;code&gt;, and that&#x27;s exactly where it goes wrong. That number is the average over &lt;em&gt;all&lt;&#x2F;em&gt; time since the last reset, so on a cluster that&#x27;s been up for months it blends a cold-cache Monday morning, the nightly batch job, and a quiet Sunday into one figure that describes no real moment. To get a &quot;this week&quot; number you have to do the bookkeeping yourself: snapshot the view on a schedule, keep each snapshot in a table, and subtract two of them. The average over the last hour is just &lt;code&gt;(total_exec_time_now - total_exec_time_then) &#x2F; (calls_now - calls_then)&lt;&#x2F;code&gt;. The view hands you a running total; turning it into a rate is on you.&lt;&#x2F;p&gt;
&lt;p&gt;That subtraction is less safe than it looks. It quietly assumes the &quot;now&quot; counters carry on from the &quot;then&quot; counters, that they only ever climb. Usually true, not always. Eviction and resets, both covered in &lt;a href=&quot;&#x2F;posts&#x2F;pg-stat-statements-part-2&#x2F;&quot;&gt;part two&lt;&#x2F;a&gt;, break that assumption, and a snapshot diff you&#x27;d actually trust has to handle three cases:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The entry was evicted and rebuilt.&lt;&#x2F;strong&gt; If a query drops out of the table and later runs again, it comes back with counters at zero. Now &lt;code&gt;total_exec_time_now&lt;&#x2F;code&gt; is smaller than &lt;code&gt;total_exec_time_then&lt;&#x2F;code&gt;, and the subtraction gives you a negative delta. Don&#x27;t read that as &quot;the query got faster.&quot; Read it as &quot;this window is invalid for this queryid,&quot; because the entry&#x27;s history was cut.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;A global reset happened between snapshots.&lt;&#x2F;strong&gt; Someone ran &lt;code&gt;pg_stat_statements_reset()&lt;&#x2F;code&gt;, or a deploy script did. Every row&#x27;s &quot;now&quot; is smaller than its &quot;then.&quot; This one is easy to catch: compare &lt;code&gt;pg_stat_statements_info.stats_reset&lt;&#x2F;code&gt; against the time of your earlier snapshot, and if the reset is newer, the whole window crossed a reset and the entire diff is garbage.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;The queryid is on only one side.&lt;&#x2F;strong&gt; A query in &quot;then&quot; but missing from &quot;now&quot; was evicted, or its table was dropped and recreated (new OID, new id). A query in &quot;now&quot; but not &quot;then&quot; is new or rebuilt. Join the two snapshots with a full outer join, and treat the rows that don&#x27;t match on both sides as events, not as zeros to subtract from.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;div class=&quot;sidenote&quot;&gt;PostgreSQL 17 adds a cleaner per-entry guard: every row carries &lt;code&gt;stats_since&lt;&#x2F;code&gt;, the moment the entry was created or recreated. If a row&#x27;s &lt;code&gt;stats_since&lt;&#x2F;code&gt; is newer than your earlier snapshot, its counters reset under you, so discard that window no matter what the subtraction says.&lt;&#x2F;div&gt;
&lt;p&gt;Before PG 17 the global &lt;code&gt;stats_reset&lt;&#x2F;code&gt; is your only built-in signal, and per-entry eviction is invisible except through the counters-went-backwards trick. The bare formula is right; the monitoring layer around it is mostly this bookkeeping, and skipping it is how homegrown dashboards end up reporting impossible negative latencies.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The average hides the spread.&lt;&#x2F;strong&gt; Even inside one window, the extension only keeps four numbers per shape: &lt;code&gt;min&lt;&#x2F;code&gt;, &lt;code&gt;max&lt;&#x2F;code&gt;, &lt;code&gt;mean&lt;&#x2F;code&gt;, and &lt;code&gt;stddev&lt;&#x2F;code&gt; of exec time. It builds them on the fly, folding each execution into running aggregates and then dropping the timing, so there&#x27;s no histogram, no percentiles, no per-call record to go back to. A query that runs in 1ms 99% of the time and 2 seconds the other 1% has a &lt;code&gt;mean_exec_time&lt;&#x2F;code&gt; around 21ms, a number that matches none of its real executions and hides the p99 that&#x27;s paging your on-call. Two-humped latency, a common shape for real performance problems, just disappears into an average and a standard deviation. The extension can tell you a query is slow on average, not that it&#x27;s sometimes catastrophically slow, which is usually the more dangerous case.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The mean is just &lt;code&gt;total_exec_time &#x2F; calls&lt;&#x2F;code&gt;, but the extension maintains it (and the variance behind &lt;code&gt;stddev&lt;&#x2F;code&gt;) with Welford&#x27;s online algorithm: each execution folds into a running mean and sum-of-squares, then its timing is dropped. An average and a standard deviation are all that survive, never a percentile, because no samples are kept to rank.&lt;&#x2F;div&gt;
&lt;p&gt;If you want percentiles or a latency timeline, this is the wrong tool, and querying it harder won&#x27;t help. It dropped the spread when it wrote the row.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the first half of the story: everything &lt;code&gt;pg_stat_statements&lt;&#x2F;code&gt; tells you, and the ways it quietly bends what it reports. One logical query fragmented into hundreds of shapes, the text frozen at first sight, the whole distribution flattened into a single average.&lt;&#x2F;p&gt;
&lt;p&gt;The other half is what it never records at all: the entries it silently evicts, the query text that can vanish in one stroke, the plans it can&#x27;t see, and the replicas it doesn&#x27;t know exist. That&#x27;s &lt;a href=&quot;&#x2F;posts&#x2F;pg-stat-statements-part-2&#x2F;&quot;&gt;part two&lt;&#x2F;a&gt;, which also returns to the question we opened on: is this the query store Postgres is missing, or just the floor you&#x27;d build one on?&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Welcome to ORDER BY jungle</title>
        <published>2026-05-15T11:43:00+02:00</published>
        <updated>2026-05-15T11:43:00+02:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/order-by-jungle/"/>
        <id>https://boringsql.com/posts/order-by-jungle/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/order-by-jungle/">&lt;p&gt;SQL is fun and not at all boring. The latest article by Markus Winand on &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;modern-sql.com&#x2F;blog&#x2F;2026-05&#x2F;order-by-history&quot;&gt;Order by Has Come a Long Way&lt;&#x2F;a&gt; sent me on quite a journey.&lt;&#x2F;p&gt;
&lt;p&gt;First, set up a table called &lt;code&gt;nums&lt;&#x2F;code&gt; with one integer column and four rows:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; nums&lt;&#x2F;span&gt;&lt;span&gt; (a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;), (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Try to guess what these two queries return.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT -&lt;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY -&lt;&#x2F;span&gt;&lt;span&gt;a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Most of us would guess the same rows in a different order. The actual answer is that they produce &lt;strong&gt;exactly the same rows in exactly the same order&lt;&#x2F;strong&gt;. By the same logic you might expect&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; c &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY -&lt;&#x2F;span&gt;&lt;span&gt;c;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;to do exactly the same. Except it does not. It errors with &lt;code&gt;column &quot;c&quot; does not exist&lt;&#x2F;code&gt; despite the alias being right there in the statement. Welcome to ORDER BY jungle.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;names-and-expressions-are-not-the-same&quot;&gt;Names and expressions are not the same&lt;a class=&quot;zola-anchor&quot; href=&quot;#names-and-expressions-are-not-the-same&quot; aria-label=&quot;Anchor link for: names-and-expressions-are-not-the-same&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;If you ask most developers how &lt;code&gt;ORDER BY&lt;&#x2F;code&gt; works, they will say &quot;you put a column name there and it sorts the rows&quot;. In 99% of queries that is exactly what happens. People sort by &lt;code&gt;created_at&lt;&#x2F;code&gt; or &lt;code&gt;id&lt;&#x2F;code&gt; and move on.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Strictly speaking, three, if you count &lt;code&gt;ORDER BY 1&lt;&#x2F;code&gt;. Positional references are their own can of worms and out of scope for this post.&lt;&#x2F;div&gt;But `ORDER BY` accepts two different kinds of things:
&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;&#x2F;span&gt;&lt;span&gt; created_at, user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; events &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; created_at;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; created_at, user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; events &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY date&lt;&#x2F;span&gt;&lt;span&gt;(created_at);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both feel natural. And the thing nobody tells you is that they go down completely different code paths in the parser. Different scope rules, different lookups, different error messages. The first looks at your &lt;code&gt;SELECT&lt;&#x2F;code&gt; list. The second looks at your &lt;code&gt;FROM&lt;&#x2F;code&gt; clause. They never look at the same place.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;same-answer-two-different-sorts&quot;&gt;Same answer, two different sorts&lt;a class=&quot;zola-anchor&quot; href=&quot;#same-answer-two-different-sorts&quot; aria-label=&quot;Anchor link for: same-answer-two-different-sorts&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Look at the first query again.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You wrote &lt;code&gt;ORDER BY a&lt;&#x2F;code&gt;. A bare identifier, no decoration. Postgres goes down the &lt;em&gt;name&lt;&#x2F;em&gt; path. It scans the &lt;code&gt;SELECT&lt;&#x2F;code&gt; list for something called &lt;code&gt;a&lt;&#x2F;code&gt;, finds the aliased column &lt;code&gt;-a AS a&lt;&#x2F;code&gt;, and sorts by its output values. The negated values are &lt;code&gt;-3, -2, -1, 0&lt;&#x2F;code&gt;, ascending is &lt;code&gt;-3, -2, -1, 0&lt;&#x2F;code&gt;. That is what comes out.&lt;&#x2F;p&gt;
&lt;p&gt;Now the twin.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY -&lt;&#x2F;span&gt;&lt;span&gt;a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You wrote &lt;code&gt;ORDER BY -a&lt;&#x2F;code&gt;. This is no longer an identifier. It&#x27;s an expression: unary minus around a column reference. The parser does not even try the same logic.&lt;&#x2F;p&gt;
&lt;p&gt;Instead it switches to the &lt;strong&gt;expression path&lt;&#x2F;strong&gt;, where the only &lt;code&gt;a&lt;&#x2F;code&gt; it knows is the column in &lt;code&gt;nums&lt;&#x2F;code&gt;, and sorts the input values negated. And by arithmetic luck, the two queries land on the same row order. Same output, completely different logic. If you don&#x27;t believe it is just luck, drop the negation from the &lt;code&gt;SELECT&lt;&#x2F;code&gt; list and keep it in &lt;code&gt;ORDER BY&lt;&#x2F;code&gt;:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; c &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY -&lt;&#x2F;span&gt;&lt;span&gt;a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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; c&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;ORDER BY -a&lt;&#x2F;code&gt; is an expression, so it sorts by &lt;code&gt;-input_a&lt;&#x2F;code&gt; ascending, which is &lt;code&gt;input_a&lt;&#x2F;code&gt; descending. The alias &lt;code&gt;c&lt;&#x2F;code&gt; was never consulted. The result has nothing to do with whatever &lt;code&gt;c&lt;&#x2F;code&gt; happens to be.&lt;&#x2F;p&gt;
&lt;p&gt;And &lt;code&gt;ORDER BY -c&lt;&#x2F;code&gt; is now obvious. &lt;code&gt;-c&lt;&#x2F;code&gt; is an expression, so the parser looks for column &lt;code&gt;c&lt;&#x2F;code&gt; in &lt;code&gt;FROM&lt;&#x2F;code&gt;, doesn&#x27;t find it, and errors. The alias exists, but in a scope this code path cannot see.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;above-the-identifier-or-around-it&quot;&gt;Above the identifier, or around it&lt;a class=&quot;zola-anchor&quot; href=&quot;#above-the-identifier-or-around-it&quot; aria-label=&quot;Anchor link for: above-the-identifier-or-around-it&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Once the rule is clear (bare identifier hits the &lt;code&gt;SELECT&lt;&#x2F;code&gt; list, anything else hits the table) the rest of the surprises fall out.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;hello&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; x::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- ERROR: column &amp;quot;x&amp;quot; does not exist&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It is probably not surprising that &lt;strong&gt;casts&lt;&#x2F;strong&gt; count as expressions and push the lookup to the table.&lt;&#x2F;p&gt;
&lt;p&gt;The surprise might come with&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; c &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; c &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DESC NULLS FIRST&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Which will work as expected. Both &lt;code&gt;DESC&lt;&#x2F;code&gt; and &lt;code&gt;NULLS FIRST&lt;&#x2F;code&gt; are part of the sort clause itself, not of the sort expression. They sit above the identifier in the parse tree, so they never touch it. The parser still sees a bare &lt;code&gt;c&lt;&#x2F;code&gt;, takes the fast path, finds the alias, sorts by it, and then applies &quot;descending, nulls first&quot; on top of the resolved key.&lt;&#x2F;p&gt;
&lt;p&gt;The same cannot be said about &lt;strong&gt;collation&lt;&#x2F;strong&gt;.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;A&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text AS&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;COLLATE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;C&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- ERROR: column &amp;quot;x&amp;quot; does not exist&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is a really bad one. &lt;code&gt;COLLATE&lt;&#x2F;code&gt; might look the same as a sort modifier, but it is not. It wraps the expression in the parse tree.&lt;&#x2F;p&gt;
&lt;p&gt;Parentheses are a special case.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; (a);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- works, sorts by alias&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Postgres collapses redundant parens before the bare-identifier check, so &lt;code&gt;(a)&lt;&#x2F;code&gt; is still bare &lt;code&gt;a&lt;&#x2F;code&gt;. The seam is asymmetric in the way that maximises confusion: &lt;code&gt;COLLATE&lt;&#x2F;code&gt; is &quot;still a name to a human, an expression to the parser&quot;, and &lt;code&gt;(a)&lt;&#x2F;code&gt; is &quot;an expression to a human, still a name to the parser&quot;. You get both flavours of wrong intuition mixed here.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Unary plus.&lt;&#x2F;strong&gt; &lt;code&gt;+a&lt;&#x2F;code&gt; and &lt;code&gt;a&lt;&#x2F;code&gt; evaluate to the same value, but they do not parse to the same node.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT -&lt;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY +&lt;&#x2F;span&gt;&lt;span&gt;a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A plus sign you would not even think about changes which rows come out in which order.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The parser stores a column reference as a list of name parts: one part when it is unqualified, two or more once you add a table or schema. The fast path only fires on lists of length one.&lt;&#x2F;div&gt;
&lt;p&gt;Finally, &lt;strong&gt;schema- and table-qualified references&lt;&#x2F;strong&gt;. &lt;code&gt;ORDER BY nums.a&lt;&#x2F;code&gt; looks like an identifier, but it is not.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT -&lt;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; nums&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;a&lt;&#x2F;span&gt;&lt;span&gt;; &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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; a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; -3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; -2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; -1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; -1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; -2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; -3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;aliases-that-aren-t-the-names-you-think&quot;&gt;Aliases that aren&#x27;t the names you think&lt;a class=&quot;zola-anchor&quot; href=&quot;#aliases-that-aren-t-the-names-you-think&quot; aria-label=&quot;Anchor link for: aliases-that-aren-t-the-names-you-think&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Here is one that cost me an afternoon once. Easy to come across once an ORM or a generated view declared the alias for you. SQLAlchemy, Hibernate, jOOQ, and most code generators quote anything that isn&#x27;t pure lowercase. Two queries, identical except that the alias is quoted in one. Two different result sets.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt;  A  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- sorts by alias (-3,-2,-1,0)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT -&lt;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;A&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- sorts by input (0,-1,-2,-3)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The bare-identifier check compares names with &lt;code&gt;strcmp&lt;&#x2F;code&gt;. Unquoted &lt;code&gt;A&lt;&#x2F;code&gt; folds to lowercase &lt;code&gt;a&lt;&#x2F;code&gt; and matches. Quoted &lt;code&gt;&quot;A&quot;&lt;&#x2F;code&gt; preserves case, stays &lt;code&gt;A&lt;&#x2F;code&gt;, and does not match the lowercase &lt;code&gt;a&lt;&#x2F;code&gt; in the &lt;code&gt;ORDER BY&lt;&#x2F;code&gt;. The lookup fails, the parser falls through to the expression path, the expression path finds the column &lt;code&gt;a&lt;&#x2F;code&gt; in &lt;code&gt;nums&lt;&#x2F;code&gt;, and the query runs successfully while doing something different from what you meant.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;group-by-checks-the-opposite-scope-first&quot;&gt;GROUP BY checks the opposite scope first&lt;a class=&quot;zola-anchor&quot; href=&quot;#group-by-checks-the-opposite-scope-first&quot; aria-label=&quot;Anchor link for: group-by-checks-the-opposite-scope-first&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Both &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; and &lt;code&gt;ORDER BY&lt;&#x2F;code&gt; accept a bare identifier, and both can resolve it either way: to a table column or to a &lt;code&gt;SELECT&lt;&#x2F;code&gt;-list alias. The difference is the order they check:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ORDER BY a&lt;&#x2F;code&gt; looks at the &lt;code&gt;SELECT&lt;&#x2F;code&gt; list first, then the table.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;GROUP BY a&lt;&#x2F;code&gt; looks at the table first, then the &lt;code&gt;SELECT&lt;&#x2F;code&gt; list.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;For most queries this never matters. The two clauses end up picking the same thing because nothing is shadowed. The surprise happens when an alias has the same name as a base column but a different value:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; a&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&#x2F;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; a, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now the two clauses disagree about what &lt;code&gt;a&lt;&#x2F;code&gt; means. &lt;code&gt;GROUP BY a&lt;&#x2F;code&gt; picks the input column (four distinct values, four groups, one row each). &lt;code&gt;ORDER BY a&lt;&#x2F;code&gt; picks the alias, which is &lt;code&gt;a&#x2F;2&lt;&#x2F;code&gt;. The result has four rows because the grouping was on a finer-grained key than the projection:&lt;&#x2F;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; a | count&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---+-------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 0 |     1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 0 |     1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1 |     1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1 |     1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two rows where &lt;code&gt;a&#x2F;2 = 0&lt;&#x2F;code&gt; (from input &lt;code&gt;0&lt;&#x2F;code&gt; and &lt;code&gt;1&lt;&#x2F;code&gt;), two where &lt;code&gt;a&#x2F;2 = 1&lt;&#x2F;code&gt; (from input &lt;code&gt;2&lt;&#x2F;code&gt; and &lt;code&gt;3&lt;&#x2F;code&gt;). The duplicates are real. The same identifier means two different columns in two adjacent clauses of one query.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;window-order-by-does-not-even-pretend&quot;&gt;Window ORDER BY does not even pretend&lt;a class=&quot;zola-anchor&quot; href=&quot;#window-order-by-does-not-even-pretend&quot; aria-label=&quot;Anchor link for: window-order-by-does-not-even-pretend&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;This one trips people up because it does not look like a different clause.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; a, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; neg, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;row_number&lt;&#x2F;span&gt;&lt;span&gt;() &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OVER&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; neg) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- ERROR: column &amp;quot;neg&amp;quot; does not exist&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;OVER (ORDER BY ...)&lt;&#x2F;code&gt; is a different parse path entirely. It does not check the targetlist at all, only the FROM scope. The bare-name fast path simply does not exist here.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; a, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; neg, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;row_number&lt;&#x2F;span&gt;&lt;span&gt;() &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OVER&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY -&lt;&#x2F;span&gt;&lt;span&gt;a) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- this works&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two &lt;code&gt;ORDER BY&lt;&#x2F;code&gt; clauses in the same query, two different scoping rules.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;union-order-by-is-name-only&quot;&gt;UNION ORDER BY is name-only&lt;a class=&quot;zola-anchor&quot; href=&quot;#union-order-by-is-name-only&quot; aria-label=&quot;Anchor link for: union-order-by-is-name-only&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When &lt;code&gt;ORDER BY&lt;&#x2F;code&gt; follows a &lt;code&gt;UNION&lt;&#x2F;code&gt;, neither path is fully open.&lt;&#x2F;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;-- ok&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 9&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- ERROR&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 9&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY -&lt;&#x2F;span&gt;&lt;span&gt;a;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- ERROR&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UNION ALL&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 9&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;COLLATE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;C&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The error message is unusually helpful:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Only result column names can be used, not expressions or functions. HINT: Add the expression&#x2F;function to every SELECT, or move the UNION into a FROM clause.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Set operations do not have a single FROM scope to fall back to, so the expression path is closed entirely. Bare names or nothing.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-seam-in-the-source&quot;&gt;The seam, in the source&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-seam-in-the-source&quot; aria-label=&quot;Anchor link for: the-seam-in-the-source&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;div class=&quot;sidenote&quot;&gt;Full disclosure: I got this section wrong three times before Claude Code helped me trace the actual parse tree. Lack of sleep from a whole night of geeking out over &lt;code&gt;ORDER BY&lt;&#x2F;code&gt; is the other plausible explanation.&lt;&#x2F;div&gt;Open `src&#x2F;backend&#x2F;parser&#x2F;parse_clause.c` and find `findTargetlistEntrySQL92`. It is forty lines of comment, two `if` blocks, and a final `return`. SQL92&#x27;s two resolution rules are tried first; SQL99 is the fallback.
&lt;p&gt;&lt;strong&gt;Block one: the bare-name path.&lt;&#x2F;strong&gt; The gate is a &lt;code&gt;ColumnRef&lt;&#x2F;code&gt; node with exactly one name part, and that part must be a string identifier (not &lt;code&gt;*&lt;&#x2F;code&gt;, which is also a &lt;code&gt;ColumnRef&lt;&#x2F;code&gt; but with an &lt;code&gt;A_Star&lt;&#x2F;code&gt; field). If the node passes, the function walks the target list looking for a non-&lt;code&gt;resjunk&lt;&#x2F;code&gt; entry whose &lt;code&gt;resname&lt;&#x2F;code&gt; equals the identifier. The loop keeps going past the first match to detect ambiguity: identical expressions are fine (this is why &lt;code&gt;SELECT a, a FROM nums ORDER BY a&lt;&#x2F;code&gt; works), different expressions error out. On a unique match, return.&lt;&#x2F;p&gt;
&lt;p&gt;If the loop finds nothing, the block does &lt;strong&gt;not&lt;&#x2F;strong&gt; return. Control falls through. This is the case behind the quoted-alias surprise earlier in the post: &lt;code&gt;AS &quot;A&quot;&lt;&#x2F;code&gt; stores &lt;code&gt;resname = &quot;A&quot;&lt;&#x2F;code&gt;, &lt;code&gt;ORDER BY a&lt;&#x2F;code&gt; looks up &lt;code&gt;resname = &quot;a&quot;&lt;&#x2F;code&gt;, the &lt;code&gt;strcmp&lt;&#x2F;code&gt; fails, and the function moves on as if no SQL92 fast path applied.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;GROUP BY&lt;&#x2F;code&gt; is the small exception inside this block. The name is first tested against the &lt;code&gt;FROM&lt;&#x2F;code&gt; scope, and a hit there causes the targetlist loop to be skipped. That is how &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; ends up preferring the input column.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Block two: the positional path.&lt;&#x2F;strong&gt; The gate is &lt;code&gt;IsA(node, A_Const)&lt;&#x2F;code&gt;. A non-integer constant errors immediately (&quot;non-integer constant in ...&quot;), which catches &lt;code&gt;ORDER BY NULL&lt;&#x2F;code&gt;, &lt;code&gt;ORDER BY &#x27;a&#x27;&lt;&#x2F;code&gt;, &lt;code&gt;ORDER BY TRUE&lt;&#x2F;code&gt;. An integer is used as a 1-based position into the non-&lt;code&gt;resjunk&lt;&#x2F;code&gt; target list; anything outside the range errors as &quot;position %d is not in select list&quot;. Block two never falls through.&lt;&#x2F;p&gt;
&lt;p&gt;Both &lt;code&gt;1&lt;&#x2F;code&gt; and &lt;code&gt;-1&lt;&#x2F;code&gt; arrive here as integer &lt;code&gt;A_Const&lt;&#x2F;code&gt;s. &lt;code&gt;doNegate&lt;&#x2F;code&gt; in the grammar folds &lt;code&gt;&#x27;-&#x27; Iconst&lt;&#x2F;code&gt; into a single integer constant before the function ever runs, so &lt;code&gt;ORDER BY 1&lt;&#x2F;code&gt; and &lt;code&gt;ORDER BY -1&lt;&#x2F;code&gt; go through the same code, with only the integer value (and the result of the position lookup) differing.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The fallthrough.&lt;&#x2F;strong&gt; Anything not caught above reaches the last line:&lt;&#x2F;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: #6A737D;&quot;&gt;&#x2F;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; * Otherwise, we have an expression, so process it per SQL99 rules.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt; *&#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;return&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; findTargetlistEntrySQL99&lt;&#x2F;span&gt;&lt;span&gt;(pstate, node, tlist, exprKind);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That is the seam. SQL92 succeeds in two narrow shapes: a bare identifier with a matching alias, or an in-range positive integer. Everything else, including a bare identifier whose alias lookup found nothing, becomes a SQL99 expression resolved against &lt;code&gt;FROM&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-useful-workaround&quot;&gt;A useful workaround&lt;a class=&quot;zola-anchor&quot; href=&quot;#a-useful-workaround&quot; aria-label=&quot;Anchor link for: a-useful-workaround&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;If you want the alias inside an expression in &lt;code&gt;ORDER BY&lt;&#x2F;code&gt;, the portable trick is to wrap the query in a subselect:&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT -&lt;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums) s&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;+&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now &lt;code&gt;x&lt;&#x2F;code&gt; is a real column in the FROM scope of the outer query. The expression path finds it. The seam has been moved out of the way.&lt;&#x2F;p&gt;
&lt;p&gt;This is, conceptually, what you would want the engine to do for you when you write &lt;code&gt;ORDER BY x + 0&lt;&#x2F;code&gt; directly. The SQL-99 standard does not actually require that, though, and Postgres (along with SQL Server) documents explicitly that an alias inside an &lt;code&gt;ORDER BY&lt;&#x2F;code&gt; expression is not supported. So you do it by hand.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-boring-takeaway&quot;&gt;The boring takeaway&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-boring-takeaway&quot; aria-label=&quot;Anchor link for: the-boring-takeaway&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Most of the time none of this matters. You sort by a column you just selected, the alias and the input column have the same name and the same value, and either parser path gives the same answer. The seam is invisible.&lt;&#x2F;p&gt;
&lt;p&gt;The minute the alias and the input column disagree in expression, value, case, or anything wrapped around the identifier, the parser picks one or the other silently, by a rule older than most working programmers.&lt;&#x2F;p&gt;
&lt;p&gt;There are two parsers. The bare-name path is SQL-92, the expression path is SQL-99, and they were stitched together in the late 1990s. They still disagree about which scope your identifiers live in, and knowing which one you triggered tells you which scope.&lt;&#x2F;p&gt;
&lt;p&gt;If after reading this post you still have to stop and think for a minute before predicting what&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt;a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; nums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;COLLATE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;C&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;does, that is the right reaction. It means you have the mental model.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;em&gt;The opening puzzle queries are from Jamie Brandon&#x27;s &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;lobste.rs&#x2F;s&#x2F;zp6fnc&#x2F;sql_s_order_by_has_come_long_way&quot;&gt;comment on the Lobsters thread&lt;&#x2F;a&gt; discussing Markus Winand&#x27;s &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;modern-sql.com&#x2F;blog&#x2F;2026-05&#x2F;order-by-history&quot;&gt;history of &lt;code&gt;ORDER BY&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; on modern-sql.com. Everything that follows here is the explanation that comment did not give. Both pieces are worth reading on their own.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Strong views on PostgreSQL VIEWs</title>
        <published>2026-05-10T00:00:00+00:00</published>
        <updated>2026-05-10T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/strong-views/"/>
        <id>https://boringsql.com/posts/strong-views/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/strong-views/">&lt;p&gt;VIEWs should be the cleanest abstraction SQL, and therefore Postgres, has on offer. I love the concept. The promise of decoupling logical intent from physical storage is perfect on paper. In practice, few things in the database world trigger such a heated debate or carry as much historical baggage. VIEWs mix big promises with false hopes, and the promises rarely survive contact with production.&lt;&#x2F;p&gt;
&lt;p&gt;The appeal is straightforward. &lt;strong&gt;Abstract&lt;&#x2F;strong&gt; &quot;active customer&quot; once and reuse it everywhere. Every query, report and dashboard uses the same definition. The &quot;active customer&quot; then becomes the foundation of a &quot;customer orders&quot; view, which in turn powers an operational &quot;customer summary&quot; view.&lt;&#x2F;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;-- layer 1: who counts as an active customer?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; active_customers&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; c.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; customers c&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;deleted_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; IS NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;active&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;last_login_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt; now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;90 days&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- layer 2: active customers with their recent orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_orders&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ac.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         AS&lt;&#x2F;span&gt;&lt;span&gt; order_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total_cents&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;created_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; ordered_at,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;     AS&lt;&#x2F;span&gt;&lt;span&gt; order_status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; active_customers ac&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LEFT JOIN&lt;&#x2F;span&gt;&lt;span&gt; orders o &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; ac&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;created_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt; now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;12 months&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;   OR&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;created_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; IS NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- layer 3: one row per customer, ready for the dashboard&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_summary&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;email&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    COUNT&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;order_id&lt;&#x2F;span&gt;&lt;span&gt;)                                   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; orders_12mo,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    COALESCE&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;SUM&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total_cents&lt;&#x2F;span&gt;&lt;span&gt;), &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;)                     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; revenue_12mo_cents,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    MAX&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;ordered_at&lt;&#x2F;span&gt;&lt;span&gt;)                                   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; last_order_at,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    COUNT&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FILTER&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;order_status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;refunded&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; refunds_12mo&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; customer_orders co&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;email&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each layer has one job. &quot;Active customer&quot; is defined exactly once - if marketing changes the ninety-day rule tomorrow, it is one line in one place, and the dashboard query collapses to &lt;code&gt;SELECT * FROM customer_summary WHERE id = $1&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;VIEWs also have the potential to be a real &lt;strong&gt;security boundary&lt;&#x2F;strong&gt;. Three hand-written queries means three places where a predicate can be forgotten, and three subtly different result sets a quarter later. With one definition, you have one result set.&lt;&#x2F;p&gt;
&lt;p&gt;For simple views, there is no performance penalty either. PostgreSQL inlines them directly into the calling query, so the planner sees through the view and plans as if you had written the underlying SQL by hand. That mechanism, and the cases where it stops working, has &lt;a href=&quot;&#x2F;posts&#x2F;view-inlining&#x2F;&quot;&gt;a deep-dive of its own&lt;&#x2F;a&gt;. This article assumes it as background and focuses on the other side: what views are made of, and what happens when the schema underneath them moves.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;views-are-just-like-tables&quot;&gt;VIEWs are just like tables&lt;a class=&quot;zola-anchor&quot; href=&quot;#views-are-just-like-tables&quot; aria-label=&quot;Anchor link for: views-are-just-like-tables&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The trouble starts with the syntax.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;SELECT * FROM customer_summary&lt;&#x2F;code&gt; reads exactly like &lt;code&gt;SELECT * FROM customers&lt;&#x2F;code&gt;. The catalog reinforces it: &lt;code&gt;pg_class.relkind = &#x27;v&#x27;&lt;&#x2F;code&gt; instead of &lt;code&gt;&#x27;r&#x27;&lt;&#x2F;code&gt;, but everything else (column lists, grants, comments, even &lt;code&gt;\d&lt;&#x2F;code&gt; output) looks the same. So developers reach for them the same way: joining them, nesting them, wrapping them in other views without thinking about it.&lt;&#x2F;p&gt;
&lt;p&gt;A table is a heap with indexes. A view is a stored parse tree that gets expanded into your query at planning time. Most of the time the planner is clever enough that the difference does not matter: simple views are inlined, predicates are pushed down, the resulting plan is what you would have written by hand.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-nested-view-spiral&quot;&gt;The nested view spiral&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-nested-view-spiral&quot; aria-label=&quot;Anchor link for: the-nested-view-spiral&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;In practice, what you get is the dependency tree from the introduction (&lt;code&gt;customer_summary&lt;&#x2F;code&gt; on top of &lt;code&gt;customer_orders&lt;&#x2F;code&gt; on top of &lt;code&gt;active_customers&lt;&#x2F;code&gt;) and a new engineer who has no way to know that &lt;code&gt;SELECT * FROM customer_summary WHERE id = $1&lt;&#x2F;code&gt; expands into a three-layer rewrite, a &lt;code&gt;LEFT JOIN&lt;&#x2F;code&gt; against twelve months of orders, and a &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; that the planner cannot push the &lt;code&gt;id&lt;&#x2F;code&gt; predicate through.&lt;&#x2F;p&gt;
&lt;p&gt;Nothing in the query, the schema browser, or the ORM signals this. You only learn the shape of the tree by reading every definition top to bottom, which is exactly the work the abstraction was meant to save you.&lt;&#x2F;p&gt;
&lt;p&gt;You cannot reason about the performance of a query against a view without reading the view, the views it depends on, and the tables underneath.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;views-are-rewrite-rules&quot;&gt;Views are rewrite rules&lt;a class=&quot;zola-anchor&quot; href=&quot;#views-are-rewrite-rules&quot; aria-label=&quot;Anchor link for: views-are-rewrite-rules&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;A view is a macro. When you reference one, Postgres pastes its body into your query before the planner runs. There is no stored result, no view &quot;object&quot; the executor consults. The &lt;code&gt;pg_class&lt;&#x2F;code&gt; row you see when you query the catalog is an empty shell: it holds the name, the column list, and the grants, but no definition.&lt;&#x2F;p&gt;
&lt;p&gt;The definition itself, the &lt;code&gt;SELECT&lt;&#x2F;code&gt; you wrote, is stored separately in &lt;code&gt;pg_rewrite&lt;&#x2F;code&gt;:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; ev_class::regclass, ev_type, is_instead&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_rewrite&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; ev_class &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;active_customers&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;     ev_class     | ev_type | is_instead&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------------+---------+------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; active_customers | 1       | t&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That row is the view. &lt;code&gt;ev_type = 1&lt;&#x2F;code&gt; is &lt;code&gt;SELECT&lt;&#x2F;code&gt;, &lt;code&gt;is_instead = t&lt;&#x2F;code&gt; means &quot;replace, do not augment&quot; (the &lt;code&gt;pg_class&lt;&#x2F;code&gt; shell has no rows to read, so substitution is the only option that makes sense). Between the parser and the planner Postgres runs a stage called the &lt;strong&gt;rewriter&lt;&#x2F;strong&gt;, and the rewriter&#x27;s job is to walk the parse tree, find references to relations that have rules, and substitute the rule body in place. This way a query like &lt;code&gt;SELECT * FROM active_customers WHERE id = $1&lt;&#x2F;code&gt; arrives at the planner already rewritten:&lt;&#x2F;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;-- what the planner actually sees&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT *&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;email&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;           c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;last_login_at&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;deleted_at&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; customers c&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;deleted_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; IS NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;      AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;active&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;      AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;last_login_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt; now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;90 days&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;) active_customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Notice that &lt;code&gt;c.*&lt;&#x2F;code&gt; is already expanded into an explicit column list. That happened at &lt;code&gt;CREATE VIEW&lt;&#x2F;code&gt; time and was frozen into the stored parse tree; the rewriter does not re-expand it. From here the planner can usually flatten the subquery into the outer query through a pass called &lt;strong&gt;subquery pull-up&lt;&#x2F;strong&gt;, leaving &lt;code&gt;id = $1&lt;&#x2F;code&gt; as a predicate next to the others so an index on &lt;code&gt;customers.id&lt;&#x2F;code&gt; is reachable.&lt;&#x2F;p&gt;
&lt;p&gt;That flattening is what makes simple views free. When it bails out (because the view body has &lt;code&gt;LIMIT&lt;&#x2F;code&gt;, &lt;code&gt;DISTINCT&lt;&#x2F;code&gt;, an aggregate, a set operation, or a few other shapes) the subquery stays put and outer predicates cannot move past it. The full list of &lt;a href=&quot;&#x2F;posts&#x2F;view-inlining&#x2F;#planner-barriers&quot;&gt;planner barriers and how to spot them in &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; lives in the inlining post.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;Historically Postgres exposed this same machinery as user-facing &lt;code&gt;CREATE RULE&lt;&#x2F;code&gt;, on the theory that arbitrary query rewriting was a general-purpose feature. It mostly was not. Rules are effectively deprecated outside of views; triggers do the same jobs without the surprises. The rule infrastructure survives because views need it.&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Once you hold that model, every awkward thing about views falls out of it. Two in particular drive the rest of this article.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Columns are referenced by attribute number, not name.&lt;&#x2F;strong&gt; The stored parse tree does not remember that it reads &lt;code&gt;customers.email&lt;&#x2F;code&gt;; it remembers that it reads attribute 2 of relation 16385. Rename the column and the view keeps working, because the OID and the position are unchanged. Drop a column in the middle and Postgres refuses upfront, because the stored attribute numbers would no longer line up. Every dependency error later in this article traces back to this.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The body is expanded once per reference.&lt;&#x2F;strong&gt; Every mention of the view in a query produces an independent copy of its parse tree in the rewritten plan. For a stable expression that is invisible. For a volatile one like &lt;code&gt;random()&lt;&#x2F;code&gt; or &lt;code&gt;clock_timestamp()&lt;&#x2F;code&gt;, each copy evaluates separately:&lt;&#x2F;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 VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; v_rand&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; r &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;r&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; r_a, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;b&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;r&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; r_b&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; v_rand a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;&#x2F;span&gt;&lt;span&gt; v_rand b &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; b&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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 |         r_a          |         r_b&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+----------------------+---------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 |     0.84043639656488 |  0.0833458769902089&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 | 0.009846241116064247 |  0.6574000469586228&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 |  0.14667469313524628 | 0.21862693208148087&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Same view, same row, two different &lt;code&gt;r&lt;&#x2F;code&gt; values. The rewriter expanded &lt;code&gt;v_rand&lt;&#x2F;code&gt; twice, so the rewritten query contains two independent &lt;code&gt;random()&lt;&#x2F;code&gt; calls, and the planner has no reason to share them. You might hit this expecting &lt;code&gt;a.r = b.r&lt;&#x2F;code&gt; and end up rewriting the view to push the volatile call outside.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;This is also why &lt;code&gt;CREATE OR REPLACE VIEW&lt;&#x2F;code&gt; can only append columns to the end of the column list. Existing attribute numbers must stay stable for every dependent, so anything that would shift them is forbidden.&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A useful side effect of the same mechanism: views are immune to the search-path attacks that bite &lt;code&gt;SECURITY DEFINER&lt;&#x2F;code&gt; functions. Identifiers are resolved to OIDs at &lt;code&gt;CREATE VIEW&lt;&#x2F;code&gt; time, so a later &lt;code&gt;SET search_path&lt;&#x2F;code&gt; cannot redirect a view to a different table. Functions resolve identifiers per call against the caller&#x27;s &lt;code&gt;search_path&lt;&#x2F;code&gt; unless you pin it; views never have that exposure.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;writable-views-the-half-kept-promise&quot;&gt;Writable views, the half-kept promise&lt;a class=&quot;zola-anchor&quot; href=&quot;#writable-views-the-half-kept-promise&quot; aria-label=&quot;Anchor link for: writable-views-the-half-kept-promise&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;Funnily enough, I was not aware writable views existed until earlier this year. It came up during a hallway conversation with &lt;a href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;webervin&#x2F;&quot;&gt;Ervin Weber&lt;&#x2F;a&gt; and &lt;a href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;jesper-st-john-962a4963&#x2F;&quot;&gt;Jesper St John&lt;&#x2F;a&gt; at PgDay Nordic 2026 in Helsinki. Twelve years writing PostgreSQL and the auto-updatable rules had simply never crossed my desk.&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The original promise was symmetry: if a view looks like a table for reads, it should look like one for writes. Postgres delivers this for the easy case (a view over a single base table, no joins, no aggregates, no &lt;code&gt;DISTINCT&lt;&#x2F;code&gt;) and calls them &lt;strong&gt;auto-updatable&lt;&#x2F;strong&gt;. You can &lt;code&gt;INSERT&lt;&#x2F;code&gt;, &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, and &lt;code&gt;DELETE&lt;&#x2F;code&gt; through them, and the rewrite rule translates the operation against the underlying table.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; active_customers (email, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;alice@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Alice&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- becomes: INSERT INTO customers (email, name, status, last_login_at, deleted_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--          VALUES (&amp;#39;alice@example.com&amp;#39;, &amp;#39;Alice&amp;#39;, DEFAULT, DEFAULT, DEFAULT);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Add a join, an aggregate, a &lt;code&gt;GROUP BY&lt;&#x2F;code&gt;, or anything else outside the auto-updatable rules and the write path goes silent. &lt;code&gt;SELECT&lt;&#x2F;code&gt; still works, but &lt;code&gt;INSERT&lt;&#x2F;code&gt; errors and you have to wire up &lt;code&gt;INSTEAD OF&lt;&#x2F;code&gt; triggers by hand. So &lt;code&gt;customer_orders&lt;&#x2F;code&gt; and &lt;code&gt;customer_summary&lt;&#x2F;code&gt; are read-only by accident, not by design, and the rules for what counts are not visible in the view definition itself. You find out at write time, in production.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;WITH CHECK OPTION&lt;&#x2F;code&gt; rejects any write that would produce a row the view cannot see, with &lt;code&gt;LOCAL&lt;&#x2F;code&gt; checking only this view&#x27;s predicate and &lt;code&gt;CASCADED&lt;&#x2F;code&gt; (the default) checking every underlying view up the chain. It is how you hand an application a writable, scoped slice of a table without trusting it to enforce the scope itself.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;views-and-row-level-security&quot;&gt;Views and row-level security&lt;a class=&quot;zola-anchor&quot; href=&quot;#views-and-row-level-security&quot; aria-label=&quot;Anchor link for: views-and-row-level-security&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;RLS policies live on tables, not views. For a regular view that matters less than it sounds: the policies still fire when the view&#x27;s query hits the base table. What determines whether they fire correctly is whose context the view runs under.&lt;&#x2F;p&gt;
&lt;p&gt;By default, a view runs as its owner. Superusers bypass RLS unconditionally; table owners bypass it unless &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;&#x2F;code&gt; is set. A view owned by a privileged role is therefore a hole in any RLS scheme, silently, because nothing in the view definition or the catalog signals it.&lt;&#x2F;p&gt;
&lt;p&gt;Since PostgreSQL 15 the fix is a single option:&lt;&#x2F;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 VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; my_view&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WITH&lt;&#x2F;span&gt;&lt;span&gt; (security_invoker &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; true) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  SELECT&lt;&#x2F;span&gt;&lt;span&gt; ... &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; base_table;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With &lt;code&gt;security_invoker&lt;&#x2F;code&gt; set, the view runs as the calling user and RLS evaluates against the actual caller. Before 15, the workaround was a dedicated low-privilege view owner plus &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;&#x2F;code&gt; on the base table.&lt;&#x2F;p&gt;
&lt;p&gt;Materialized views are a different problem. &lt;code&gt;REFRESH MATERIALIZED VIEW&lt;&#x2F;code&gt; runs as the owner, so the snapshot contains whatever the owner can see. Every user then reads that same materialized data. There is no per-row filtering because the rows are physically stored; &lt;code&gt;security_invoker&lt;&#x2F;code&gt; has nothing to attach to.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-architect-s-stigma&quot;&gt;The architect&#x27;s stigma&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-architect-s-stigma&quot; aria-label=&quot;Anchor link for: the-architect-s-stigma&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;When I first got into databases, views were the thing senior people warned you off. &quot;Don&#x27;t use views in production&quot;, &quot;views are slow&quot;, &quot;views hide the real query&quot;. You learned to write the SQL inline, even when the same predicate appeared in twenty places, because the alternative was disapproval at the next code review.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;In Oracle shops the warning had specific roots. The cost-based optimiser of that era struggled to merge predicates through views with non-trivial projections, joins, or set operations, so a clean three-layer view could expand into a plan that scanned everything and filtered late. The shop-wide answer was almost always materialised views or PL&#x2F;SQL packages that encapsulated every access path; a simple view sat awkwardly between the two, not cached like a matview, not encapsulated like a package, and with optimiser surprises on top.&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Postgres carried the same baggage by association. Nested-view spirals were real, rule-system surprises were real, and the optimiser of fifteen years ago was less capable than today&#x27;s. The rule of thumb hardened into doctrine and outlived its conditions.&lt;&#x2F;p&gt;
&lt;p&gt;The improvements are real; the reputation has not caught up. Reach for a view in a code review today and someone will still object on principle. Worth knowing before your PR hits review.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-happens-when-you-change-a-table&quot;&gt;What happens when you change a table&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-happens-when-you-change-a-table&quot; aria-label=&quot;Anchor link for: what-happens-when-you-change-a-table&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Everything covered so far stays invisible while the schema underneath holds still. View bodies live in &lt;code&gt;pg_rewrite&lt;&#x2F;code&gt;, columns are pinned to attribute numbers, types are frozen at &lt;code&gt;CREATE VIEW&lt;&#x2F;code&gt; time, and the planner just expands and runs.&lt;&#x2F;p&gt;
&lt;p&gt;The moment someone tries to change a column the view tree references, every one of those mechanics surfaces at once. Suppose someone needs to drop the &lt;code&gt;name&lt;&#x2F;code&gt; column from &lt;code&gt;customers&lt;&#x2F;code&gt;:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP&lt;&#x2F;span&gt;&lt;span&gt; COLUMN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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:  cannot drop column name of table customers because other objects depend on it&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DETAIL:  view active_customers depends on column name of table customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;HINT:  Use DROP ... CASCADE to drop the dependent objects too.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;active_customers&lt;&#x2F;code&gt; references &lt;code&gt;name&lt;&#x2F;code&gt; (the &lt;code&gt;c.*&lt;&#x2F;code&gt; was expanded at view-creation time), &lt;code&gt;customer_orders&lt;&#x2F;code&gt; inherits the dependency through &lt;code&gt;ac.*&lt;&#x2F;code&gt;, and &lt;code&gt;customer_summary&lt;&#x2F;code&gt; is another layer down. The whole tree is frozen on one column.&lt;&#x2F;p&gt;
&lt;p&gt;It is not just dropping. Try widening a type:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; COLUMN email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TYPE TEXT&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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:  cannot alter type of a column used by a view or rule&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DETAIL:  rule _RETURN on view active_customers depends on column &amp;quot;email&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The same wall blocks any structural change to a column a view references: drop, rename, type change. Even widening &lt;code&gt;VARCHAR(255)&lt;&#x2F;code&gt; to &lt;code&gt;TEXT&lt;&#x2F;code&gt;, a change that loses no data and adds no constraint, is forbidden because the view&#x27;s stored definition references the old type OID.&lt;&#x2F;p&gt;
&lt;p&gt;The hint suggests &lt;code&gt;CASCADE&lt;&#x2F;code&gt;:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP&lt;&#x2F;span&gt;&lt;span&gt; COLUMN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt; CASCADE;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;NOTICE:  drop cascades to 3 other objects&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DETAIL:  drop cascades to view active_customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;drop cascades to view customer_orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;drop cascades to view customer_summary&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;All three views are dropped, not modified, along with their grants and any other dependent objects. On a schema with dozens of interconnected views, &lt;code&gt;CASCADE&lt;&#x2F;code&gt; is nightmare.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Never use CASCADE on production views without a full recreation script ready.&lt;&#x2F;strong&gt;&lt;br&#x2F;&gt;&lt;br&#x2F;&gt;
CASCADE doesn&#x27;t modify views, it drops them. All GRANTs, row-level security policies, and downstream dependencies disappear with them. There is no undo.
&lt;&#x2F;div&gt;
&lt;p&gt;So the manual path is: save every view definition, drop them in reverse dependency order, alter the table, recreate them in forward dependency order, reapply all grants. For three views this is tedious. For thirty views across multiple schemas, it is a full migration project.&lt;&#x2F;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;-- 1. save definitions &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_get_viewdef(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;customer_summary&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, true);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_get_viewdef(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;customer_orders&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, true);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_get_viewdef(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;active_customers&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, true);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 2. drop in leaf-first order&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP VIEW&lt;&#x2F;span&gt;&lt;span&gt; customer_summary;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP VIEW&lt;&#x2F;span&gt;&lt;span&gt; customer_orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP VIEW&lt;&#x2F;span&gt;&lt;span&gt; active_customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 3. now you can alter the table&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP&lt;&#x2F;span&gt;&lt;span&gt; COLUMN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 4. recreate in correct order&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; active_customers&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, email, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, last_login_at, deleted_at&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; deleted_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;active&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span&gt; last_login_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt; now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;90 days&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_orders&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; ...;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_summary&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; ...;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 5. reapply grants and so on&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- ...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;the-select-trap&quot;&gt;The SELECT * trap&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-select-trap&quot; aria-label=&quot;Anchor link for: the-select-trap&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;You might think &lt;code&gt;SELECT *&lt;&#x2F;code&gt; saves you from column-level dependencies. It does not. It makes things worse.&lt;&#x2F;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 VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; all_customers&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This looks flexible. PostgreSQL expands &lt;code&gt;SELECT *&lt;&#x2F;code&gt; at &lt;strong&gt;view creation time&lt;&#x2F;strong&gt; and freezes the result. Check what the database actually stored:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_get_viewdef(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;all_customers&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass, true);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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; SELECT id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    email,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    status,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    last_login_at,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    deleted_at&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   FROM customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;*&lt;&#x2F;code&gt; was expanded to the columns that existed when the view was created. Now add a column to the base table:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD&lt;&#x2F;span&gt;&lt;span&gt; COLUMN phone &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; all_customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;phone&lt;&#x2F;code&gt; column is &lt;strong&gt;not there&lt;&#x2F;strong&gt;. The view still returns the original columns. To pick up new columns, you have to &lt;code&gt;CREATE OR REPLACE VIEW all_customers AS SELECT * FROM customers&lt;&#x2F;code&gt;, which re-expands the &lt;code&gt;*&lt;&#x2F;code&gt; against the current table definition.&lt;&#x2F;p&gt;
&lt;p&gt;Drop a column that was in the original expansion and the migration is blocked the same way it would be with an explicit column list, except now the dependency is hidden in the catalog instead of visible in the view body. &lt;code&gt;SELECT *&lt;&#x2F;code&gt; gives the illusion of flexibility while creating the same rigid coupling.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;This is actually documented behaviour and follows the SQL standard. But it surprises nearly everyone the first time they encounter it, and it is the source of many &quot;my view is missing columns&quot; bug reports.&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Always use explicit column lists in views.&lt;&#x2F;strong&gt; At least then the dependency is visible and the breakage is predictable.&lt;&#x2F;p&gt;
&lt;p&gt;The dependency wall, the type-OID coupling, the frozen &lt;code&gt;SELECT *&lt;&#x2F;code&gt; expansion: all of it is the same trade-off, applied consistently. Rigidity is the feature, not a bug.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-this-isn-t-a-postgresql-bug&quot;&gt;Where this isn&#x27;t a PostgreSQL bug&lt;a class=&quot;zola-anchor&quot; href=&quot;#where-this-isn-t-a-postgresql-bug&quot; aria-label=&quot;Anchor link for: where-this-isn-t-a-postgresql-bug&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The rigidity is a design choice, not a defect. Other databases made different choices, and none of them are free.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Oracle&lt;&#x2F;strong&gt; marks dependent views as &lt;code&gt;INVALID&lt;&#x2F;code&gt; rather than blocking the DDL. The views are recompiled automatically on next access. If they still work, great; if not, you get an error at query time. This sounds better until you realize what it means in practice: you can deploy a migration, get a clean exit code, and not discover that a critical reporting view is broken until a user hits it on Monday morning. The breakage moves from migration time, where you are paying attention, to runtime, where you may not be. For teams that value deployment confidence, this is arguably worse.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;SQL Server&lt;&#x2F;strong&gt; has &lt;code&gt;sp_refreshview&lt;&#x2F;code&gt;, which recompiles a view&#x27;s metadata against the current table definitions. You can alter a table and then refresh the dependent views to pick up the changes. But &lt;code&gt;sp_refreshview&lt;&#x2F;code&gt; works on one view at a time. There is no built-in way to refresh an entire dependency chain in the right order. And if a view references a dropped column, &lt;code&gt;sp_refreshview&lt;&#x2F;code&gt; fails; it does not remove the reference for you. You still have to manually edit and recreate the view. It is a convenience, not a solution.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL chose compile-time safety: no surprise &lt;code&gt;INVALID&lt;&#x2F;code&gt; views in production, no silently wrong results from a lazy recompilation papering over a structural change. The cost is manual dependency management on every schema change, and it is high enough to push experienced teams away from views entirely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-saving-grace-transactional-ddl&quot;&gt;The saving grace: transactional DDL&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-saving-grace-transactional-ddl&quot; aria-label=&quot;Anchor link for: the-saving-grace-transactional-ddl&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Before the workarounds, there is one PostgreSQL feature that materially changes the risk calculus: &lt;strong&gt;DDL is transactional&lt;&#x2F;strong&gt;. The entire drop-alter-recreate-regrant sequence can live inside a single &lt;code&gt;BEGIN&#x2F;COMMIT&lt;&#x2F;code&gt;:&lt;&#x2F;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;BEGIN&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP VIEW&lt;&#x2F;span&gt;&lt;span&gt; customer_summary;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP VIEW&lt;&#x2F;span&gt;&lt;span&gt; customer_orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP VIEW&lt;&#x2F;span&gt;&lt;span&gt; active_customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP&lt;&#x2F;span&gt;&lt;span&gt; COLUMN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; active_customers&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; ...;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_orders&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; ...;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_summary&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; ...;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT SELECT ON&lt;&#x2F;span&gt;&lt;span&gt; customer_summary &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; reporting;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;COMMIT&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If any statement raises (a typo in a recreated view, a missing grant, an unexpected dependency) the whole migration rolls back and the database is exactly as it was. Oracle and SQL Server cannot do this for most DDL; their &lt;code&gt;CREATE VIEW&lt;&#x2F;code&gt; auto-commits, so a partial failure leaves you with a half-migrated schema and no &lt;code&gt;ROLLBACK&lt;&#x2F;code&gt; to lean on. PostgreSQL&#x27;s &quot;you have to drop and recreate everything&quot; pain is real, but the recovery story is much better than Oracle&#x27;s or SQL Server&#x27;s.&lt;&#x2F;p&gt;
&lt;p&gt;This does not solve the locking problem. &lt;code&gt;DROP VIEW&lt;&#x2F;code&gt; and &lt;code&gt;CREATE VIEW&lt;&#x2F;code&gt; take &lt;code&gt;AccessExclusiveLock&lt;&#x2F;code&gt; on the view; &lt;code&gt;ALTER TABLE&lt;&#x2F;code&gt; takes one on the table. For the duration of the transaction, anything that touches those objects waits behind it. A long view-rebuild transaction on a busy system is a stop-the-world window. Keep the transaction tight: prepare the new SQL outside the transaction, hold no other locks, and do not run it during peak traffic. Transactional DDL gives you safety, not concurrency.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-workarounds&quot;&gt;The workarounds&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-workarounds&quot; aria-label=&quot;Anchor link for: the-workarounds&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;None of these solve the fundamental problem; they make it manageable.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Avoid views entirely.&lt;&#x2F;strong&gt; Replace views with application-level query builders or ORM scopes. You lose the shared abstraction: when three services need the same definition of &quot;active customer,&quot; each one implements it independently. Definitions drift. For teams with full control over a small codebase, this is a reasonable trade. For shared data platforms, it is not.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Script the recreation.&lt;&#x2F;strong&gt; Use &lt;code&gt;pg_get_viewdef()&lt;&#x2F;code&gt; to extract definitions, then write migration scripts that drop and recreate views around schema changes. The problems: no impact analysis (you have to figure out the dependency order yourself), no grant preservation (view definitions do not include permissions), and it does not scale past a handful of views. Your migration framework will not help here either. They run your SQL files in order, but figuring out &lt;em&gt;what&lt;&#x2F;em&gt; SQL to write is entirely on you.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Version your views.&lt;&#x2F;strong&gt; Instead of modifying views in place, create &lt;code&gt;active_customers_v2&lt;&#x2F;code&gt; alongside &lt;code&gt;active_customers_v1&lt;&#x2F;code&gt;. Migrate consumers one at a time. Drop the old version when nothing uses it. This works well when views are consumed by multiple independent teams, since you cannot force everyone to migrate in the same deployment window. The trade-off is naming discipline and version proliferation: if &lt;code&gt;customer_orders_v1&lt;&#x2F;code&gt; depends from &lt;code&gt;active_customers_v1&lt;&#x2F;code&gt;, you now need &lt;code&gt;customer_orders_v2&lt;&#x2F;code&gt; too.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;Schema-based versioning (&lt;code&gt;CREATE SCHEMA api_v2; CREATE VIEW api_v2.active_customers AS ...&lt;&#x2F;code&gt;) is cleaner than suffixes for public-facing APIs. It gives you a natural namespace and lets you &lt;code&gt;SET search_path&lt;&#x2F;code&gt; to switch versions.&lt;&#x2F;span&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Query pg_depend yourself.&lt;&#x2F;strong&gt; PostgreSQL tracks all object dependencies in the &lt;code&gt;pg_depend&lt;&#x2F;code&gt; system catalog. The catch: views do not depend on tables directly. The rewrite rule that implements the view does, so every traversal goes &lt;code&gt;pg_depend -&amp;gt; pg_rewrite -&amp;gt; pg_class&lt;&#x2F;code&gt;. Direct view dependents of &lt;code&gt;customers&lt;&#x2F;code&gt;:&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    depns&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;nspname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; dependent_schema,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AS&lt;&#x2F;span&gt;&lt;span&gt; dependent_view,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    COALESCE&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;attname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_attribute a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;attrelid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;refobjid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;attnum&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;refobjsubid&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;        &amp;#39;*&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; source_column&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_depend d&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_rewrite r       &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;objid&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_class depc      &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;ev_class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_namespace depns &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depns&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relnamespace&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;refobjid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;customers&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;classid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;     =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_rewrite&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;refclassid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_class&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relkind&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;v&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;m&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;     &amp;lt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;customers&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depns&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;nspname&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relname&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Useful, but only one hop. &lt;code&gt;customer_summary&lt;&#x2F;code&gt; depends on &lt;code&gt;customers&lt;&#x2F;code&gt; transitively through two intermediate views, which this query misses. A recursive CTE walks the chain:&lt;&#x2F;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;WITH RECURSIVE&lt;&#x2F;span&gt;&lt;span&gt; view_deps &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- direct dependents of the target table&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT DISTINCT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;        depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;                              AS&lt;&#x2F;span&gt;&lt;span&gt; view_oid,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;        depns&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;nspname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;.&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AS&lt;&#x2F;span&gt;&lt;span&gt; view_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;        1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;                                     AS&lt;&#x2F;span&gt;&lt;span&gt; depth&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_depend d&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_rewrite r       &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;objid&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_class depc      &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;ev_class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_namespace depns &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depns&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relnamespace&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;refobjid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;customers&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;      AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;classid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_rewrite&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;      AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relkind&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;v&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;m&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    UNION&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- views depending on views we already found&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;           depns&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;nspname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;.&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relname&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;           vd&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;depth&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; view_deps vd&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_depend d        &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;refobjid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; vd&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;view_oid&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_rewrite r       &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;objid&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_class depc      &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;ev_class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_namespace depns &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depns&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relnamespace&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; d&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;classid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_rewrite&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::regclass&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;      AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relkind&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;v&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;m&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;      AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; depc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; vd&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;view_oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  -- skip the view&amp;#39;s own _RETURN rule&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; view_name, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;MIN&lt;&#x2F;span&gt;&lt;span&gt;(depth) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; depth&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; view_deps&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; view_name&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; depth, view_name;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;depc.oid &amp;lt;&amp;gt; vd.view_oid&lt;&#x2F;code&gt; filter matters: each view&#x27;s &lt;code&gt;_RETURN&lt;&#x2F;code&gt; rule has a self-dependency entry that without the filter sends the walk back to the view it just visited.&lt;&#x2F;p&gt;
&lt;p&gt;This works, but at this point you are writing a dependency-analysis tool. Add topological sort, grant capture, matview handling, RLS-policy preservation, and you have small product.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Materialized views have it worse.&lt;&#x2F;strong&gt; Everything above applies, plus you lose the cached result set on &lt;code&gt;DROP&lt;&#x2F;code&gt;. After recreating, &lt;code&gt;REFRESH&lt;&#x2F;code&gt; rebuilds from scratch: minutes to hours on large datasets, with no &lt;code&gt;CONCURRENTLY&lt;&#x2F;code&gt; option until a unique index exists. For matviews that back dashboards, this means downtime. RLS is also off the table: &lt;code&gt;REFRESH&lt;&#x2F;code&gt; runs as the matview owner, so the snapshot reflects the owner&#x27;s access, not the querying user&#x27;s.
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;prior-art-hiding-in-pg-dump&quot;&gt;Prior art hiding in pg_dump&lt;a class=&quot;zola-anchor&quot; href=&quot;#prior-art-hiding-in-pg-dump&quot; aria-label=&quot;Anchor link for: prior-art-hiding-in-pg-dump&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The mechanism the manual workaround keeps reinventing, rewriting a view&#x27;s body while preserving its identity, grants, and dependents, already exists inside PostgreSQL. It happens to live in &lt;code&gt;pg_dump&lt;&#x2F;code&gt;, used only when needed.&lt;&#x2F;p&gt;
&lt;p&gt;Most of the time &lt;code&gt;pg_dump&lt;&#x2F;code&gt; does the obvious thing: builds a dependency graph, topologically sorts it, and emits each view in base-first order with a normal &lt;code&gt;CREATE VIEW&lt;&#x2F;code&gt;. Dump our four-view chain and that is exactly what you get.&lt;&#x2F;p&gt;
&lt;p&gt;The interesting case is when the sort fails. Cycles are uncommon but real: a view body that calls a function whose own body references the view back, a trigger on a base table that reads through a view reading the same table, cross-referencing matviews with RLS policies pointing back at views. When &lt;code&gt;pg_dump&lt;&#x2F;code&gt; hits one, it falls back to the &lt;strong&gt;placeholder view trick&lt;&#x2F;strong&gt;: emit one of the views early as a stub with the right column list and types but a dummy body, then come back with &lt;code&gt;CREATE OR REPLACE VIEW&lt;&#x2F;code&gt; to install the real definition once the rest of the cycle exists.&lt;&#x2F;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;-- emitted early, as a placeholder&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_summary&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    NULL&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer AS&lt;&#x2F;span&gt;&lt;span&gt; id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    NULL&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text    AS&lt;&#x2F;span&gt;&lt;span&gt; email,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    NULL&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text    AS name&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    NULL&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint  AS&lt;&#x2F;span&gt;&lt;span&gt; orders_12mo,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    NULL&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint  AS&lt;&#x2F;span&gt;&lt;span&gt; revenue_12mo_cents;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- emitted later, once dependencies exist&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE OR REPLACE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_summary&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;email&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;       COUNT&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;order_id&lt;&#x2F;span&gt;&lt;span&gt;)               &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; orders_12mo,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;       COALESCE&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;SUM&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total_cents&lt;&#x2F;span&gt;&lt;span&gt;), &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; revenue_12mo_cents&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; customer_orders co&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;email&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;co&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Grants, comments, and policies attach to the stub and survive the rewrite because the OID never changes. &lt;code&gt;CREATE OR REPLACE VIEW&lt;&#x2F;code&gt; mutates the same &lt;code&gt;pg_class&lt;&#x2F;code&gt; row in place. PostgreSQL has been doing this on every &lt;code&gt;pg_dump --schema-only&lt;&#x2F;code&gt; for years. The mechanism is there; it is simply not exposed as user-facing DDL.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;before-you-reach-for-a-view&quot;&gt;Before you reach for a VIEW&lt;a class=&quot;zola-anchor&quot; href=&quot;#before-you-reach-for-a-view&quot; aria-label=&quot;Anchor link for: before-you-reach-for-a-view&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Views are worth using. The abstraction is real: shared definitions, clean layering, column-level security, and no runtime cost when the planner can inline them. The trouble is that none of the rigidity is visible from the outside. A view looks like a table in the catalog, in &lt;code&gt;\d&lt;&#x2F;code&gt;, in the ORM, and in every query that touches it. The teardown cost only appears the first time someone tries to drop a column, widen a type, or &lt;code&gt;CASCADE&lt;&#x2F;code&gt; their way out of a migration on a Friday afternoon.&lt;&#x2F;p&gt;
&lt;p&gt;So before you reach for one, hold the trade-off in mind:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SELECT *&lt;&#x2F;code&gt; in a view body is a trap.&lt;&#x2F;strong&gt; It freezes the column list at creation time, hides the dependency in the catalog, and still blocks the same DDL an explicit list would. Always write the columns out.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Every layer multiplies the teardown cost.&lt;&#x2F;strong&gt; A three-deep view chain means three drops, three recreates, three sets of grants and policies to reapply, in the right order, for any structural change to a column at the bottom. Keep dependency trees shallow, and be honest about whether the abstraction is paying for itself.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;CASCADE&lt;&#x2F;code&gt; is not a fix.&lt;&#x2F;strong&gt; It drops dependent views along with their grants, RLS policies, and downstream dependencies, with no undo. Never run it in production without a recreation script ready.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;security_invoker&lt;&#x2F;code&gt; is not the default.&lt;&#x2F;strong&gt; A view runs as its owner, which silently bypasses RLS on base tables when the owner is privileged. On PostgreSQL 15+, &lt;code&gt;WITH (security_invoker = true)&lt;&#x2F;code&gt; is what makes the security boundary real. For materialized views there is no equivalent. Enforce access at the layer that reads the matview, never below it.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;When the migration finally has to happen, two things make the cycle survivable. Wrap the whole drop-alter-recreate-regrant sequence in a transaction so a typo or missed grant rolls back cleanly, and keep that transaction tight — &lt;code&gt;AccessExclusiveLock&lt;&#x2F;code&gt; blocks everything else for its duration. Then map the dependency graph before you cut: &lt;code&gt;pg_depend&lt;&#x2F;code&gt; joined through &lt;code&gt;pg_rewrite&lt;&#x2F;code&gt;, walked with a recursive CTE, tells you what actually breaks. Run that query before the migration, not after the incident.&lt;&#x2F;p&gt;
&lt;p&gt;The pain traces back to one missing primitive. &lt;code&gt;ALTER VIEW&lt;&#x2F;code&gt; today handles renames, owner and schema changes, column defaults, and options like &lt;code&gt;security_barrier&lt;&#x2F;code&gt;, but nothing structural. &lt;code&gt;CREATE OR REPLACE VIEW&lt;&#x2F;code&gt; can append columns at the end and nothing else. A real &lt;code&gt;ALTER VIEW DROP COLUMN&lt;&#x2F;code&gt;, &lt;code&gt;ADD COLUMN&lt;&#x2F;code&gt;, &lt;code&gt;ALTER COLUMN TYPE&lt;&#x2F;code&gt; is what would make views safe to evolve. The catalog and the in-place-rewrite mechanism are already there, as &lt;code&gt;pg_dump&lt;&#x2F;code&gt;&#x27;s placeholder trick demonstrates; the user-facing DDL is what is missing.&lt;&#x2F;p&gt;
&lt;p&gt;Even without it, views are still worth using. Just don&#x27;t pretend they&#x27;re tables.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;EDIT (2026-05-17)&lt;&#x2F;em&gt;: The original article understated how RLS interacts with views. Added a section on &lt;code&gt;security_invoker&lt;&#x2F;code&gt; and why the ownership model matters, plus a note on why materialized views are structurally incompatible with RLS.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Don&#x27;t let AI touch your production database</title>
        <published>2026-04-06T22:24:00+02:00</published>
        <updated>2026-04-06T22:24:00+02:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/dont-let-ai-to-prod/"/>
        <id>https://boringsql.com/posts/dont-let-ai-to-prod/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/dont-let-ai-to-prod/">&lt;p&gt;Not so long ago, the biggest threat to production databases was the developer who claimed it worked on their machine. If you&#x27;ve attended my sessions, you know this is a topic I&#x27;m particularly sensitive to.&lt;&#x2F;p&gt;
&lt;p&gt;These days, AI agents are writing your SQL. The models are getting incredibly good at producing &lt;em&gt;plausible&lt;&#x2F;em&gt; code. It looks right, it feels right, and often it passes a cursory glance. But &quot;plausible&quot; isn&#x27;t a performance metric, and it doesn&#x27;t care about your execution plan or locking strategy.&lt;&#x2F;p&gt;
&lt;p&gt;AI-generated SQL is syntactically correct, which is the easy part. The hard part is knowing what a statement &lt;em&gt;does&lt;&#x2F;em&gt; to a running system: which locks it takes, how long it holds them, whether it rewrites the table on disk.&lt;&#x2F;p&gt;
&lt;p&gt;The spectacular failures get the headlines. In July 2025, an AI coding agent &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;fortune.com&#x2F;2025&#x2F;07&#x2F;23&#x2F;ai-coding-tool-replit-wiped-database-called-it-a-catastrophic-failure&#x2F;&quot;&gt;wiped a production database&lt;&#x2F;a&gt; during a code freeze -- ran destructive commands, panicked, then lied about what it had done.&lt;&#x2F;p&gt;
&lt;p&gt;But the biggest damage is quieter. It&#x27;s the migration that passes every test, ships through CI and then locks a production table during peak traffic. The query written with random assumptions. Indexes added based on copy&#x2F;paste from &lt;code&gt;psql&lt;&#x2F;code&gt;. The cumulative effect that builds over time, when nobody is looking.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-to-give-ai-agent-eyes&quot;&gt;How to give AI agent &quot;eyes&quot;&lt;a class=&quot;zola-anchor&quot; href=&quot;#how-to-give-ai-agent-eyes&quot; aria-label=&quot;Anchor link for: how-to-give-ai-agent-eyes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When you&#x27;re running Claude Code or any other agentic coding tool, the bottleneck isn&#x27;t the model&#x27;s intelligence. It&#x27;s the fidelity of the environment. Standard AI coding involves the agent guessing column names based on your description, or in better cases parsing a &lt;code&gt;schema.sql&lt;&#x2F;code&gt; file or using a local database with seed data.&lt;&#x2F;p&gt;
&lt;p&gt;None of these give the agent the one thing it actually needs: awareness of your &lt;em&gt;production&lt;&#x2F;em&gt; schema. The table sizes, the indexes, the constraints, the statistics that determine whether a query index-scans in 2ms or sequential-scans for 40 seconds.&lt;&#x2F;p&gt;
&lt;p&gt;The obvious fix is to give it a database connection. Let it query &lt;code&gt;pg_catalog&lt;&#x2F;code&gt;, read table sizes, check existing indexes. This is how Anthropic&#x27;s reference PostgreSQL MCP server worked, and that&#x27;s the one Datadog Security Labs &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;securitylabs.datadoghq.com&#x2F;articles&#x2F;mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server&#x2F;&quot;&gt;found a SQL injection in&lt;&#x2F;a&gt;. The vulnerable npm package still gets 21,000 downloads per week. The server wrapped queries in read-only transactions, but accepted semicolons. &lt;code&gt;COMMIT; DROP SCHEMA public CASCADE;&lt;&#x2F;code&gt; works because the &lt;code&gt;COMMIT&lt;&#x2F;code&gt; ends the read-only transaction and everything after runs with full privileges. Read-only transactions are not a security boundary when the protocol accepts semicolons.&lt;&#x2F;p&gt;
&lt;p&gt;But here&#x27;s the thing: the AI doesn&#x27;t need a &lt;em&gt;connection&lt;&#x2F;em&gt;. It needs the &lt;em&gt;information&lt;&#x2F;em&gt;. Schema metadata is structural data. You can extract it once, save it as a JSON file, commit it to git, and hand that file to every tool in your stack.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
&lt;a href=&quot;https:&#x2F;&#x2F;modelcontextprotocol.io&quot;&gt;MCP&lt;&#x2F;a&gt; is an open standard for connecting AI assistants to external tools and data sources. Instead of relying on training data, the agent calls structured tools exposed by an MCP server and gets deterministic answers back.
&lt;&#x2F;div&gt;
&lt;p&gt;What&#x27;s been missing is a standard way to wire that information into the agent&#x27;s workflow. Not a one-time dump that sits in a docs folder, but a protocol that lets the agent &lt;em&gt;call tools&lt;&#x2F;em&gt; and get structured answers back. That protocol is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;modelcontextprotocol.io&quot;&gt;MCP&lt;&#x2F;a&gt; (Model Context Protocol).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-are-postgresql-mcp-servers-actually-for&quot;&gt;What are PostgreSQL MCP servers actually for?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-are-postgresql-mcp-servers-actually-for&quot; aria-label=&quot;Anchor link for: what-are-postgresql-mcp-servers-actually-for&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;This has been bugging me. If you search for &quot;PostgreSQL MCP server&quot; today, you&#x27;ll find two kinds.&lt;&#x2F;p&gt;
&lt;p&gt;The first is a connection wrapper. You give it a &lt;code&gt;DATABASE_URL&lt;&#x2F;code&gt;, it exposes tools like &lt;code&gt;query&lt;&#x2F;code&gt; and &lt;code&gt;execute&lt;&#x2F;code&gt;. This is &lt;code&gt;psql&lt;&#x2F;code&gt; with extra steps. The Anthropic reference server that got the SQL injection? Connection wrapper. The dozen community forks that replaced it? Connection wrappers with slightly better input validation.&lt;&#x2F;p&gt;
&lt;p&gt;The second is a managed-provider gateway. Cloud database platforms expose their platform APIs through MCP. Create a branch, provision a database, manage users. Useful if you&#x27;re on that platform, but not database intelligence. Control-plane operations wearing a protocol hat.&lt;&#x2F;p&gt;
&lt;p&gt;Neither kind answers the question an AI agent actually needs answered: &lt;em&gt;&quot;is this SQL going to be a problem?&quot;&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A connection wrapper can run &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt;, if you trust it with credentials. But who tells the agent that &lt;code&gt;SET NOT NULL&lt;&#x2F;code&gt; on a 4-million-row table takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;&#x2F;code&gt; lock? Who tells it that its query defeats partition pruning? That&#x27;s not a connection problem. It&#x27;s a knowledge problem.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dryrun&quot;&gt;dryrun&lt;a class=&quot;zola-anchor&quot; href=&quot;#dryrun&quot; aria-label=&quot;Anchor link for: dryrun&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;I didn&#x27;t set out to build an AI tool. My primary project is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;products&#x2F;regresql&#x2F;&quot;&gt;RegreSQL&lt;&#x2F;a&gt;, a statistics-based SQL regression testing framework. RegreSQL needs production statistics to produce realistic query plans in test environments, and dryrun&#x27;s schema capture was originally just the plumbing to get those statistics into the right place.&lt;&#x2F;p&gt;
&lt;p&gt;But driving adoption of a testing tool is hard. The best place to plug in is where the coding actually happens. And these days, that&#x27;s increasingly AI agents. Instead of fighting it, I turned the plumbing into a proper tool.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;products&#x2F;dryrun&#x2F;&quot;&gt;dryrun&lt;&#x2F;a&gt; is a PostgreSQL schema intelligence tool. It ships as a CLI and as an MCP server. It doesn&#x27;t connect to your database at runtime. Instead, it works from a JSON snapshot of your schema, captured once, committed to git, and available to everyone.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;offline-first&quot;&gt;Offline first&lt;a class=&quot;zola-anchor&quot; href=&quot;#offline-first&quot; aria-label=&quot;Anchor link for: offline-first&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;One person with production access captures the schema:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dump-schema&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --source&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;$PROD_URL&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --pretty --name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;production&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -o&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The JSON contains everything structural: tables, columns, constraints, indexes, and the rest of what you&#x27;d find in &lt;code&gt;pg_catalog&lt;&#x2F;code&gt;. It also captures per-table statistics from &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; (histograms, most common values, correlation, &lt;code&gt;n_distinct&lt;&#x2F;code&gt;) and runtime counters from &lt;code&gt;pg_class&lt;&#x2F;code&gt; like &lt;code&gt;reltuples&lt;&#x2F;code&gt;, &lt;code&gt;seq_scan&lt;&#x2F;code&gt;, and &lt;code&gt;idx_scan&lt;&#x2F;code&gt;. One file, typically 200-500KB. Structural metadata, not row data.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
If your schema uses &lt;code&gt;COMMENT ON&lt;&#x2F;code&gt; (and it should), dryrun captures those too. A column named &lt;code&gt;status&lt;&#x2F;code&gt; is ambiguous. &lt;code&gt;COMMENT ON COLUMN orders.status IS &#x27;pending | completed | refunded, set by payment processor webhook&#x27;&lt;&#x2F;code&gt; tells the agent what the values mean, where they come from, and that it shouldn&#x27;t invent new ones.
&lt;p&gt;If you don&#x27;t take my word for it, check what Bruce Momjian &lt;a href=&quot;https:&#x2F;&#x2F;momjian.us&#x2F;main&#x2F;blogs&#x2F;pgblog&#x2F;2026.html#March_16_2026&quot;&gt;has to say&lt;&#x2F;a&gt; about it. MCP clients can only work with schemas, object names, and constraints. Comments are the simplest way to supply the semantic context that makes AI-generated queries meaningful.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;Import it and every subsequent command works offline:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; import schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No connection needed, no credentials on developer machines or in CI.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;as-an-mcp-server&quot;&gt;As an MCP server&lt;a class=&quot;zola-anchor&quot; href=&quot;#as-an-mcp-server&quot; aria-label=&quot;Anchor link for: as-an-mcp-server&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;claude&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; mcp add dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dryrun mcp-serve&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Claude now has 16 tools for exploring your schema, validating queries, checking migrations, and suggesting improvements, all from the offline snapshot.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-the-agent-gets&quot;&gt;What the agent gets&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-the-agent-gets&quot; aria-label=&quot;Anchor link for: what-the-agent-gets&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;dryrun exposes 16 tools through MCP. The four that matter most for the argument in this article:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;check_migration&lt;&#x2F;strong&gt; is the one I find myself using most often, even outside of AI workflows. It analyzes DDL statements for lock implications, table rewrites, and version-specific behavior, and returns the safe multi-step alternative when one exists. Every migration example in this article was caught by this tool.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;suggest_index&lt;&#x2F;strong&gt; reads column statistics from the snapshot and recommends indexes informed by your actual data distribution. Not &quot;add an index on this column&quot; but &quot;add a partial index on the 3% of rows that match your query pattern.&quot; It also uses correlation data to recommend BRIN indexes where appropriate: 3MB instead of 400MB for a B-tree on a column with near-perfect insertion order.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;validate_query&lt;&#x2F;strong&gt; parses SQL against the schema snapshot without executing it. It catches references to tables and columns that don&#x27;t exist, detects anti-patterns, and flags issues before the query goes anywhere near the database.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;lint_schema&lt;&#x2F;strong&gt; runs 18 convention rules and 14 structural audit checks: naming, types, constraints, timestamps, partition health, duplicate indexes, foreign key columns missing indexes, tables without primary keys. All disableable in &lt;code&gt;dryrun.toml&lt;&#x2F;code&gt;, because not every convention fits every project.&lt;&#x2F;p&gt;
&lt;p&gt;The remaining tools cover schema exploration (&lt;strong&gt;list_tables&lt;&#x2F;strong&gt;, &lt;strong&gt;describe_table&lt;&#x2F;strong&gt;, &lt;strong&gt;search_schema&lt;&#x2F;strong&gt;, &lt;strong&gt;find_related&lt;&#x2F;strong&gt;), operations (&lt;strong&gt;compare_nodes&lt;&#x2F;strong&gt;, &lt;strong&gt;detect&lt;&#x2F;strong&gt;, &lt;strong&gt;vacuum_health&lt;&#x2F;strong&gt;), execution plans (&lt;strong&gt;explain_query&lt;&#x2F;strong&gt;), and schema maintenance (&lt;strong&gt;refresh_schema&lt;&#x2F;strong&gt;, &lt;strong&gt;check_drift&lt;&#x2F;strong&gt;). The full list is in the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringsql&#x2F;dryrun&quot;&gt;documentation&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;a class=&quot;zola-anchor&quot; href=&quot;#try-it-yourself&quot; aria-label=&quot;Anchor link for: try-it-yourself&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s a schema and seed data you can run in any local PostgreSQL:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customers&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name text NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz NOT NULL DEFAULT now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status text NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    total_amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz NOT NULL DEFAULT now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Seed it with data that has the distributions that matter, like skewed status values and nullable emails:&lt;&#x2F;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;-- 100K customers, ~5% with NULL email&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; customers (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, email, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;customer_&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; i,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;05&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;user&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    END&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2023-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;&#x2F;span&gt;&lt;span&gt; (i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; seconds&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)::interval&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; i;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 1M orders, 97% completed &#x2F; 3% pending&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders (customer_id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, total_amount, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span&gt; (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 99999&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;03&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;         ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;completed&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    END&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2024-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;&#x2F;span&gt;&lt;span&gt; (i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; seconds&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)::interval&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000000&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; i;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now capture the schema for dryrun and install it as an MCP server:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dump-schema&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --source&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;$DATABASE_URL&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -o&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; init&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; import schema.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;claude&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; mcp add dryrun&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; dryrun mcp-serve&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Open Claude Code and try these prompts. First without the dryrun MCP server, then with it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;add-an-index-to-speed-up-queries-that-filter-orders-by-status&quot;&gt;&quot;Add an index to speed up queries that filter orders by status&quot;&lt;a class=&quot;zola-anchor&quot; href=&quot;#add-an-index-to-speed-up-queries-that-filter-orders-by-status&quot; aria-label=&quot;Anchor link for: add-an-index-to-speed-up-queries-that-filter-orders-by-status&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Without dryrun, Claude sees a &lt;code&gt;text&lt;&#x2F;code&gt; column and does the obvious thing:&lt;&#x2F;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 INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; idx_orders_status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; orders (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A full B-tree index on 1 million rows. It works, but 97% of those rows are &lt;code&gt;&#x27;completed&#x27;&lt;&#x2F;code&gt; and nobody queries for completed orders.&lt;&#x2F;p&gt;
&lt;p&gt;With dryrun, Claude calls &lt;strong&gt;suggest_index&lt;&#x2F;strong&gt;, sees &lt;code&gt;most_common_freqs&lt;&#x2F;code&gt; showing &lt;code&gt;&#x27;completed&#x27;&lt;&#x2F;code&gt; at 97% and &lt;code&gt;&#x27;pending&#x27;&lt;&#x2F;code&gt; at 3%, and suggests:&lt;&#x2F;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 INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; idx_orders_status_pending&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; orders (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The full index maintains 1 million entries. The partial index maintains 30,000. Every INSERT that creates a &lt;code&gt;&#x27;completed&#x27;&lt;&#x2F;code&gt; order no longer pays for index maintenance it&#x27;ll never use.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;make-the-email-column-on-customers-required&quot;&gt;&quot;Make the email column on customers required&quot;&lt;a class=&quot;zola-anchor&quot; href=&quot;#make-the-email-column-on-customers-required&quot; aria-label=&quot;Anchor link for: make-the-email-column-on-customers-required&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Without dryrun, Claude writes a single statement and moves on:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; COLUMN email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This fails immediately because 5% of rows have NULL emails. Claude didn&#x27;t know the data existed. Even if the column had no NULLs, the statement would still take an &lt;code&gt;ACCESS EXCLUSIVE&lt;&#x2F;code&gt; lock and perform a full table scan to verify.&lt;&#x2F;p&gt;
&lt;p&gt;With dryrun, Claude calls &lt;strong&gt;check_migration&lt;&#x2F;strong&gt; and &lt;strong&gt;describe_table&lt;&#x2F;strong&gt;. It sees 100,000 rows, knows the column is nullable, and knows from the statistics that NULLs exist. It generates a three-step migration:&lt;&#x2F;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;-- Step 1: backfill NULLs&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;&#x2F;span&gt;&lt;span&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SET&lt;&#x2F;span&gt;&lt;span&gt; email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;unknown-&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span&gt; email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Step 2: add constraint without full table scan&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    ADD CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; customers_email_not_null&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CHECK&lt;&#x2F;span&gt;&lt;span&gt; (email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT&lt;&#x2F;span&gt;&lt;span&gt; VALID;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Step 3: validate in the background with a weaker lock&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    VALIDATE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; customers_email_not_null;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The first step handles the data. The second adds the constraint without scanning existing rows. The third validates with a weaker lock that allows concurrent reads and writes. Three statements instead of one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;keeping-the-snapshot-fresh&quot;&gt;Keeping the snapshot fresh&lt;a class=&quot;zola-anchor&quot; href=&quot;#keeping-the-snapshot-fresh&quot; aria-label=&quot;Anchor link for: keeping-the-snapshot-fresh&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun is only as good as your last snapshot. If the schema changed yesterday and nobody re-dumped, the agent is working from stale data. The &lt;strong&gt;check_drift&lt;&#x2F;strong&gt; tool can detect this, but only if someone runs it.&lt;&#x2F;p&gt;
&lt;p&gt;The best approach is to treat schema snapshots like you treat production backups: automate them. If you&#x27;re already capturing production schema dumps on a schedule, add dryrun to the same pipeline. A nightly &lt;code&gt;dump-schema&lt;&#x2F;code&gt; from the primary gives you current structure and statistics. If you run read replicas with different workloads, add &lt;code&gt;--stats-only&lt;&#x2F;code&gt; dumps from each node for higher-fidelity statistics across the cluster. Commit the resulting &lt;code&gt;schema.json&lt;&#x2F;code&gt; to your repository so every developer and every AI agent starts the day with a current view.&lt;&#x2F;p&gt;
&lt;p&gt;The snapshot also serves as a lightweight audit trail. If someone adds a column through &lt;code&gt;psql&lt;&#x2F;code&gt; without a migration, the next scheduled dump captures it, and the diff in git shows exactly what changed and when.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-dryrun-is-not&quot;&gt;What dryrun is not&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-dryrun-is-not&quot; aria-label=&quot;Anchor link for: what-dryrun-is-not&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun does not execute arbitrary SQL against your database. It does not manage migrations. It does not provision infrastructure. It is not an ORM, a query builder, or a database admin panel. It&#x27;s read-only and narrow by design, trustworthy enough that you&#x27;d let an AI agent use it unsupervised.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s next&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-s-next&quot; aria-label=&quot;Anchor link for: what-s-next&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun ships today at v0.5.0. The next releases are focused on:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Shadow EXPLAIN&lt;&#x2F;strong&gt; (v0.6.0): inject production statistics into a local PostgreSQL instance so &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; produces realistic plans from your laptop, without any production data. Currently &lt;strong&gt;explain_query&lt;&#x2F;strong&gt; runs against whatever local database you point it at, but if that database has 10 rows in every table, the planner gives you toy plans that tell you nothing about production behavior.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;CI&#x2F;CD Trust Gate&lt;&#x2F;strong&gt;: a single command that takes a migration file and runs safety analysis, schema diff, lint, and index suggestions in one pass. Designed to block unsafe migrations before they merge.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Improved migration support&lt;&#x2F;strong&gt;: deeper analysis of multi-statement migrations, version-specific advice, and integration with common migration frameworks.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;getting-started&quot;&gt;Getting started&lt;a class=&quot;zola-anchor&quot; href=&quot;#getting-started&quot; aria-label=&quot;Anchor link for: getting-started&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;dryrun is MIT licensed and available on &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringsql&#x2F;dryrun&quot;&gt;GitHub&lt;&#x2F;a&gt;. The &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringsql&#x2F;dryrun&#x2F;blob&#x2F;master&#x2F;TUTORIAL.md&quot;&gt;tutorial&lt;&#x2F;a&gt; covers installation, offline and online workflows, multi-node setups, and MCP integration. It&#x27;s part of the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&quot;&gt;boringSQL&lt;&#x2F;a&gt; suite.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Good CTE, bad CTE</title>
        <published>2026-03-29T14:47:00+02:00</published>
        <updated>2026-03-29T14:47:00+02:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/good-cte-bad-cte/"/>
        <id>https://boringsql.com/posts/good-cte-bad-cte/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/good-cte-bad-cte/">&lt;p&gt;The &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;queries-with.html&quot;&gt;Common Table Expression&lt;&#x2F;a&gt;, or CTE, is often the first feature developers reach for beyond basic SQL, and often the only one. You write a subquery after &lt;code&gt;WITH&lt;&#x2F;code&gt;, give it a name, and use it in the rest of your query. It only exists for the duration of that query.&lt;&#x2F;p&gt;
&lt;p&gt;But the popularity of CTEs usually has less to do with modernizing code and more to do with the promise of imperative logic. For many, CTE acts as an easy to understand remedy for &#x27;scary queries&#x27; and way how to force execution order on the database. The way how many write queries is as if they tell optimizer &quot;first do this, then do that&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;This creates a problem. CTEs handle query decomposition, recursion and multi statement DDLs. Planner treats them differently depending how you write and use them though. For long time (prior PostgreSQL 12) CTEs acted as optimization fence. The planner couldn&#x27;t push predicates into them, couldn&#x27;t use indexes on the underlying tables. Couldn&#x27;t do anything that materialize them and scan through the result.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 12 changed this. CTEs now get inlined, materialized, or something in between, depending on how you write them.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sample-schema&quot;&gt;Sample schema&lt;a class=&quot;zola-anchor&quot; href=&quot;#sample-schema&quot; aria-label=&quot;Anchor link for: sample-schema&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;We will use the same schema as in the article &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;&quot;&gt;PostgreSQL Statistics: Why queries run slow&lt;&#x2F;a&gt;.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customers&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name text NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; customers(id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status text NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    note &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;date NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_DATE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders_archive&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIKE&lt;&#x2F;span&gt;&lt;span&gt; orders INCLUDING ALL EXCLUDING &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IDENTITY&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; customers (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Customer &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; i&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2000&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; i;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders (customer_id, amount, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, note, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1999&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[&amp;#39;pending&amp;#39;,&amp;#39;shipped&amp;#39;,&amp;#39;delivered&amp;#39;,&amp;#39;cancelled&amp;#39;])[floor(random()*4+1)::int],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Some note text here for padding&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ELSE NULL END&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2022-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;date +&lt;&#x2F;span&gt;&lt;span&gt; (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1095&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;For recursive examples later on we&#x27;ll also need an &lt;code&gt;employees&lt;&#x2F;code&gt; table with a self-referencing hierarchy:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; employees&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name text NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    manager_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; employees(id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    department &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; employees (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, manager_id, department) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Alice&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Engineering&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Bob&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Engineering&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Charlie&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Engineering&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Diana&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Engineering&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Eve&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Engineering&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Frank&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Sales&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Grace&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Sales&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Hank&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;6&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Sales&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Ivy&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;6&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Sales&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE employees;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;the-optimization-fence-era-pre-pg-12&quot;&gt;The optimization fence era (pre-PG 12)&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-optimization-fence-era-pre-pg-12&quot; aria-label=&quot;Anchor link for: the-optimization-fence-era-pre-pg-12&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As we already covered before PostgreSQL 12, every CTE was materialized. No exceptions. The planner would compute the CTE result set in full, store it in a temporary tuplestore, and then scan that tuplestore whenever the main query referenced the CTE. This made CTEs an &lt;strong&gt;optimization fence&lt;&#x2F;strong&gt; because the planner could not look through them.&lt;&#x2F;p&gt;
&lt;p&gt;Consider this simple query:&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On PostgreSQL 11 and earlier, the &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; output would look something like this:&lt;&#x2F;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;                            QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; CTE Scan on filtered  (cost=1840.00..2290.00 rows=2 width=58)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (status = &amp;#39;pending&amp;#39;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE filtered&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Seq Scan on orders  (cost=0.00..1840.00 rows=10000 width=58)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           Filter: (created_at &amp;gt; &amp;#39;2025-01-01&amp;#39;::date)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Notice what happens here. The CTE runs a sequential scan on &lt;code&gt;orders&lt;&#x2F;code&gt; with the date filter. It materializes all matching rows. Then the outer query applies the &lt;code&gt;status = &#x27;pending&#x27;&lt;&#x2F;code&gt; filter &lt;em&gt;after&lt;&#x2F;em&gt; materialization. Even if a composite index on &lt;code&gt;(created_at, status)&lt;&#x2F;code&gt; existed, the planner couldn&#x27;t use it as it can&#x27;t see through the CTE boundary to combine the predicates.&lt;&#x2F;p&gt;
&lt;p&gt;Why was it designed this way? Two reasons. First, reason was snapshot isolation. Materializing the CTE guaranteed that the result set was computed once, from a single snapshot, regardless of how many times it was referenced. Second, as protection for side-effect edge cases. If a CTE contained a data-modifying statement (&lt;code&gt;INSERT&lt;&#x2F;code&gt;, &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, &lt;code&gt;DELETE&lt;&#x2F;code&gt;), materialising ensured it executed exactly once.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The workaround was well-known in the community: rewrite CTEs as subqueries. Subqueries had always been subject to the planner&#x27;s normal optimisation rules, including predicate pushdown and inlining. The same query written as &lt;code&gt;SELECT * FROM (SELECT * FROM orders WHERE created_at &gt; &#x27;2025-01-01&#x27;) sub WHERE status = &#x27;pending&#x27;&lt;&#x2F;code&gt; would produce a much better plan.&lt;&#x2F;div&gt;
&lt;p&gt;This led to an entire workaround culture. Developers would write queries with CTEs for readability during development, then rewrite them as nested subqueries for production. The community had a saying: &lt;em&gt;CTEs are optimization fences&lt;&#x2F;em&gt;. It was repeated so often. Many developers still believe it today. But it hasn&#x27;t been true since PostgreSQL 12.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;postgresql-12-cte-inlining&quot;&gt;PostgreSQL 12: CTE inlining&lt;a class=&quot;zola-anchor&quot; href=&quot;#postgresql-12-cte-inlining&quot; aria-label=&quot;Anchor link for: postgresql-12-cte-inlining&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;PostgreSQL 12 introduced automatic CTE inlining. Non-recursive, side-effect-free, singly-referenced CTEs are now inlined by default. The planner started treating them as subqueries and applies all its normal optimisations. Predicate pushdown, index usage, join reordering apply exactly as if the CTE syntax never existed.&lt;&#x2F;p&gt;
&lt;p&gt;The same query from the previous section now produces a completely different plan:&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                    QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Seq Scan on orders  (cost=0.00..2355.00 rows=2 width=58)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: ((created_at &amp;gt; &amp;#39;2025-01-01&amp;#39;::date) AND (status = &amp;#39;pending&amp;#39;::text))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The CTE is gone from the plan entirely. Both predicates are merged into a single scan on &lt;code&gt;orders&lt;&#x2F;code&gt;. If there&#x27;s a suitable index, the planner can use it. The CTE syntax doesn&#x27;t change the execution plan.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 12 also introduced two new keywords that let you override the planner&#x27;s decision:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MATERIALIZED&lt;&#x2F;code&gt; - forces the CTE to materialize, even when the planner would inline it&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;NOT MATERIALIZED&lt;&#x2F;code&gt; - forces inlining, even when the planner would materialize it&lt;&#x2F;li&gt;
&lt;&#x2F;ul&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;-- force materialization&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; MATERIALIZED (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Force inlining&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS NOT&lt;&#x2F;span&gt;&lt;span&gt; MATERIALIZED (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This follows the same principle as &lt;a href=&quot;&#x2F;posts&#x2F;view-inlining&#x2F;&quot;&gt;VIEW inlining&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-does-a-cte-get-materialized&quot;&gt;When does a CTE get materialized?&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-does-a-cte-get-materialized&quot; aria-label=&quot;Anchor link for: when-does-a-cte-get-materialized&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;case-1-single-reference-no-side-effects-inlined&quot;&gt;Case 1: single reference, no side effects (INLINED)&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-1-single-reference-no-side-effects-inlined&quot; aria-label=&quot;Anchor link for: case-1-single-reference-no-side-effects-inlined&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The simplest and most common case. If you reference the CTE exactly once and it contains no side effects, the planner inlines it.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; recent &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; recent &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                  QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Seq Scan on orders  (cost=0.00..2355.00 rows=2 width=59)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: ((created_at &amp;gt; &amp;#39;2025-01-01&amp;#39;::date) AND (status = &amp;#39;pending&amp;#39;::text))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both predicates are merged. The planner considers all access paths on &lt;code&gt;orders&lt;&#x2F;code&gt; directly.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;case-2-multiple-references-materialized&quot;&gt;Case 2: multiple references (MATERIALIZED)&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-2-multiple-references-materialized&quot; aria-label=&quot;Anchor link for: case-2-multiple-references-materialized&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;When a CTE is referenced more than once, the planner materializes it. This is actually a feature, CTE is computed once and reused. Therefore avoiding redundant work.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; summary &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT status&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; cnt &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;b&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; summary a, summary b&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;cnt&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; b&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;cnt&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                 QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Nested Loop  (cost=2355.04..2355.52 rows=5 width=64)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Join Filter: (a.cnt &amp;gt; b.cnt)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE summary&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  HashAggregate  (cost=2355.00..2355.04 rows=4 width=17)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           Group Key: orders.status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  Seq Scan on orders  (cost=0.00..1855.00 rows=100000 width=9)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  CTE Scan on summary a  (cost=0.00..0.08 rows=4 width=40)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  CTE Scan on summary b  (cost=0.00..0.08 rows=4 width=40)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(8 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;CTE Scan&lt;&#x2F;code&gt; nodes appear twice, but the &lt;code&gt;HashAggregate&lt;&#x2F;code&gt; runs only once. For expensive computations referenced multiple times, this is exactly what you want.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;case-3-recursive-cte-always-materialized&quot;&gt;Case 3: recursive CTE (ALWAYS MATERIALIZED)&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-3-recursive-cte-always-materialized&quot; aria-label=&quot;Anchor link for: case-3-recursive-cte-always-materialized&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Recursive CTEs must maintain a working table between iterations. There&#x27;s no way to inline them. We&#x27;ll cover recursion in detail later in the article.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH RECURSIVE&lt;&#x2F;span&gt;&lt;span&gt; subordinates &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, manager_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; employees &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    UNION ALL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;manager_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; employees e&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; subordinates s &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;manager_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; s&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; subordinates;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                          QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; CTE Scan on subordinates  (cost=17.21..18.83 rows=81 width=40)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE subordinates&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Recursive Union  (cost=0.00..17.21 rows=81 width=13)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  Seq Scan on employees  (cost=0.00..1.11 rows=1 width=13)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 Filter: (id = 1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  Hash Join  (cost=0.33..1.53 rows=8 width=13)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 Hash Cond: (e.manager_id = s.id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 -&amp;gt;  Seq Scan on employees e  (cost=0.00..1.09 rows=9 width=13)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 -&amp;gt;  Hash  (cost=0.20..0.20 rows=10 width=4)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                       -&amp;gt;  WorkTable Scan on subordinates s  (cost=0.00..0.20 rows=10 width=4)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(10 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;case-4-data-modifying-cte-always-materialized&quot;&gt;Case 4: data-modifying CTE (ALWAYS MATERIALIZED)&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-4-data-modifying-cte-always-materialized&quot; aria-label=&quot;Anchor link for: case-4-data-modifying-cte-always-materialized&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;CTEs that contain &lt;code&gt;INSERT&lt;&#x2F;code&gt;, &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, or &lt;code&gt;DELETE&lt;&#x2F;code&gt; are always materialized. The side effects must execute exactly once, in a predictable order.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; deleted &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;cancelled&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt; RETURNING &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; deleted;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Aggregate  (cost=2670.13..2670.14 rows=1 width=8)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE deleted&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Delete on orders  (cost=0.00..2105.00 rows=25117 width=6)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  Seq Scan on orders  (cost=0.00..2105.00 rows=25117 width=6)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 Filter: (status = &amp;#39;cancelled&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  CTE Scan on deleted  (cost=0.00..502.34 rows=25117 width=0)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(6 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;CTE Scan&lt;&#x2F;code&gt; is present because the &lt;code&gt;DELETE&lt;&#x2F;code&gt; must be fully executed before the &lt;code&gt;count(*)&lt;&#x2F;code&gt; can run.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;case-5-volatile-function-materialized&quot;&gt;Case 5: VOLATILE function (MATERIALIZED)&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-5-volatile-function-materialized&quot; aria-label=&quot;Anchor link for: case-5-volatile-function-materialized&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;If a CTE contains a &lt;code&gt;VOLATILE&lt;&#x2F;code&gt; function, the planner materializes it to prevent the function from being evaluated multiple times with potentially different results.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; rand &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; r &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; rand &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; r &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;01&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                              QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; CTE Scan on rand  (cost=2105.00..4355.00 rows=33333 width=12)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (r &amp;lt; &amp;#39;0.01&amp;#39;::double precision)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE rand&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Seq Scan on orders  (cost=0.00..2105.00 rows=100000 width=12)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Even though &lt;code&gt;rand&lt;&#x2F;code&gt; is referenced only once, the &lt;code&gt;CTE Scan&lt;&#x2F;code&gt; is there. &lt;code&gt;random()&lt;&#x2F;code&gt; is &lt;code&gt;VOLATILE&lt;&#x2F;code&gt;, which forces materialization.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;case-6-stable-functions-inlined&quot;&gt;Case 6: STABLE functions (INLINED)&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-6-stable-functions-inlined&quot; aria-label=&quot;Anchor link for: case-6-stable-functions-inlined&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;code&gt;STABLE&lt;&#x2F;code&gt; functions like &lt;code&gt;now()&lt;&#x2F;code&gt; do &lt;strong&gt;not&lt;&#x2F;strong&gt; prevent inlining. The reason is that the time is frozen at transaction start.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; recent &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt; now&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;7 days&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; recent &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                       QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Seq Scan on orders  (cost=0.00..2855.00 rows=2 width=59)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: ((status = &amp;#39;pending&amp;#39;::text) AND (created_at &amp;gt; (now() - &amp;#39;7 days&amp;#39;::interval)))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No &lt;code&gt;CTE Scan&lt;&#x2F;code&gt;. The planner inlines the CTE and merges both predicates, just like Case 1. &lt;code&gt;now()&lt;&#x2F;code&gt; is &lt;code&gt;STABLE&lt;&#x2F;code&gt; as it returns the same value within a transaction - and the planner&#x27;s inlining check only looks for &lt;code&gt;VOLATILE&lt;&#x2F;code&gt; functions (via &lt;code&gt;contain_volatile_functions()&lt;&#x2F;code&gt;). &lt;code&gt;STABLE&lt;&#x2F;code&gt; passes that check.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;Why do people think &lt;code&gt;STABLE&lt;&#x2F;code&gt; blocks inlining? Because before PostgreSQL 12, &lt;strong&gt;all&lt;&#x2F;strong&gt; CTEs were materialized regardless of volatility. When PG 12 introduced inlining, the only function-level barrier was &lt;code&gt;VOLATILE&lt;&#x2F;code&gt;. But the old &quot;CTEs are optimization fences&quot; mental model was so deeply ingrained that many developers assumed &lt;code&gt;STABLE&lt;&#x2F;code&gt; was also a problem. It isn&#x27;t.&lt;&#x2F;p&gt;
&lt;p&gt;The function that actually blocks inlining: &lt;code&gt;clock_timestamp()&lt;&#x2F;code&gt;. Unlike &lt;code&gt;now()&lt;&#x2F;code&gt;, it&#x27;s &lt;code&gt;VOLATILE&lt;&#x2F;code&gt; and returns a different value on every call. A CTE with &lt;code&gt;clock_timestamp()&lt;&#x2F;code&gt; will be materialized. Similarly, &lt;code&gt;random()&lt;&#x2F;code&gt; and &lt;code&gt;nextval()&lt;&#x2F;code&gt; are &lt;code&gt;VOLATILE&lt;&#x2F;code&gt; and force materialization (as shown in Case 5).&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;If you see a CTE with &lt;code&gt;now()&lt;&#x2F;code&gt; being materialized, the cause is something else. Either multiple references, a data-modifying statement, or an explicit &lt;code&gt;MATERIALIZED&lt;&#x2F;code&gt; hint. Don&#x27;t blame &lt;code&gt;STABLE&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;case-7-forcing-behaviour-with-hints&quot;&gt;Case 7: forcing behaviour with hints&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-7-forcing-behaviour-with-hints&quot; aria-label=&quot;Anchor link for: case-7-forcing-behaviour-with-hints&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;You can always override the planner&#x27;s decision.&lt;&#x2F;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;-- force materialization on something that would normally be inlined&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; MATERIALIZED (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 400&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                              QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; CTE Scan on filtered  (cost=2105.00..2670.58 rows=5290 width=92)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (amount &amp;gt; &amp;#39;400&amp;#39;::numeric)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE filtered&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Seq Scan on orders  (cost=0.00..2105.00 rows=25137 width=59)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           Filter: (status = &amp;#39;pending&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(5 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;-- force inlining on something that would normally be materialized&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; filtered &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS NOT&lt;&#x2F;span&gt;&lt;span&gt; MATERIALIZED (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; filtered a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;&#x2F;span&gt;&lt;span&gt; filtered b &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; b&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                    QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Hash Join  (cost=2419.21..10686.65 rows=317742 width=118)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Hash Cond: (orders.customer_id = orders_1.customer_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Seq Scan on orders  (cost=0.00..2105.00 rows=25137 width=59)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Filter: (status = &amp;#39;pending&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Hash  (cost=2105.00..2105.00 rows=25137 width=59)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Seq Scan on orders orders_1  (cost=0.00..2105.00 rows=25137 width=59)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Filter: (status = &amp;#39;pending&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(7 rows)    &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the CTE were materialized, you would see a single scan of the &lt;code&gt;orders&lt;&#x2F;code&gt; table, followed by two CTE Scans on the result. Instead, the planner has treated your query as if you had written a standard join between two subqueries.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;&lt;strong&gt;Be careful with &lt;code&gt;NOT MATERIALIZED&lt;&#x2F;code&gt; on multiply-referenced CTEs.&lt;&#x2F;strong&gt; When you force inlining, the subquery runs once for each reference. In the example above, the &lt;code&gt;orders&lt;&#x2F;code&gt; table is scanned twice. Once for &lt;code&gt;a&lt;&#x2F;code&gt; and once for &lt;code&gt;b&lt;&#x2F;code&gt;. For small result sets this might be fine. For large ones, you&#x27;re doing double the work. Measure before using.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h3 id=&quot;case-8-for-update-for-share-materialized&quot;&gt;Case 8: FOR UPDATE &#x2F; FOR SHARE (MATERIALIZED)&lt;a class=&quot;zola-anchor&quot; href=&quot;#case-8-for-update-for-share-materialized&quot; aria-label=&quot;Anchor link for: case-8-for-update-for-share-materialized&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Row-locking clauses force materialization even on a singly-referenced, side-effect-free CTE. Internally, the planner&#x27;s &lt;code&gt;contain_dml()&lt;&#x2F;code&gt; check treats &lt;code&gt;FOR UPDATE&lt;&#x2F;code&gt; and &lt;code&gt;FOR SHARE&lt;&#x2F;code&gt; the same as data-modifying statements.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; locked &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FOR UPDATE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; locked &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 400&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                 QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; CTE Scan on locked  (cost=2356.37..2921.95 rows=5290 width=92)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (amount &amp;gt; &amp;#39;400&amp;#39;::numeric)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE locked&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  LockRows  (cost=0.00..2356.37 rows=25137 width=65)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  Seq Scan on orders  (cost=0.00..2105.00 rows=25137 width=65)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 Filter: (status = &amp;#39;pending&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(6 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Without &lt;code&gt;FOR UPDATE&lt;&#x2F;code&gt;, this CTE would be inlined. The &lt;code&gt;LockRows&lt;&#x2F;code&gt; node and &lt;code&gt;CTE Scan&lt;&#x2F;code&gt; confirm materialization.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;decision-matrix&quot;&gt;Decision matrix&lt;a class=&quot;zola-anchor&quot; href=&quot;#decision-matrix&quot; aria-label=&quot;Anchor link for: decision-matrix&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Here&#x27;s the full picture across PostgreSQL versions:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Condition&lt;&#x2F;th&gt;&lt;th&gt;PG ≤ 11&lt;&#x2F;th&gt;&lt;th&gt;PG 12–16&lt;&#x2F;th&gt;&lt;th&gt;PG 17–18&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Single ref, pure SELECT&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Inlined&lt;&#x2F;td&gt;&lt;td&gt;Inlined&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Multiple refs, pure SELECT&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized (better stats)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;VOLATILE function&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;STABLE function&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Inlined&lt;&#x2F;td&gt;&lt;td&gt;Inlined&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Data-modifying (DML)&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;FOR UPDATE &#x2F; FOR SHARE&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Recursive&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Explicit &lt;code&gt;MATERIALIZED&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;-&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;td&gt;Materialized&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Explicit &lt;code&gt;NOT MATERIALIZED&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;-&lt;&#x2F;td&gt;&lt;td&gt;Inlined&lt;&#x2F;td&gt;&lt;td&gt;Inlined&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h2 id=&quot;the-statistics-black-hole&quot;&gt;The statistics black hole&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-statistics-black-hole&quot; aria-label=&quot;Anchor link for: the-statistics-black-hole&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As mentioned in &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;&quot;&gt;PostgreSQL Statistics: Why queries run slow&lt;&#x2F;a&gt;, materialized CTEs are one of the places &quot;where no statistics go.&quot; This is arguably the biggest practical problem with CTE materialization.&lt;&#x2F;p&gt;
&lt;p&gt;When the planner materializes a CTE, the result set is stored in a temporary tuplestore. This tuplestore has no &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; entries - no histograms, no MCVs, no correlation data. The planner has to estimate row counts and value distributions using hardcoded defaults.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s see this in action. Here&#x27;s a CTE over 10,000 rows:&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; all_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; MATERIALIZED (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; all_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span&gt; amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 400&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                              QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; CTE Scan on all_orders  (cost=1855.00..4355.00 rows=5290 width=92)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: ((amount &amp;gt; &amp;#39;400&amp;#39;::numeric) AND (status = &amp;#39;pending&amp;#39;::text))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE all_orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Seq Scan on orders  (cost=0.00..1855.00 rows=100000 width=59)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The planner estimated 5,290 rows. Where does that number come from? The planner has no MCV list for &lt;code&gt;status&lt;&#x2F;code&gt; inside the CTE, no histogram for &lt;code&gt;amount&lt;&#x2F;code&gt;. It falls back to default selectivities of &lt;code&gt;0.3333&lt;&#x2F;code&gt; for the range comparison on &lt;code&gt;amount&lt;&#x2F;code&gt; and a rough guess for the equality on &lt;code&gt;status&lt;&#x2F;code&gt;, and multiplies them against the 100,000 input rows.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;If this CTE were inlined, the planner would read the actual statistics from &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; for the &lt;code&gt;orders&lt;&#x2F;code&gt; table and produce estimates based on real data distribution, not defaults.&lt;&#x2F;div&gt;
&lt;p&gt;In a simple query this might not matter much. But when a materialized CTE feeds into a join, default-based estimates can cascade. The planner might choose a nested loop where a hash join would be better, or vice versa. It might underestimate memory needs and spill to disk unexpectedly.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;pg-17-statistics-propagation&quot;&gt;PG 17: Statistics propagation&lt;a class=&quot;zola-anchor&quot; href=&quot;#pg-17-statistics-propagation&quot; aria-label=&quot;Anchor link for: pg-17-statistics-propagation&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;PostgreSQL 17 brought two significant improvements to materialized CTEs:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Column statistics propagation.&lt;&#x2F;strong&gt; When the planner creates a &lt;code&gt;CTE Scan&lt;&#x2F;code&gt; node, it now propagates column statistics from the underlying query into the scan node. This means &lt;code&gt;n_distinct&lt;&#x2F;code&gt;, MCV lists, and histograms from the source table can inform estimates on the CTE scan.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Path key propagation.&lt;&#x2F;strong&gt; Materialized CTEs now preserve sort order information. If the CTE&#x27;s subquery produces sorted output, the planner knows about it and can skip redundant sorts downstream.&lt;&#x2F;p&gt;
&lt;p&gt;These improvements significantly reduce the estimation gap, but they don&#x27;t eliminate it. Inlined CTEs are still strictly better for planning accuracy, because the planner works directly with the base table statistics rather than propagated copies. If your CTE doesn&#x27;t need to be materialized, don&#x27;t force it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-materialization-helps&quot;&gt;When materialization helps&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-materialization-helps&quot; aria-label=&quot;Anchor link for: when-materialization-helps&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Materialization isn&#x27;t always bad.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Multiple references.&lt;&#x2F;strong&gt; If the CTE result is used in multiple places, materialization computes it once. Without it, the subquery runs once per reference.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; monthly_totals &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span&gt; date_trunc(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;month&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, created_at) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS month&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;           status&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;           sum&lt;&#x2F;span&gt;&lt;span&gt;(amount) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; total&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    GROUP BY&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; cur&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;month&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;cur&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;cur&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;       prev&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; prev_month_total,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;       cur&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; prev&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; delta&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; monthly_totals cur&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LEFT JOIN&lt;&#x2F;span&gt;&lt;span&gt; monthly_totals prev&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; cur&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;month&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; prev&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;month&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 month&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; cur&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; prev&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;; &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                                  QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Merge Left Join  (cost=3887.46..3990.90 rows=4384 width=136)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Merge Cond: ((cur.month = ((prev.month + &amp;#39;1 mon&amp;#39;::interval))) AND (cur.status = prev.status))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE monthly_totals&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  HashAggregate  (cost=3105.00..3181.72 rows=4384 width=49)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           Group Key: date_trunc(&amp;#39;month&amp;#39;::text, (orders.created_at)::timestamp with time zone), orders.status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  Seq Scan on orders  (cost=0.00..2355.00 rows=100000 width=23)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Sort  (cost=352.87..363.83 rows=4384 width=72)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Sort Key: cur.month, cur.status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  CTE Scan on monthly_totals cur  (cost=0.00..87.68 rows=4384 width=72)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Sort  (cost=352.87..363.83 rows=4384 width=72)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Sort Key: ((prev.month + &amp;#39;1 mon&amp;#39;::interval)), prev.status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  CTE Scan on monthly_totals prev  (cost=0.00..87.68 rows=4384 width=72)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(12 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The aggregation runs once. Both &lt;code&gt;cur&lt;&#x2F;code&gt; and &lt;code&gt;prev&lt;&#x2F;code&gt; read from the materialized result. Without materialization, the entire aggregation would run twice.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Expensive VOLATILE expressions.&lt;&#x2F;strong&gt; If a CTE contains calls to volatile functions or expensive computations, materialization ensures they execute exactly once.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Data-modifying operations.&lt;&#x2F;strong&gt; The whole point of writable CTEs is that the side effects happen once and the &lt;code&gt;RETURNING&lt;&#x2F;code&gt; data is available downstream. Materialization is not optional here.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-inlining-isn-t-enough&quot;&gt;When inlining isn&#x27;t enough&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-inlining-isn-t-enough&quot; aria-label=&quot;Anchor link for: when-inlining-isn-t-enough&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The imperative mindset from the introduction, &quot;first do this, then do that&quot;, doesn&#x27;t go away just because the planner inlines your CTEs. And this one is my personal favourite, and source that never stop delivering query refactoring.&lt;&#x2F;p&gt;
&lt;p&gt;Developers still structure queries as sequential pipelines, and that structure itself can create performance problems that have nothing to do with materialization.&lt;&#x2F;p&gt;
&lt;p&gt;A common pattern is building queries as an assembly line: one CTE filters rows, the next LEFT JOINs related tables and aggregates metadata with &lt;code&gt;GROUP BY&lt;&#x2F;code&gt;, the next filters on the aggregated results. It reads like a clean pipeline, but the &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; in the middle creates a wall the planner can&#x27;t optimize past.&lt;&#x2F;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;WITH&lt;&#x2F;span&gt;&lt;span&gt; recent_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2024-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;order_metadata &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;        o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        bool_or(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oa&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; IS NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; was_archived,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;        count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;o2&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; related_count&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; recent_orders o&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    LEFT JOIN&lt;&#x2F;span&gt;&lt;span&gt; orders_archive oa &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; oa&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    LEFT JOIN&lt;&#x2F;span&gt;&lt;span&gt; orders o2 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o2&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o2&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    GROUP BY&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; o.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;m&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;was_archived&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;m&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;related_count&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; recent_orders o&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;&#x2F;span&gt;&lt;span&gt; order_metadata m &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; m&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; m&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;was_archived&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; false&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; m&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;related_count&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each CTE here is referenced once, so they all get inlined. No materialization, no optimization fence. The planner sees the full query. So what&#x27;s the problem?&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; in &lt;code&gt;order_metadata&lt;&#x2F;code&gt;. Even after inlining, the planner cannot push the &lt;code&gt;was_archived = false&lt;&#x2F;code&gt; predicate past the aggregation. It must first LEFT JOIN every filtered order against &lt;code&gt;orders_archive&lt;&#x2F;code&gt; and self-join &lt;code&gt;orders&lt;&#x2F;code&gt;, compute the aggregates for all of them, and only then discard the rows that don&#x27;t match. If &lt;code&gt;recent_orders&lt;&#x2F;code&gt; returns 50,000 rows but only 200 were ever archived, you&#x27;re joining and aggregating 49,800 rows for nothing.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is to replace the aggregation-then-filter pattern with correlated &lt;code&gt;EXISTS&lt;&#x2F;code&gt; subqueries:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; o.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; orders o&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;created_at&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2024-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND NOT EXISTS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;&#x2F;span&gt;&lt;span&gt; orders_archive oa &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; oa&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND EXISTS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; FROM&lt;&#x2F;span&gt;&lt;span&gt; orders o2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o2&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;customer_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o2&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  );&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;EXISTS&lt;&#x2F;code&gt; short-circuits after finding the first matching row. The planner can push &lt;code&gt;created_at &amp;gt; &#x27;2024-01-01&#x27;&lt;&#x2F;code&gt; all the way down to an index scan on &lt;code&gt;orders&lt;&#x2F;code&gt;, then probe each related table per result. No aggregation, no wasted work.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;&lt;strong&gt;The rule of thumb:&lt;&#x2F;strong&gt; if your CTE contains a &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; or a &lt;code&gt;LEFT JOIN&lt;&#x2F;code&gt; just to compute a boolean (&quot;does this row have related data?&quot;), you&#x27;ve built a wall the planner can&#x27;t see past. A correlated &lt;code&gt;EXISTS&lt;&#x2F;code&gt; lets the planner push filters down and stop scanning early. This applies whether the CTE is materialized or inlined.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;writable-ctes-the-power-and-the-traps&quot;&gt;Writable CTEs (the power and the traps)&lt;a class=&quot;zola-anchor&quot; href=&quot;#writable-ctes-the-power-and-the-traps&quot; aria-label=&quot;Anchor link for: writable-ctes-the-power-and-the-traps&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Data-modifying CTEs let you &lt;code&gt;INSERT&lt;&#x2F;code&gt;, &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, or &lt;code&gt;DELETE&lt;&#x2F;code&gt; inside a &lt;code&gt;WITH&lt;&#x2F;code&gt; clause and use the &lt;code&gt;RETURNING&lt;&#x2F;code&gt; data in subsequent CTEs or the main query.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; deleted &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;cancelled&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;      AND&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2023-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    RETURNING &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;archived &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders_archive&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; deleted&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    RETURNING id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; archived;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                          QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Aggregate  (cost=2709.19..2709.20 rows=1 width=8)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE deleted&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Delete on orders  (cost=0.00..2355.00 rows=8334 width=6)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  Seq Scan on orders  (cost=0.00..2355.00 rows=8334 width=6)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                 Filter: ((created_at &amp;lt; &amp;#39;2023-01-01&amp;#39;::date) AND (status = &amp;#39;cancelled&amp;#39;::text))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   CTE archived&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     -&amp;gt;  Insert on orders_archive  (cost=0.00..166.68 rows=8334 width=92)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;           -&amp;gt;  CTE Scan on deleted  (cost=0.00..166.68 rows=8334 width=92)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  CTE Scan on archived  (cost=0.00..166.68 rows=8334 width=0)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(9 rows)  &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This deletes old cancelled orders, moves them to an archive table, and counts how many were archived - all in a single atomic statement, no application-level coordination needed.&lt;&#x2F;p&gt;
&lt;p&gt;But there are sharp edges.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;you-cannot-read-what-you-just-wrote&quot;&gt;You cannot read what you just wrote&lt;a class=&quot;zola-anchor&quot; href=&quot;#you-cannot-read-what-you-just-wrote&quot; aria-label=&quot;Anchor link for: you-cannot-read-what-you-just-wrote&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;All sub-statements in a data-modifying CTE see the same snapshot. This means the effects of one CTE are &lt;strong&gt;not visible&lt;&#x2F;strong&gt; to other CTEs or the main query when they read the &lt;em&gt;target table&lt;&#x2F;em&gt;. Only the &lt;code&gt;RETURNING&lt;&#x2F;code&gt; clause communicates data between CTE steps.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    31&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;WITH&lt;&#x2F;span&gt;&lt;span&gt; ins &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders (customer_id, amount, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, CURRENT_DATE)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    RETURNING id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- this does NOT see the row we just inserted&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    31&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;count(1)&lt;&#x2F;code&gt; query sees the pre-insert snapshot. If you need the inserted data, you must use the &lt;code&gt;RETURNING&lt;&#x2F;code&gt; clause from the &lt;code&gt;ins&lt;&#x2F;code&gt; CTE, not re-read the table.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;tuple-shuffling&quot;&gt;Tuple shuffling&lt;a class=&quot;zola-anchor&quot; href=&quot;#tuple-shuffling&quot; aria-label=&quot;Anchor link for: tuple-shuffling&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;A common pattern is using writable CTEs to move rows between tables atomically:&lt;&#x2F;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;WITH&lt;&#x2F;span&gt;&lt;span&gt; moved &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; orders_staging&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    RETURNING &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; moved;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This deletes all rows from the staging table and inserts them into the production table in a single atomic operation. No window where data exists in both or neither table.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Data-modifying CTEs disable parallel query for the entire statement. If you have a complex query that mixes reads and writes, the write CTE prevents parallelism even for the read-only parts.&lt;&#x2F;div&gt;
&lt;h2 id=&quot;recursive-ctes-are-always-materialized&quot;&gt;Recursive CTEs are always materialized&lt;a class=&quot;zola-anchor&quot; href=&quot;#recursive-ctes-are-always-materialized&quot; aria-label=&quot;Anchor link for: recursive-ctes-are-always-materialized&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Recursive CTEs use an iterative working-table mechanism. Despite the name, they aren&#x27;t truly recursive. PostgreSQL doesn&#x27;t &quot;call itself&quot; by creating a nested stack of unfinished queries. Instead, it operates in loops.&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Execute the non-recursive term (the &quot;seed&quot;). Put results in the working table.&lt;&#x2F;li&gt;
&lt;li&gt;Execute the recursive term using the working table as input. New rows become the &lt;em&gt;next&lt;&#x2F;em&gt; working table.&lt;&#x2F;li&gt;
&lt;li&gt;Repeat until the recursive term returns no new rows.&lt;&#x2F;li&gt;
&lt;li&gt;Return the union of all iterations.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&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;WITH RECURSIVE&lt;&#x2F;span&gt;&lt;span&gt; org_chart &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- Seed: start from the CEO&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, manager_id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; depth&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; employees&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WHERE&lt;&#x2F;span&gt;&lt;span&gt; manager_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    UNION ALL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- Recursive term: find direct reports&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;manager_id&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;depth&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FROM&lt;&#x2F;span&gt;&lt;span&gt; employees e&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; org_chart oc &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; e&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;manager_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; oc&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; org_chart &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; depth, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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 |  name   | manager_id | depth&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----+---------+------------+-------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  1 | Alice   |            |     1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  2 | Bob     |          1 |     2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  3 | Charlie |          1 |     2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  4 | Diana   |          2 |     3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  5 | Eve     |          2 |     3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  6 | Frank   |          3 |     3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  7 | Grace   |          3 |     3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  8 | Hank    |          6 |     4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  9 | Ivy     |          6 |     4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(9 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;union-vs-union-all&quot;&gt;UNION vs UNION ALL&lt;a class=&quot;zola-anchor&quot; href=&quot;#union-vs-union-all&quot; aria-label=&quot;Anchor link for: union-vs-union-all&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The choice between &lt;code&gt;UNION&lt;&#x2F;code&gt; and &lt;code&gt;UNION ALL&lt;&#x2F;code&gt; in recursive CTEs matters more than in regular queries.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;UNION ALL&lt;&#x2F;code&gt; keeps all rows, including duplicates. This is faster but dangerous: if your graph has cycles, the recursion never terminates. PostgreSQL will run until you cancel the query or memory is exhausted.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;UNION&lt;&#x2F;code&gt; deduplicates at each iteration. This prevents infinite loops in cyclic graphs but adds the cost of hashing and comparing rows at every step.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 14 added SQL-standard &lt;code&gt;SEARCH&lt;&#x2F;code&gt; and &lt;code&gt;CYCLE&lt;&#x2F;code&gt; clauses that replace the manual patterns for controlling traversal order (breadth-first vs. depth-first) and detecting cycles. &lt;code&gt;SEARCH BREADTH FIRST BY&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;SEARCH DEPTH FIRST BY&lt;&#x2F;code&gt; control the order, while &lt;code&gt;CYCLE&lt;&#x2F;code&gt; automatically detects and marks cycles, which is far cleaner than the old pattern of accumulating an array of visited IDs.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;For pure hierarchical data (trees without cycles), consider the &lt;code&gt;ltree&lt;&#x2F;code&gt; extension as an alternative to recursive CTEs. It stores the full path as a label tree and supports efficient ancestor&#x2F;descendant queries with GiST indexes. The trade-off is denormalized storage vs. on-the-fly recursion.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;the-exotic-edge-cases&quot;&gt;The exotic edge cases&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-exotic-edge-cases&quot; aria-label=&quot;Anchor link for: the-exotic-edge-cases&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;partition-pruning-lost&quot;&gt;Partition pruning lost&lt;a class=&quot;zola-anchor&quot; href=&quot;#partition-pruning-lost&quot; aria-label=&quot;Anchor link for: partition-pruning-lost&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;When you materialize a CTE over a partitioned table, partition pruning cannot happen on the CTE scan side. The materialized result is a flat tuplestore disconnected from the partition metadata.&lt;&#x2F;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;-- assume orders is range-partitioned by created_at&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; recent &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; MATERIALIZED (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; recent &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-06-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;created_at &amp;gt; &#x27;2025-06-01&#x27;&lt;&#x2F;code&gt; predicate is applied &lt;em&gt;after&lt;&#x2F;em&gt; materialization. All partitions are scanned to build the CTE, even though only one or two would be needed. Use &lt;code&gt;NOT MATERIALIZED&lt;&#x2F;code&gt; (or simply let the planner inline it) to preserve partition pruning.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;prepared-statements-and-plan-caching&quot;&gt;Prepared statements and plan caching&lt;a class=&quot;zola-anchor&quot; href=&quot;#prepared-statements-and-plan-caching&quot; aria-label=&quot;Anchor link for: prepared-statements-and-plan-caching&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;PostgreSQL generates custom plans for the first 5 executions of a prepared statement. After that, it may switch to a generic plan. CTE inlining decisions can differ between custom and generic plans, because generic plans don&#x27;t know the actual parameter values.&lt;&#x2F;p&gt;
&lt;p&gt;This means a CTE that gets inlined during your first 5 calls might start materializing on the 6th or vice versa. If you see sudden plan changes with prepared statements, check whether the CTE inlining behaviour has shifted.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;work-mem-spilling&quot;&gt;work_mem spilling&lt;a class=&quot;zola-anchor&quot; href=&quot;#work-mem-spilling&quot; aria-label=&quot;Anchor link for: work-mem-spilling&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Materialized CTEs store their results in memory, bounded by &lt;code&gt;work_mem&lt;&#x2F;code&gt;. When the result set exceeds this limit, it silently spills to disk as a temporary file. This is not an error - it just gets slower.&lt;&#x2F;p&gt;
&lt;p&gt;Monitor with &lt;code&gt;log_temp_files = 0&lt;&#x2F;code&gt; (logs all temp files) or check &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;&#x2F;code&gt; for temp read&#x2F;write counts.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 18: EXPLAIN now shows memory&#x2F;disk usage&lt;&#x2F;strong&gt;&lt;br&gt;
Starting with PostgreSQL 18, &lt;code&gt;EXPLAIN ANALYZE&lt;&#x2F;code&gt; reports memory and disk usage for Material nodes, including CTE materialization. You can see exactly how much memory a materialized CTE consumed and whether it spilled to disk.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h3 id=&quot;cte-and-security-barrier-views&quot;&gt;CTE and security barrier views&lt;a class=&quot;zola-anchor&quot; href=&quot;#cte-and-security-barrier-views&quot; aria-label=&quot;Anchor link for: cte-and-security-barrier-views&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;div class=&quot;sidenote&quot;&gt;A `security_barrier` view is a &quot;black box&quot; that forces the database to fully resolve the view&#x27;s internal logic before any outer filters are applied.&lt;&#x2F;div&gt;
Security-barrier views already prevent subquery flattening as a security measure (to stop user-defined functions from seeing rows they shouldn&#x27;t). When you combine a security-barrier view with a CTE, you compound the optimisation barriers. The planner can neither inline the view nor the CTE. If performance matters in this scenario, consider materializing the security-sensitive filtering into a temporary table first.
&lt;h2 id=&quot;cte-vs-subquery-vs-temporary-table&quot;&gt;CTE vs. subquery vs. temporary table&lt;a class=&quot;zola-anchor&quot; href=&quot;#cte-vs-subquery-vs-temporary-table&quot; aria-label=&quot;Anchor link for: cte-vs-subquery-vs-temporary-table&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;CTE, referenced once&lt;&#x2F;strong&gt; the planner inlines it on PG 12+, so it doesn&#x27;t affect the execution plan. This is the default choice for breaking up complex queries.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;CTE, referenced multiple times (small result)&lt;&#x2F;strong&gt; represents acceptable cost. Materialization means the subquery runs once. For aggregations or filtered subsets that produce a few hundred rows, the overhead is minimal.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;As &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;hettie-d&quot;&gt;Henrietta Dombrovskaya&lt;&#x2F;a&gt; emphasizes, &quot;The best temporary table is the one you didn&#x27;t create&quot;. Always exhaust your indexing and query-rewriting options before reaching for a temp table, as the DDL overhead often outweighs the execution gains.&lt;&#x2F;div&gt;
**CTE, referenced multiple times (large result)** ss case when you might consider a temporary table instead. A materialized CTE has no indexes and no statistics. A temporary table can have both, and as covered in [Introduction to Buffers](&#x2F;posts&#x2F;introduction-to-buffers&#x2F;), temporary tables use local buffers with simpler locking and no WAL overhead. If you&#x27;re joining against 100k+ rows from a CTE, create a temp table, add an index, and `ANALYZE` it.
&lt;p&gt;&lt;strong&gt;Data-modifying operations&lt;&#x2F;strong&gt; with writable CTE. No alternative gives you the atomic, single-statement behavior.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Recursive CTEs&lt;&#x2F;strong&gt;. There&#x27;s no alternative in pure SQL.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Large intermediate results needing indexes&#x2F;statistics&lt;&#x2F;strong&gt;. If you really need them use temporary table. As discussed in &lt;a href=&quot;&#x2F;posts&#x2F;explain-buffers&#x2F;&quot;&gt;Reading Buffer statistics&lt;&#x2F;a&gt;, temporary tables offer full planner support - indexes, statistics, and buffer management - that materialized CTEs simply don&#x27;t have.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;&#x2F;th&gt;&lt;th&gt;Recommendation&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Readability, single reference&lt;&#x2F;td&gt;&lt;td&gt;CTE (inlined, free)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Compute once, use many times (small)&lt;&#x2F;td&gt;&lt;td&gt;CTE (materialized)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Compute once, use many times (large)&lt;&#x2F;td&gt;&lt;td&gt;Temporary table&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Atomic data modification&lt;&#x2F;td&gt;&lt;td&gt;Writable CTE&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Hierarchy &#x2F; graph traversal&lt;&#x2F;td&gt;&lt;td&gt;Recursive CTE&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Need indexes on intermediate data&lt;&#x2F;td&gt;&lt;td&gt;Temporary table&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h2 id=&quot;the-pg-18-state-of-affairs&quot;&gt;The PG 18 state of affairs&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-pg-18-state-of-affairs&quot; aria-label=&quot;Anchor link for: the-pg-18-state-of-affairs&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;PostgreSQL 18 continues to refine CTE handling without any revolutionary changes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;EXPLAIN shows memory&#x2F;disk usage&lt;&#x2F;strong&gt; for CTE materialization nodes. You can finally see whether your CTE fit in &lt;code&gt;work_mem&lt;&#x2F;code&gt; or spilled to disk.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Better query plans for CTEs on the same table.&lt;&#x2F;strong&gt; The planner is smarter about eliminating redundant scans when multiple CTEs reference the same underlying table.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Data-modifying CTE fix for updatable views with rules.&lt;&#x2F;strong&gt; An edge case where writable CTEs interacted incorrectly with views defined using rules has been resolved.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;CTE inlining is mature.&lt;&#x2F;strong&gt; The core inlining logic hasn&#x27;t changed since PG 12. What has improved is everything around it - better statistics propagation (PG 17), better materialisation diagnostics (PG 18), better cost estimation.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;CTEs are a good tool. Just know when you&#x27;re holding the sharp end.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>pg_regresql: truly portable PostgreSQL statistics</title>
        <published>2026-03-21T14:32:00+00:00</published>
        <updated>2026-03-21T14:32:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/regresql-extension/"/>
        <id>https://boringsql.com/posts/regresql-extension/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/regresql-extension/">&lt;p&gt;The &lt;a href=&quot;&#x2F;posts&#x2F;portable-stats&#x2F;&quot;&gt;previous article&lt;&#x2F;a&gt; showed that PostgreSQL 18 makes optimizer statistics portable, but left one gap open:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;It&#x27;s not worth trying to inject &lt;code&gt;relpages&lt;&#x2F;code&gt; as the planner checks the actual file size and scales it proportionally.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The planner doesn&#x27;t trust &lt;code&gt;pg_class.relpages&lt;&#x2F;code&gt;. It calls &lt;code&gt;smgrnblocks()&lt;&#x2F;code&gt; to read the actual number of 8KB pages from disk. Your table is 74 pages on disk but &lt;code&gt;pg_class.relpages&lt;&#x2F;code&gt; says 123,513? The planner uses the ratio to scale &lt;code&gt;reltuples&lt;&#x2F;code&gt; down to match the actual file size. The selectivity ratios stay correct, plan shapes mostly survive, but the absolute cost estimates are off.&lt;&#x2F;p&gt;
&lt;p&gt;For debugging a single query, that&#x27;s usually fine. For automated regression testing where you compare &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; costs across runs, it breaks things. A cost threshold of 2× means something different when the baseline was computed from fake-scaled numbers.&lt;&#x2F;p&gt;
&lt;p&gt;As part of my work on &lt;a href=&quot;&#x2F;products&#x2F;regresql&#x2F;&quot;&gt;RegreSQL&lt;&#x2F;a&gt; I&#x27;m happy to announce &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringSQL&#x2F;regresql&#x2F;tree&#x2F;master&#x2F;pg_ext&quot;&gt;pg_regresql&lt;&#x2F;a&gt; extension which fixes this by hooking directly into the planner.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-the-planner-ignores-relpages&quot;&gt;Why the planner ignores relpages&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-the-planner-ignores-relpages&quot; aria-label=&quot;Anchor link for: why-the-planner-ignores-relpages&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When PostgreSQL&#x27;s planner calls &lt;code&gt;get_relation_info()&lt;&#x2F;code&gt; in &lt;code&gt;plancat.c&lt;&#x2F;code&gt;, it delegates to &lt;code&gt;estimate_rel_size()&lt;&#x2F;code&gt; which ends up in &lt;code&gt;table_block_relation_estimate_size()&lt;&#x2F;code&gt; in &lt;code&gt;tableam.c&lt;&#x2F;code&gt;. There, the actual page count comes from the storage manager:&lt;&#x2F;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&gt;curpages &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; RelationGetNumberOfBlocks&lt;&#x2F;span&gt;&lt;span&gt;(rel);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The function then computes a tuple density from &lt;code&gt;pg_class&lt;&#x2F;code&gt; (&lt;code&gt;reltuples &#x2F; relpages&lt;&#x2F;code&gt;) and multiplies it by &lt;code&gt;curpages&lt;&#x2F;code&gt; to estimate tuples. So &lt;code&gt;pg_class.reltuples&lt;&#x2F;code&gt; isn&#x27;t ignored, it&#x27;s scaled to match the real file size. The reasoning is sound for normal operation: the catalog might be stale, but the file system is always current.&lt;&#x2F;p&gt;
&lt;p&gt;The same applies to indexes. The planner reads their actual sizes from disk too.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-pg-regresql-does&quot;&gt;What pg_regresql does&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-pg-regresql-does&quot; aria-label=&quot;Anchor link for: what-pg-regresql-does&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The extension hooks into &lt;code&gt;get_relation_info_hook&lt;&#x2F;code&gt;, a planner callback that runs after PostgreSQL reads the physical file stats. The hook replaces the file-based numbers with the values stored in &lt;code&gt;pg_class&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;rel-&amp;gt;pages&lt;&#x2F;code&gt; ← &lt;code&gt;pg_class.relpages&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;rel-&amp;gt;tuples&lt;&#x2F;code&gt; ← &lt;code&gt;pg_class.reltuples&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;rel-&amp;gt;allvisfrac&lt;&#x2F;code&gt; ← &lt;code&gt;pg_class.relallvisible &#x2F; pg_class.relpages&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;It does the same for every index in &lt;code&gt;rel-&amp;gt;indexlist&lt;&#x2F;code&gt;. Pages and tuples for each index are overridden from the index&#x27;s own &lt;code&gt;pg_class&lt;&#x2F;code&gt; entry.&lt;&#x2F;p&gt;
&lt;p&gt;The guard conditions are simple: skip the override if &lt;code&gt;relpages == 0&lt;&#x2F;code&gt; (empty or never analyzed) or &lt;code&gt;reltuples == -1&lt;&#x2F;code&gt; (never analyzed). The hook only activates for tables that have been &lt;code&gt;ANALYZE&lt;&#x2F;code&gt;d or had statistics injected.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;installation&quot;&gt;Installation&lt;a class=&quot;zola-anchor&quot; href=&quot;#installation&quot; aria-label=&quot;Anchor link for: installation&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Build from source using PGXS:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;cd&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; pg_ext&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;make&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;make&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; install&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It requires no GUCs, background workers, or shared memory.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;usage&quot;&gt;Usage&lt;a class=&quot;zola-anchor&quot; href=&quot;#usage&quot; aria-label=&quot;Anchor link for: usage&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Load the extension in your session:&lt;&#x2F;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;LOAD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_regresql&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s it. Every &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; in this session will now use catalog statistics instead of file sizes. There are no functions to call, no tables to configure.&lt;&#x2F;p&gt;
&lt;p&gt;You can also load it per-database by adding it to &lt;code&gt;session_preload_libraries&lt;&#x2F;code&gt; in &lt;code&gt;postgresql.conf&lt;&#x2F;code&gt; or via &lt;code&gt;ALTER DATABASE&lt;&#x2F;code&gt;:&lt;&#x2F;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 DATABASE&lt;&#x2F;span&gt;&lt;span&gt; test_db &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; session_preload_libraries &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_regresql&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;the-difference-it-makes&quot;&gt;The difference it makes&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-difference-it-makes&quot; aria-label=&quot;Anchor link for: the-difference-it-makes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Using the same &lt;code&gt;test_orders&lt;&#x2F;code&gt; example from the &lt;a href=&quot;&#x2F;posts&#x2F;portable-stats&#x2F;&quot;&gt;previous article&lt;&#x2F;a&gt;: 10,000 actual rows, injected with production statistics claiming 50 million rows across 123,513 pages.&lt;&#x2F;p&gt;
&lt;p&gt;Without &lt;code&gt;pg_regresql&lt;&#x2F;code&gt;:&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2024-06-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                             QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Index Scan using test_orders_created_at_idx on test_orders  (cost=0.29..153.21 rows=6340 width=26)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Index Cond: (created_at &amp;gt; &amp;#39;2024-06-01&amp;#39;::date)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The plan shape is correct (index scan thanks to the histogram), but the row estimate is 6,340. For a 50-million-row table where the filter covers roughly 10% of the histogram range, the expected estimate should be in the millions. The planner saw 74 real pages on disk, scaled &lt;code&gt;reltuples&lt;&#x2F;code&gt; down to ~30,000, then applied selectivity. The ratio is preserved but the absolute number is wrong.&lt;&#x2F;p&gt;
&lt;p&gt;With &lt;code&gt;pg_regresql&lt;&#x2F;code&gt;:&lt;&#x2F;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;LOAD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_regresql&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;EXPLAIN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2024-06-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                                QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------------------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Index Scan using test_orders_created_at_idx on test_orders  (cost=0.29..153212.27 rows=10791836 width=27)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Index Cond: (created_at &amp;gt; &amp;#39;2024-06-01&amp;#39;::date)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Cost numbers now reflect the full 50 million rows.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-this-matters&quot;&gt;Where this matters&lt;a class=&quot;zola-anchor&quot; href=&quot;#where-this-matters&quot; aria-label=&quot;Anchor link for: where-this-matters&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Cost-based regression testing.&lt;&#x2F;strong&gt; If you&#x27;re comparing &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; costs between schema versions (which is what &lt;a href=&quot;&#x2F;products&#x2F;regresql&quot;&gt;RegreSQL&lt;&#x2F;a&gt; does), you need the absolute numbers to be stable and realistic. With the scaling behavior, your baseline costs are proportional to your test database size, not production. A migration that doubles a cost in production might show a 1.3× increase in CI because the scaled-down numbers compress the range.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Reproducing production plans on a laptop.&lt;&#x2F;strong&gt; Sometimes the plan shape itself changes depending on the absolute numbers. A query with multiple joins might get a different join order when the planner sees 50 million rows vs. 30,000 rows, because the cost crossover between hash join and nested loop depends on the absolute row count, not just the ratio.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Index-only scans.&lt;&#x2F;strong&gt; The &lt;code&gt;allvisfrac&lt;&#x2F;code&gt; (fraction of all-visible pages) matters for index-only scan costing. Without the hook, &lt;code&gt;allvisfrac&lt;&#x2F;code&gt; is computed from the real &lt;code&gt;relallvisible&lt;&#x2F;code&gt; catalog value divided by the real page count. With injected stats, &lt;code&gt;relallvisible&lt;&#x2F;code&gt; might be 120,000 but the real page count is 74, so the fraction clamps to 1.0 and the planner overestimates how cheap index-only scans are. The hook fixes this by using the injected &lt;code&gt;relpages&lt;&#x2F;code&gt; as the denominator.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-doesn-t-do&quot;&gt;What it doesn&#x27;t do&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-it-doesn-t-do&quot; aria-label=&quot;Anchor link for: what-it-doesn-t-do&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Column-level statistics and &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; behavior are unchanged. The extension only affects how the planner reads table and index sizes. One thing worth noting: &lt;code&gt;EXPLAIN ANALYZE&lt;&#x2F;code&gt; will still show actual row counts from the real (small) data. The extension changes the planner&#x27;s cost estimates, not query execution.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-full-workflow&quot;&gt;The full workflow&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-full-workflow&quot; aria-label=&quot;Anchor link for: the-full-workflow&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Combining PostgreSQL 18&#x27;s portable statistics with &lt;code&gt;pg_regresql&lt;&#x2F;code&gt;, the full workflow looks like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 1. dump schema and statistics from production&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pg_dump&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --schema-only -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; production_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pg_dump&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --statistics-only -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; production_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; stats.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 2. create test database&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;createdb&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -f&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 3. load minimal fixture data (optional)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -f&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; fixtures.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 4. inject production statistics&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -f&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; stats.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 5. install pg_regresql and prevent stats from being overwritten&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;SQL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;ALTER DATABASE test_db SET session_preload_libraries = &amp;#39;pg_regresql&amp;#39;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;ALTER TABLE orders SET (autovacuum_enabled = false);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;-- repeat for other tables&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;SQL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 6. reconnect and verify (plans now match production)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -c&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;EXPLAIN SELECT * FROM orders WHERE status = &amp;#39;pending&amp;#39;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;compatibility&quot;&gt;Compatibility&lt;a class=&quot;zola-anchor&quot; href=&quot;#compatibility&quot; aria-label=&quot;Anchor link for: compatibility&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The extension works with PostgreSQL 13 through 18. The portable statistics functions (&lt;code&gt;pg_restore_relation_stats&lt;&#x2F;code&gt;, &lt;code&gt;pg_restore_attribute_stats&lt;&#x2F;code&gt;) require PostgreSQL 18, but &lt;code&gt;pg_regresql&lt;&#x2F;code&gt; works with any method of writing to &lt;code&gt;pg_class&lt;&#x2F;code&gt;, including direct catalog updates on older versions.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 19 will need a small update: the &lt;code&gt;get_relation_info_hook&lt;&#x2F;code&gt; used by pg_regresql has been &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;git.postgresql.org&#x2F;gitweb&#x2F;?p=postgresql.git;a=commit;h=91f33a2ae92&quot;&gt;replaced&lt;&#x2F;a&gt; with &lt;code&gt;build_simple_rel_hook&lt;&#x2F;code&gt;. The new hook runs slightly later with different arguments, but the override logic stays the same.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Don&#x27;t use this in production.&lt;&#x2F;strong&gt; The extension makes the planner ignore reality. That&#x27;s exactly what you want for testing with injected statistics. In production, you want the planner to see the actual file sizes so it can adapt to data growth, bloat, and vacuum activity. Keep &lt;code&gt;pg_regresql&lt;&#x2F;code&gt; in your dev&#x2F;test and CI databases.
&lt;&#x2F;div&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Production query plans without production data</title>
        <published>2026-03-08T22:05:00+00:00</published>
        <updated>2026-03-08T22:05:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/portable-stats/"/>
        <id>https://boringsql.com/posts/portable-stats/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/portable-stats/">&lt;p&gt;In the &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;&quot;&gt;previous article&lt;&#x2F;a&gt; we covered how the PostgreSQL planner reads &lt;code&gt;pg_class&lt;&#x2F;code&gt; and &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; to estimate row counts, choose join strategies, and decide whether an index scan is worth it. The message was clear: when statistics are wrong, everything else goes with it.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;Streaming replication provides bit-to-bit replication, so all replicas share the same statistics with primary server.&lt;&#x2F;div&gt;
But there was one thing we didn&#x27;t talk about. Statistics are specific to the database cluster that generated them. The primary way to populate them is `ANALYZE` which requires the actual data.
&lt;p&gt;PostgreSQL 18 changed that. Two new functions: &lt;code&gt;pg_restore_relation_stats&lt;&#x2F;code&gt; and &lt;code&gt;pg_restore_attribute_stats&lt;&#x2F;code&gt; write numbers directly into the catalog tables. Combined with &lt;code&gt;pg_dump --statistics-only&lt;&#x2F;code&gt;, you can treat optimizer statistics as a deployable artifact. Compact, portable, plain SQL.&lt;&#x2F;p&gt;
&lt;p&gt;The feature was &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.cybertec-postgresql.com&#x2F;en&#x2F;preserve-optimizer-statistics-during-major-upgrades-with-postgresql-v18&#x2F;&quot;&gt;driven by the upgrade use case&lt;&#x2F;a&gt;. In the past, major version upgrades used to leave &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; empty, forcing you to run &lt;code&gt;ANALYZE&lt;&#x2F;code&gt;. Which might take hours on large clusters. With PostgreSQL 18 upgrades now transfer statistics automatically. But that&#x27;s just the beginning. The same logic lets you export statistics from production and inject them anywhere - test database, local debugging, or as part of CI pipelines.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-problem&quot; aria-label=&quot;Anchor link for: the-problem&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Your CI database has 1,000 rows. Production has 50 million. The planner makes completely different decisions for each. Running &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; in CI tells you nothing about the production plan. This is the core premise behind &lt;a href=&quot;&#x2F;products&#x2F;regresql&quot;&gt;RegreSQL&lt;&#x2F;a&gt;. Catching query plan regressions in CI is far more reliable when the planner sees production-scale statistics.&lt;&#x2F;p&gt;
&lt;p&gt;Same applies to &lt;strong&gt;debugging&lt;&#x2F;strong&gt;. A query is slow in production and you want to reproduce the plan locally, but your database has different statistics, and planner chooses the predictable path. Porting production stats can provide you that snapshot of thinking planner has to do in production, without actually going to production.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pg-restore-relation-stats&quot;&gt;pg_restore_relation_stats&lt;a class=&quot;zola-anchor&quot; href=&quot;#pg-restore-relation-stats&quot; aria-label=&quot;Anchor link for: pg-restore-relation-stats&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The first of function behind portable PostgreSQL statistics is &lt;code&gt;pg_restore_relation_stats&lt;&#x2F;code&gt;. It writes table-level data directly into &lt;code&gt;pg_class&lt;&#x2F;code&gt; in form of variadic name&#x2F;value pairs.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_restore_relation_stats(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;schemaname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;public&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;orders&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relpages&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;123513&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;reltuples&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50000000&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relallvisible&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;123513&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relallfrozen&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;120000&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But that&#x27;s just an example. Let&#x27;s modify some real statistics to see the full value. We will create a small table, inject fake production-like statistics and watch the planner to change its mind.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; test_orders&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status text NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;date NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_DATE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; test_orders (customer_id, amount, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 9999&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 5000&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[&amp;#39;pending&amp;#39;,&amp;#39;shipped&amp;#39;,&amp;#39;delivered&amp;#39;,&amp;#39;cancelled&amp;#39;])[floor(random()*4+1)::int],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2024-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;date +&lt;&#x2F;span&gt;&lt;span&gt; (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 365&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10000&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; test_orders (created_at);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; test_orders (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE test_orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When you check the current statistics, it has predictable data.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; relname, relpages, reltuples&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_class &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; relname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;test_orders&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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   | relpages | reltuples&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------+----------+-----------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; test_orders |       74 |     10000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With 10,000 rows across 74 pages, the planner picks a sequential scan.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2024-06-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                           QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Seq Scan on test_orders  (cost=0.00..199.00 rows=5891 width=26)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (created_at &amp;gt; &amp;#39;2024-06-01&amp;#39;::date)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now inject production-scale table stats:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_restore_relation_stats(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;schemaname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;public&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;test_orders&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relpages&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;123513&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;reltuples&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50000000&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relallvisible&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;123513&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And you might be surprised by the result.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2024-06-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                            QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Seq Scan on test_orders  (cost=0.00..448.45 rows=17649 width=26)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (created_at &amp;gt; &amp;#39;2024-06-01&amp;#39;::date)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The planner is still using the sequential plan. Only the estimated number of rows has changed. Why? If you remember from previous article, it&#x27;s where column level statistics come into play. Histogram bounds still match the original 10,000 rows we inserted.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pg-restore-attribute-stats&quot;&gt;pg_restore_attribute_stats&lt;a class=&quot;zola-anchor&quot; href=&quot;#pg-restore-attribute-stats&quot; aria-label=&quot;Anchor link for: pg-restore-attribute-stats&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;This function writes column-level statistics into &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; the same catalog that &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;#how-analyze-works&quot;&gt;ANALYZE populates&lt;&#x2F;a&gt; with &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;#pg_statistic-via-pg_stats---column-level-stats&quot;&gt;MCVs, histograms, and correlation&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;In previous section, we left the planner stuck on a sequential scan despite believing the table has 50 million rows. The missing piece is column-level statistics. Let&#x27;s pick up where we left off and inject histogram bounds for &lt;code&gt;created_at&lt;&#x2F;code&gt;.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_restore_attribute_stats(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;schemaname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;public&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;test_orders&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;attname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;created_at&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;inherited&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, false::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;boolean&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;null_frac&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;avg_width&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;4&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;n_distinct&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;05&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;histogram_bounds&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{2019-01-01,2019-07-01,2020-01-01,2020-07-01,2021-01-01,2021-07-01,2022-01-01,2022-07-01,2023-01-01,2023-07-01,2024-01-01}&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;correlation&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;98&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now the planner knows the data spans 5 years. A query filtering on the last 6 months of 2024 covers a narrow slice.&lt;&#x2F;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;EXPLAIN SELECT * FROM test_orders WHERE created_at &amp;gt; &amp;#39;2024-06-01&amp;#39;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                             QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Index Scan using test_orders_created_at_idx on test_orders  (cost=0.29..153.21 rows=6340 width=26)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Index Cond: (created_at &amp;gt; &amp;#39;2024-06-01&amp;#39;::date)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;div class=&quot;sidenote&quot;&gt;
Histogram bounds divide the non-MCV portion of the data into equal-population buckets. If &lt;code&gt;most_common_vals&lt;&#x2F;code&gt; accounts for most of the data, the histogram covers only the remaining tail. The number of buckets is controlled by &lt;code&gt;default_statistics_target&lt;&#x2F;code&gt; (default 100, meaning 101 bounds).
&lt;&#x2F;div&gt;
&lt;p&gt;And that&#x27;s a plan flip! The histogram tells the planner the data spans 2019–2024, so &lt;code&gt;&amp;gt; &#x27;2024-06-01&#x27;&lt;&#x2F;code&gt; matches a narrow tail. A small fraction of 50 million rows. The index scan that was ignored before is now the obvious choice. Table-level stats set the scale, column-level stats shaped the selectivity, and together they changed the plan.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
The &lt;code&gt;correlation&lt;&#x2F;code&gt; statistic tells the planner how closely the physical row order matches the column&#x27;s sort order. A value near 1.0 means sequential access patterns - making &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;#correlation-and-index-scan-cost&quot;&gt;index scan cheaper&lt;&#x2F;a&gt; because the next row is likely on the same or adjacent page. For time-series data like &lt;code&gt;created_at&lt;&#x2F;code&gt; where rows are inserted chronologically, correlation is typically very high.
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;injecting-a-skewed-distribution&quot;&gt;Injecting a skewed distribution&lt;a class=&quot;zola-anchor&quot; href=&quot;#injecting-a-skewed-distribution&quot; aria-label=&quot;Anchor link for: injecting-a-skewed-distribution&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The same function handles &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;#selectivity-in-action&quot;&gt;MCV lists&lt;&#x2F;a&gt;. In production, your &lt;code&gt;status&lt;&#x2F;code&gt; column isn&#x27;t uniform, 95% of orders are delivered, 1.5% are pending.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_restore_attribute_stats(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;schemaname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;public&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;relname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;test_orders&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;attname&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;status&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;inherited&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, false::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;boolean&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;null_frac&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;avg_width&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;9&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;n_distinct&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;most_common_vals&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{delivered,shipped,cancelled,pending,returned}&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;most_common_freqs&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{0.95,0.015,0.015,0.015,0.005}&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;real&lt;&#x2F;span&gt;&lt;span&gt;[]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You can see&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                      QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Bitmap Heap Scan on test_orders  (cost=8.93..90.42 rows=599 width=27)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Recheck Cond: (status = &amp;#39;pending&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Bitmap Index Scan on test_orders_status_idx  (cost=0.00..8.78 rows=599 width=0)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Index Cond: (status = &amp;#39;pending&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;and compare it with&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;delivered&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                            QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Seq Scan on test_orders  (cost=0.00..448.45 rows=28458 width=27)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (status = &amp;#39;delivered&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(2 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Same column, same operator, different plans. The planner uses a bitmap index scan for &lt;code&gt;pending&lt;&#x2F;code&gt; (1.5% rare enough to justify the index) and a sequential scan for &lt;code&gt;delivered&lt;&#x2F;code&gt; (95% being most of the table). The selectivity ratios from the MCV list drive the plan choice.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
You might have noticed the row estimates (599 and 28,458) are lower than you&#x27;d expect for a 50-million-row table. The planner checks the actual physical file size. Our table is only 74 pages on disk, not the 123,513 we injected. Hence the planner scales &lt;code&gt;reltuples&lt;&#x2F;code&gt; and &lt;code&gt;relpages&lt;&#x2F;code&gt; down proportionally. The absolute numbers shrink, but the &lt;i&gt;ratios&lt;&#x2F;i&gt; between them stay correct, and it&#x27;s the ratios that determine plan shape. When you use &lt;code&gt;pg_dump --statistics-only&lt;&#x2F;code&gt; in practice, you&#x27;re typically restoring into a database with comparable data volume, so the estimates align naturally.
&lt;&#x2F;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: 2px; width: 48px; padding: 0.4rem;&quot;&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:var(--viz-page-header);&quot;&gt;&lt;&#x2F;div&gt;
        &lt;div style=&quot;height:6px; width:60%; margin:0 auto; border-radius:0 0 2px 2px; background:var(--viz-page-lp);&quot;&gt;&lt;&#x2F;div&gt;
        &lt;div style=&quot;height:10px; border-radius:2px; background:var(--viz-page-header);&quot;&gt;&lt;&#x2F;div&gt;
    &lt;&#x2F;div&gt;
    &lt;div class=&quot;visualizer-banner__content&quot;&gt;
        &lt;strong&gt;pg_regresql extension&lt;&#x2F;strong&gt;
        &lt;p&gt;The &lt;code&gt;pg_regresql&lt;&#x2F;code&gt; extension fixes this scaling problem. It hooks into the planner to trust the injected &lt;code&gt;relpages&lt;&#x2F;code&gt; value instead of reading the physical file size, so cost estimates match production even when your test database is tiny.&lt;&#x2F;p&gt;
        &lt;a href=&quot;&#x2F;posts&#x2F;regresql-extension&#x2F;&quot; class=&quot;visualizer-banner__button&quot;&gt;Read more&lt;&#x2F;a&gt;
    &lt;&#x2F;div&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;pg-dump&quot;&gt;pg_dump&lt;a class=&quot;zola-anchor&quot; href=&quot;#pg-dump&quot; aria-label=&quot;Anchor link for: pg-dump&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The functions we covered are the mechanics. For operational use &lt;code&gt;pg_dump&lt;&#x2F;code&gt; provides everything you need. PostgreSQL 18 added three flags.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Flag&lt;&#x2F;th&gt;&lt;th&gt;Effect&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--statistics&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;dump the statistics (you have to request it explicitely)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--statistics-only&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;dump only the statistics, not schema or data&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--no-statistics&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;do not dump statistics&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;When you export the statistics for your production database&lt;&#x2F;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;pg_dump --statistics-only -d production_db &amp;gt; stats.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;you will see the output is series of &lt;code&gt;SELECT pg_restore_relation_stats(...)&lt;&#x2F;code&gt; and &lt;code&gt;SELECT pg_restore_attribute_stats(...)&lt;&#x2F;code&gt; calls. Exactly as we explained above.&lt;&#x2F;p&gt;
&lt;p&gt;The full workflow to turn your production data into testable plans might look like this:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 1. dump schema from production&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pg_dump&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --schema-only -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; production_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 2. dump statistics from production&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pg_dump&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; --statistics-only -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; production_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; stats.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 3. create test database with schema&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;createdb&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -f&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; schema.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 4. load fixture data (optional; masked, minimal)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -f&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; fixtures.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 5. inject production statistics&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -f&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; stats.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;# 6. query plans now match production&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;psql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -d&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; test_db&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; -c&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;EXPLAIN SELECT * FROM test_orders WHERE status = &amp;#39;pending&amp;#39;&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;div class=&quot;callout&quot;&gt;
Statistics dumps are tiny. A database with hundreds of tables and thousands of columns produces a statistics dump under 1MB. The production data might be hundreds of GB. The statistics that describe it fit in a text file.
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;keeping-injected-statistics-alive&quot;&gt;Keeping injected statistics alive&lt;a class=&quot;zola-anchor&quot; href=&quot;#keeping-injected-statistics-alive&quot; aria-label=&quot;Anchor link for: keeping-injected-statistics-alive&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Now you might ask yourself, where&#x27;s the catch? And there&#x27;s a big one, the autovacuum will eventually kick in and run &lt;code&gt;ANALYZE&lt;&#x2F;code&gt;. Which will overwrite your injected statistics with real numbers and you are back where you started.&lt;&#x2F;p&gt;
&lt;p&gt;To prevent this, disable autovacuum analyze on the tables you&#x27;ve injected.&lt;&#x2F;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;-- disable autovacuum&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; (autovacuum_enabled &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; false);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- or set analyze threshold so high it nevers kicks-in&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; test_orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; (autovacuum_analyze_threshold &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2147483647&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Be careful here.&lt;&#x2F;strong&gt;
&lt;p&gt;
 If you&#x27;re also writing data to these tables in dev:  running migrations, loading fixtures, testing inserts, the injected statistics will drift further from reality with every write. The planner will plan based on a production distribution that no longer reflects the local data. &lt;&#x2F;p&gt;
&lt;p&gt;For read-only query plan testing this is exactly what you want. For integration tests that modify data, you may need to re-inject statistics after each test run.&lt;&#x2F;p&gt;
&lt;p&gt;And please, never ever do this in production!&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;what-s-not-covered&quot;&gt;What&#x27;s not covered?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-s-not-covered&quot; aria-label=&quot;Anchor link for: what-s-not-covered&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As we have seen earlier, it&#x27;s not worth trying to inject &lt;code&gt;relpages&lt;&#x2F;code&gt; as the planner checks the actual file size and scales it proportationally. This limits the number of absolute rows planner might estimate. I.e. to get comparable numbers to production environment you still would have to create comparable data volume (which isn&#x27;t a problem when talking about the primary use case of this feature - restoring backups).&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s also worth to note that &lt;code&gt;CREATE STATISTICS&lt;&#x2F;code&gt; used for &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;#extended-statistics&quot;&gt;multivariate correlations, distinct counts across column groups and MCV lists for column combinations&lt;&#x2F;a&gt; are not covered within PostgreSQL 18.  Those still require &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; after restore. PostgreSQL 19 will close this gap with &lt;code&gt;pg_restore_extended_stats()&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;security&quot;&gt;Security&lt;a class=&quot;zola-anchor&quot; href=&quot;#security&quot; aria-label=&quot;Anchor link for: security&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The restore functions require the &lt;code&gt;MAINTAIN&lt;&#x2F;code&gt; privilege on the target table. This is the same privilege needed for &lt;code&gt;ANALYZE&lt;&#x2F;code&gt;, &lt;code&gt;VACUUM&lt;&#x2F;code&gt;, &lt;code&gt;REINDEX&lt;&#x2F;code&gt;, and &lt;code&gt;CLUSTER&lt;&#x2F;code&gt; as it was &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-predefined-roles&#x2F;&quot;&gt;introduced in PostgreSQL 17&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The easiest way to grant it for automation:&lt;&#x2F;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;GRANT&lt;&#x2F;span&gt;&lt;span&gt; pg_maintain &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; ci_service_account;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This grants &lt;code&gt;MAINTAIN&lt;&#x2F;code&gt; on all tables in the database. Enough for a CI pipeline to inject statistics without needing superuser.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>PostgreSQL Statistics: Why queries run slow</title>
        <published>2026-02-26T23:35:00+00:00</published>
        <updated>2026-02-26T23:35:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/postgresql-statistics/"/>
        <id>https://boringsql.com/posts/postgresql-statistics/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/postgresql-statistics/">&lt;p&gt;Every query starts with a plan. Every slow query probably starts with a bad one. And more often than not, the statistics are to blame. But how does it really work? PostgreSQL doesn&#x27;t run the query to find out - it estimates the cost. It reads pre-computed data from &lt;code&gt;pg_class&lt;&#x2F;code&gt; and &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; and does the maths to figure out the cheapest path to your data.&lt;&#x2F;p&gt;
&lt;p&gt;In ideal scenario, the numbers read are accurate, and you get the plan you expect. But when they are stale, the situation gets out of control. Planner estimates 500 rows, plans a nested loop, and hits 25,000. What seemed as optimal plan turns into a cascading failure.&lt;&#x2F;p&gt;
&lt;p&gt;How do statistics get stale? It can be either bulk load, a schema migration, faster-than-expected growth, or simply &lt;code&gt;VACUUM&lt;&#x2F;code&gt; not keeping up. Whatever the cause, the result is the same. The planner is flying blind. Choosing paths based on reality that no longer exists.&lt;&#x2F;p&gt;
&lt;p&gt;In this post we will go inside the two catalogs the planner depends on, understand what &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; actually gets for you from a 30,000-row table, and see how those numbers determine whether your query takes milliseconds or minutes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sample-schema&quot;&gt;Sample schema&lt;a class=&quot;zola-anchor&quot; href=&quot;#sample-schema&quot; aria-label=&quot;Anchor link for: sample-schema&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;For demonstration purposes we will use the same schema as in the article &lt;a href=&quot;&#x2F;posts&#x2F;explain-buffers&#x2F;&quot;&gt;Reading Buffer statistics in EXPLAIN output&lt;&#x2F;a&gt;.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customers&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name text NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; customers(id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    status text NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pending&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    note &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;date NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_DATE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; customers (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Customer &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; i&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2000&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; i;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; orders (customer_id, amount, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, note, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1999&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 5&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[&amp;#39;pending&amp;#39;,&amp;#39;shipped&amp;#39;,&amp;#39;delivered&amp;#39;,&amp;#39;cancelled&amp;#39;])[floor(random()*4+1)::int],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Some note text here for padding&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ELSE NULL END&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2022-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;date +&lt;&#x2F;span&gt;&lt;span&gt; (random()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1095&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE customers;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;what-the-planner-reads&quot;&gt;What the Planner Reads&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-the-planner-reads&quot; aria-label=&quot;Anchor link for: what-the-planner-reads&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As mentioned above, every decision the planner makes is based on two sources:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;table-level metadata from &lt;code&gt;pg_class&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;column-level metadata from &lt;code&gt;pg_statistic&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;pg-class-relational-level-stats&quot;&gt;pg_class - relational-level stats&lt;a class=&quot;zola-anchor&quot; href=&quot;#pg-class-relational-level-stats&quot; aria-label=&quot;Anchor link for: pg-class-relational-level-stats&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;div class=&quot;sidenote&quot;&gt;It actually tracks all &lt;i&gt;relations&lt;&#x2F;i&gt;. Not just tables and indexes, but also partitions, TOAST tables, sequences, composite types, and views. &lt;&#x2F;div&gt;Every table, index and materialized view has a row in `pg_class`. Before it even looks at column-level statistics, the planner reads four values from it:
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Column&lt;&#x2F;th&gt;&lt;th&gt;Meaning&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;relpages&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Number of 8KB pages representing the table on the disk&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;reltuples&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Estimated number of live rows in the table&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;relallvisible&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Pages where all tuples are visible to all transactions&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;For our sample table it looks like this.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; relname, relpages, reltuples, relallvisible&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; relname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;orders&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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 | relpages | reltuples | relallvisible&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------+----------+-----------+---------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; orders  |      856 |    100000 |           856&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;div class=&quot;sidenote&quot;&gt;Compared to reading page from disk, handling single tuple represents tiny overhead. Per-row overhead is configured using &lt;code&gt;cpu_tuple_cost&lt;&#x2F;code&gt;, which defaults to &lt;code&gt;0.01&lt;&#x2F;code&gt;&lt;&#x2F;div&gt;
&lt;p&gt;The planner sees 100,000 rows spread across 856 pages. Every cost estimate starts from those two numbers. &lt;code&gt;relpages&lt;&#x2F;code&gt; drives sequential scan cost - each page is one unit of I&#x2F;O work as configured via &lt;code&gt;seq_page_cost&lt;&#x2F;code&gt;. &lt;code&gt;reltuples&lt;&#x2F;code&gt; controls estimates for joins, aggregations, and pretty much everything else.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;The &lt;code&gt;reltuples&lt;&#x2F;code&gt; value is only an estimate, not a live count. It&#x27;s updated by &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; (and autovacuum), not by individual INSERTs or DELETEs. Between ANALYZE runs, PostgreSQL scales &lt;code&gt;reltuples&lt;&#x2F;code&gt; proportionally when &lt;code&gt;relpages&lt;&#x2F;code&gt; value changes - if the table grows by 10% in pages, the planner assumes 10% more rows too.&lt;&#x2F;p&gt;
&lt;p&gt;This works well enough for normal growth, but breaks down with bloat. If dead tuples are inflating the number of pages used, without adding real rows, the planner overestimates the table size.&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;p&gt;The other column mentioned above matters for specific operations. &lt;code&gt;relallvisible&lt;&#x2F;code&gt; tells the planner how much of the table can be read with an index-only scan. Meaning an index-only scan can return results using the index without checking the heap for visibility.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;pg-statistic-via-pg-stats-column-level-stats&quot;&gt;pg_statistic (via pg_stats) - column-level stats&lt;a class=&quot;zola-anchor&quot; href=&quot;#pg-statistic-via-pg-stats-column-level-stats&quot; aria-label=&quot;Anchor link for: pg-statistic-via-pg-stats-column-level-stats&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Knowing the size of a table is only half the picture. To estimate how many rows might match, the planner needs to understand the data inside each column. PostgreSQL maintains statistics in &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; catalog. Which you&#x27;re most likely never going to use directly. In practice you will use the view &lt;code&gt;pg_stats&lt;&#x2F;code&gt; which presents the human-friendly data behind it.&lt;&#x2F;p&gt;
&lt;p&gt;The most interesting values it exposes are:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Statistic&lt;&#x2F;th&gt;&lt;th&gt;What it tells the planner&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;null_frac&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Fraction of entries that are NULL values&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;avg_width&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Average width in bytes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;n_distinct&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Number of distinct values (negative means fraction of rows)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;most_common_vals&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Most frequent values&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;most_common_freqs&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Frequencies of those values&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;histogram_bounds&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Values dividing the remaining data into equal-population buckets (most_common_vals are excluded)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;correlation&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Statistical correlation between physical row ordering and logical ordering of the column values&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&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;&#x2F;span&gt;&lt;span&gt; attname, null_frac, avg_width, n_distinct,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       most_common_vals, most_common_freqs, histogram_bounds, correlation&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stats&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; tablename &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;orders&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span&gt; attname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;status&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;attname           | status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;null_frac         | 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;avg_width         | 8&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;n_distinct        | 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;most_common_vals  | {pending,shipped,delivered,cancelled}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;most_common_freqs | {0.25396666,0.25,0.24973333,0.2463}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;histogram_bounds  |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;correlation       | 0.2524199&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;From this planner knows there are exactly 4 distinct values, more or less equally distributed, without any NULL values. When you write a predicate &lt;code&gt;WHERE status = &#x27;pending&#x27;&lt;&#x2F;code&gt; it will estimate ~25% of rows are going to match. All that by not running a query, but reading the catalog row.&lt;&#x2F;p&gt;
&lt;p&gt;Different situation is when checking column &lt;code&gt;note&lt;&#x2F;code&gt;.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; attname, null_frac, avg_width, n_distinct,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       array_length(most_common_vals, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; mcv_count,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       array_length(histogram_bounds, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; histogram_buckets&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stats&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; tablename &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;orders&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span&gt; attname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;note&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;attname           | note&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;null_frac         | 0.6982&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;avg_width         | 32&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;n_distinct        | 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;mcv_count         | 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;histogram_buckets |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;For this column there&#x27;s ~70% of NULLs, and only one distinct value. Therefore &lt;code&gt;WHERE note IS NOT NULL&lt;&#x2F;code&gt; will estimate 30% of rows.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;selectivity-in-action&quot;&gt;Selectivity in Action&lt;a class=&quot;zola-anchor&quot; href=&quot;#selectivity-in-action&quot; aria-label=&quot;Anchor link for: selectivity-in-action&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Now that we have covered what data the planner has available, let&#x27;s have a look at how it&#x27;s used to estimate how many rows a certain part of a query will read. This &quot;guess&quot; is called &lt;strong&gt;Selectivity&lt;&#x2F;strong&gt;. And it&#x27;s defined as a floating-point number between 0 and 1.&lt;&#x2F;p&gt;
&lt;p&gt;The formula is pretty simple:&lt;&#x2F;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;  Estimated Rows = Total Rows * Selectivity&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But the way Selectivity is calculated depends entirely on the operator you use (&lt;code&gt;=&lt;&#x2F;code&gt;, &lt;code&gt;&amp;lt;&lt;&#x2F;code&gt;, &lt;code&gt;&amp;gt;&lt;&#x2F;code&gt; or &lt;code&gt;LIKE&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-equality-most-common-values&quot;&gt;The equality (Most Common Values)&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-equality-most-common-values&quot; aria-label=&quot;Anchor link for: the-equality-most-common-values&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Equality is easiest to start with. When you use &lt;code&gt;WHERE status = &#x27;shipped&#x27;&lt;&#x2F;code&gt; the planner first checks &lt;code&gt;most_common_vals&lt;&#x2F;code&gt; (MCV) list. If there&#x27;s a match, the selectivity is same as the one provided by &lt;code&gt;most_common_freqs&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;If the value isn&#x27;t in the list, the planner assumes the value is part of the &quot;remaining&quot; population. It subtracts all MCV frequencies from 1.0 and divides the remainder by the number of other distinct values.&lt;&#x2F;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;(1.0 - (SELECT sum(s) FROM unnest(most_common_freqs) s))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; &#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(n_distinct - array_length(most_common_vals, 1))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;the-range-lookup-the-histogram&quot;&gt;The Range lookup (The Histogram)&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-range-lookup-the-histogram&quot; aria-label=&quot;Anchor link for: the-range-lookup-the-histogram&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Life would be easy if we would be looking only for exact values. Most of the time we need to utilise range lookup. For example &lt;code&gt;WHERE amount &amp;gt; 400&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;MCVs are useless in this case as there might be thousands or millions of unique constants. This is where &lt;code&gt;histogram_bounds&lt;&#x2F;code&gt; comes in. PostgreSQL divides the column values into a number of buckets, where each bucket contains equal number of rows (not values).&lt;&#x2F;p&gt;
&lt;p&gt;The Selectivity in this case is determined by how many buckets your query covers. In our example, if we have bounds &lt;code&gt;(100, 200, 300, 400, 500, 600)&lt;&#x2F;code&gt; the planner will establish it covers 2 full buckets. Since there are 5 buckets in total the Selectivity is going to be 0.4 (2&#x2F;5).&lt;&#x2F;p&gt;
&lt;p&gt;If you are now wondering where 2 and 5 come from? The &lt;code&gt;histogram_bounds&lt;&#x2F;code&gt; array defines boundaries between buckets. With bounds &lt;code&gt;(100, 200, 300, 400, 500, 600)&lt;&#x2F;code&gt; there are 5 buckets in total.&lt;&#x2F;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;(100-200), (200-300), (300-400), (400-500), (500-600)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And the same logic applies to matching. The condition &lt;code&gt;amount &amp;gt; 400&lt;&#x2F;code&gt; matches on &lt;code&gt;(400-500)&lt;&#x2F;code&gt; and &lt;code&gt;(500-600)&lt;&#x2F;code&gt; buckets (2 in total).&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The histogram&#x27;s biggest weakness is linear interpolation. If your data has massive spikes in distribution the planner will always assume a perfect distribution.&lt;&#x2F;div&gt;
&lt;p&gt;Slightly more complex situation happens in the cases where your value falls inside a bucket. If your query has &lt;code&gt;WHERE amount &amp;gt; 350&lt;&#x2F;code&gt; the planner locates the bucket containing value 350 (in our example &lt;code&gt;(300-400)&lt;&#x2F;code&gt;), assumes the data is linearly distributed and calculates the ratio within that bucket.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;search-and-pattern-matching&quot;&gt;Search and pattern matching&lt;a class=&quot;zola-anchor&quot; href=&quot;#search-and-pattern-matching&quot; aria-label=&quot;Anchor link for: search-and-pattern-matching&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;This is probably the most treacherous territory for the planner. For substring matching patterns like &lt;code&gt;WHERE note LIKE &#x27;%middle%&#x27;&lt;&#x2F;code&gt;, there&#x27;s no histogram or list of values to rely on. The planner must fall back to &quot;magic constants&quot; hardcoded in the PostgreSQL source code.&lt;&#x2F;p&gt;
&lt;p&gt;The default for generic patterns is 0.5% of the total rows, defined as&lt;&#x2F;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;#define DEFAULT_MATCH_SEL  0.005&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Slightly better situation comes for prefixed matches like &lt;code&gt;WHERE note LIKE &#x27;boringSQL%&#x27;&lt;&#x2F;code&gt; where PostgreSQL can fall back to range conditions and use histogram bounds. While this is a subtle difference, it makes a night and day difference.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;correlation-and-index-scan-cost&quot;&gt;Correlation and index scan cost&lt;a class=&quot;zola-anchor&quot; href=&quot;#correlation-and-index-scan-cost&quot; aria-label=&quot;Anchor link for: correlation-and-index-scan-cost&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Remember &lt;code&gt;correlation&lt;&#x2F;code&gt; from the &lt;code&gt;pg_stats&lt;&#x2F;code&gt;? It says how closely the physical order of rows on disk matches the logical order of column values. Values close to 1.0 mean high correlation, values near 0 mean data is laid out randomly across pages.&lt;&#x2F;p&gt;
&lt;p&gt;If you recall &lt;a href=&quot;&#x2F;posts&#x2F;inside-the-8kb-page&#x2F;&quot;&gt;how data is organized in 8KB page&lt;&#x2F;a&gt; the row locality matters. This matters because it determines whether an index scan is worth it. The planner assumes a random page read costs 4× more than a sequential one (&lt;code&gt;random_page_cost = 4.0&lt;&#x2F;code&gt; vs &lt;code&gt;seq_page_cost = 1.0&lt;&#x2F;code&gt;). When correlation is high, the rows an index points to are physically adjacent.  The planner expects sequential I&#x2F;O and costs the scan cheaply. When correlation is low, each lookup likely hits a different page, and the planner costs each of those reads at the higher random rate. That difference alone can be enough to make a sequential scan cheaper than an index scan.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;n-distinct-and-join-estimation&quot;&gt;n_distinct and join estimation&lt;a class=&quot;zola-anchor&quot; href=&quot;#n-distinct-and-join-estimation&quot; aria-label=&quot;Anchor link for: n-distinct-and-join-estimation&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Value &lt;code&gt;n_distinct&lt;&#x2F;code&gt; plays an important role in joins. This further stresses the importance of running &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; after bulk data changes.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s imagine this simplified logic for the equality join:&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;In reality, Postgres also accounts for &lt;code&gt;null_frac&lt;&#x2F;code&gt; (NULLs don&#x27;t join) and MCVs on both sides. If both sides have MCVs, it calculates the &quot;inner product&quot; of the frequencies.&lt;&#x2F;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;estimated_rows = (rows_left × rows_right) &#x2F; max(n_distinct_left, n_distinct_right)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Let&#x27;s say you&#x27;re trying to join two tables, both having roughly 2,000 distinct values for the join key. Outdated &lt;code&gt;n_distinct&lt;&#x2F;code&gt; will cause significant estimation drifts and the planner may pick a wrong join strategy altogether.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;this-is-the-heap-we-are-talking-about&quot;&gt;This is the heap we are talking about&lt;a class=&quot;zola-anchor&quot; href=&quot;#this-is-the-heap-we-are-talking-about&quot; aria-label=&quot;Anchor link for: this-is-the-heap-we-are-talking-about&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Please, keep in mind this describes how the planner estimates rows for a basic heap scan. Indexes, join selectivity, and complex types like JSONB each come with their own estimation logic, operator handling and quirks. The fundamentals of estimation are the same nevertheless.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-if-there-are-no-statistics&quot;&gt;What if there are no statistics?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-if-there-are-no-statistics&quot; aria-label=&quot;Anchor link for: what-if-there-are-no-statistics&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;So far we have touched on why up-to-date statistics are a must. But what if there are no statistics at all? For example for a new table or new column when &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; has not yet run.&lt;&#x2F;p&gt;
&lt;p&gt;In those cases PostgreSQL falls back to hardcoded defaults.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Condition type&lt;&#x2F;th&gt;&lt;th&gt;Default selectivity&lt;&#x2F;th&gt;&lt;th&gt;Constant&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Equality (&lt;code&gt;=&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td&gt;0.5%&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;DEFAULT_EQ_SEL = 0.005&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Range (&lt;code&gt;&amp;gt;&lt;&#x2F;code&gt;, &lt;code&gt;&amp;lt;&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td&gt;33.3%&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;DEFAULT_INEQ_SEL = 0.3333&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Range (&lt;code&gt;BETWEEN&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td&gt;0.5%&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;DEFAULT_RANGE_INEQ_SEL = 0.005&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Pattern matching (&lt;code&gt;LIKE&lt;&#x2F;code&gt;)&lt;&#x2F;td&gt;&lt;td&gt;0.5%&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;DEFAULT_MATCH_SEL = 0.005&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;IS NULL&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;0.5%&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;DEFAULT_UNK_SEL = 0.005&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;IS NOT NULL&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;99.5%&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;DEFAULT_NOT_UNK_SEL = 0.995&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Nothing that a quick &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; can&#x27;t fix, correct? Or maybe not.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;Where No Statistics Go&lt;&#x2F;strong&gt;&lt;br&#x2F;&gt;
While this article focuses on statistics and getting them right, there are situations where no statistics will be available (never or not predictably). If you believe it won&#x27;t affect you, please, think twice.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CTEs and subqueries&lt;&#x2F;strong&gt; when not inlined&#x2F;materialized have no statistics. See &lt;a href=&quot;&#x2F;posts&#x2F;good-cte-bad-cte&#x2F;&quot;&gt;Good CTE, Bad CTE&lt;&#x2F;a&gt; for when exactly this happens.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Temporary tables&lt;&#x2F;strong&gt; are not touched by autovacuum, so no automatic &lt;code&gt;ANALYZE&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Foreign tables&lt;&#x2F;strong&gt; do not guarantee stats are propagated.&lt;&#x2F;li&gt;
&lt;li&gt;And, to a big surprise, &lt;strong&gt;computed expressions in WHERE&lt;&#x2F;strong&gt; like &lt;code&gt;WHERE amount * 1.1 &gt; 500&lt;&#x2F;code&gt; or &lt;code&gt;lower(email) = &#x27;hello@example.com&#x27;&lt;&#x2F;code&gt;; unless you create an expression index or extended statistics.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And btw, do you know about &lt;code&gt;TRUNCATE&lt;&#x2F;code&gt; - it&#x27;s fast way to get rid of the data, but it leaves statistics behind...&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;how-analyze-works&quot;&gt;How &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; Works&lt;a class=&quot;zola-anchor&quot; href=&quot;#how-analyze-works&quot; aria-label=&quot;Anchor link for: how-analyze-works&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As we have already mentioned several times, &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; is the only mechanism that populates &lt;code&gt;pg_class&lt;&#x2F;code&gt; and &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; with fresh data. Understanding what it samples, what it computes, and what it misses, is key to understanding why statistics are sometimes wrong.&lt;&#x2F;p&gt;
&lt;p&gt;The process itself consists of 6 separate steps (as reported by &lt;code&gt;pg_stat_progress_analyze&lt;&#x2F;code&gt;):&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;initializing&lt;&#x2F;li&gt;
&lt;li&gt;acquiring sample rows&lt;&#x2F;li&gt;
&lt;li&gt;acquiring inherited sample rows (child&#x2F;partitioned tables)&lt;&#x2F;li&gt;
&lt;li&gt;computing statistics (MCVs, histograms, correlation, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;computing extended statistics (see below)&lt;&#x2F;li&gt;
&lt;li&gt;finalizing and writing to &lt;code&gt;pg_statistic&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;For our purposes we will only cover sampling, computing statistics and writing to &lt;code&gt;pg_statistic&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-sampling-mechanism&quot;&gt;The sampling mechanism&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-sampling-mechanism&quot; aria-label=&quot;Anchor link for: the-sampling-mechanism&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;ANALYZE actually doesn&#x27;t read the entire table. It samples what is considered to be a statistically justified minimum sample size (defined as 300 for the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Reservoir_sampling&quot;&gt;reservoir sampling&lt;&#x2F;a&gt; algorithm). For PostgreSQL that means 300 × &lt;code&gt;default_statistics_target&lt;&#x2F;code&gt; rows.&lt;&#x2F;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_statistics_target;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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; default_statistics_target&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;---------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 100&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With the default target of 100, that&#x27;s 30,000 rows. For our 100,000-row orders table, &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; reads about 30% of the data. For a 50-million-row table, it reads 0.06%. The same target also controls the size of the MCV list and histogram. Up to 100 entries each.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;With a target of 100, don&#x27;t be surprised that &lt;code&gt;histogram_bounds&lt;&#x2F;code&gt; contains 101 values. 100 buckets need 101 boundaries to close them.&lt;&#x2F;div&gt;
&lt;p&gt;The sampling is two-stage. First, &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; selects a random set of pages. Then it reads all live rows from those pages. This gives a representative cross-section without reading every page.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;computing-statistics&quot;&gt;Computing statistics&lt;a class=&quot;zola-anchor&quot; href=&quot;#computing-statistics&quot; aria-label=&quot;Anchor link for: computing-statistics&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Once ANALYZE has its 30,000 sample rows (considering the default values), it processes each column independently. The pipeline for a single column looks roughly like this:&lt;&#x2F;p&gt;
&lt;p&gt;First, it counts NULLs and calculates &lt;code&gt;null_frac&lt;&#x2F;code&gt; and &lt;code&gt;avg_width&lt;&#x2F;code&gt; - the cheapest statistics to compute. Then it sorts the non-null values and builds the MCV list by counting duplicates. Values that appear frequently enough make the cut; the rest are passed to the histogram builder, which divides them into equal-population buckets. Finally, because the values are already sorted, ANALYZE compares the logical sort order against the physical tuple positions (which page each row came from) to compute &lt;code&gt;correlation&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The key detail here is that MCVs and histograms are not built from the same pool. Values that land in &lt;code&gt;most_common_vals&lt;&#x2F;code&gt; are excluded from &lt;code&gt;histogram_bounds&lt;&#x2F;code&gt;. This is why you&#x27;ll sometimes see a column with MCVs but no histogram, or a histogram but no MCVs. They represent different slices of the same data.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;writing-to-pg-statistic&quot;&gt;Writing to pg_statistic&lt;a class=&quot;zola-anchor&quot; href=&quot;#writing-to-pg-statistic&quot; aria-label=&quot;Anchor link for: writing-to-pg-statistic&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Once all columns are processed, ANALYZE writes the results into &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; - one row per column. If a row for that column already exists, it&#x27;s updated in place. This is a regular heap update, which means the old row becomes a dead tuple. On tables with many columns or frequent ANALYZE runs, this can cause &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; itself to bloat.&lt;&#x2F;p&gt;
&lt;p&gt;After &lt;code&gt;pg_statistic&lt;&#x2F;code&gt; is updated, ANALYZE refreshes &lt;code&gt;relpages&lt;&#x2F;code&gt;, &lt;code&gt;reltuples&lt;&#x2F;code&gt;, and &lt;code&gt;relallvisible&lt;&#x2F;code&gt; in &lt;code&gt;pg_class&lt;&#x2F;code&gt;. These values are recalculated from the sampling, not from a full table scan (they are estimates too).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;controlling-statistics-quality&quot;&gt;Controlling statistics quality&lt;a class=&quot;zola-anchor&quot; href=&quot;#controlling-statistics-quality&quot; aria-label=&quot;Anchor link for: controlling-statistics-quality&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;default-statistics-target&quot;&gt;default_statistics_target&lt;a class=&quot;zola-anchor&quot; href=&quot;#default-statistics-target&quot; aria-label=&quot;Anchor link for: default-statistics-target&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The default target of 100 works well for most columns. It means up to 100 MCVs, 101 histogram bounds, and a 30,000-row sample. Increasing it helps when:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A column has many distinct values and the top 100 don&#x27;t cover enough of the distribution&lt;&#x2F;li&gt;
&lt;li&gt;Range queries on skewed data produce bad estimates because histogram buckets are too coarse&lt;&#x2F;li&gt;
&lt;li&gt;Join estimates are off because &lt;code&gt;n_distinct&lt;&#x2F;code&gt; is inaccurate&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The cost scales linearly. Setting it to 1000 means 300,000 sampled rows, up to 1000 MCVs, more catalog storage, and slower planning from larger arrays to search. The maximum is 10,000.&lt;&#x2F;p&gt;
&lt;p&gt;You don&#x27;t have to raise it globally. For a single problematic column:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; COLUMN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status SET STATISTICS&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now &lt;code&gt;status&lt;&#x2F;code&gt; gets up to 500 MCVs and 501 histogram bounds, while every other column stays at 100.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;extended-statistics&quot;&gt;Extended statistics&lt;a class=&quot;zola-anchor&quot; href=&quot;#extended-statistics&quot; aria-label=&quot;Anchor link for: extended-statistics&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Standard statistics treat each column independently. This means the planner can&#x27;t know that &lt;code&gt;city = &#x27;Edinburgh&#x27;&lt;&#x2F;code&gt; and &lt;code&gt;country = &#x27;UK&#x27;&lt;&#x2F;code&gt; are correlated. It multiplies their selectivities independently, potentially underestimating by orders of magnitude.&lt;&#x2F;p&gt;
&lt;p&gt;Extended statistics solve this for specific column combinations:&lt;&#x2F;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 STATISTICS&lt;&#x2F;span&gt;&lt;span&gt; orders_status_date (dependencies, ndistinct, mcv)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    ON status&lt;&#x2F;span&gt;&lt;span&gt;, created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This tells ANALYZE to compute functional dependencies, combined distinct counts, and combined MCVs between the columns. The planner can then use these to avoid the independence assumption for queries filtering on both columns.&lt;&#x2F;p&gt;
&lt;p&gt;The three types of extended statistics serve different purposes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;dependencies&lt;&#x2F;strong&gt; capture functional dependencies between columns. Helps when knowing one column&#x27;s value determines or narrows another&#x27;s (e.g. &lt;code&gt;zip_code&lt;&#x2F;code&gt; largely determines &lt;code&gt;city&lt;&#x2F;code&gt;).&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;ndistinct&lt;&#x2F;strong&gt; tracks the number of distinct value combinations across columns. Helps with &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; on multiple columns where the planner would otherwise multiply distinct counts independently.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;mcv&lt;&#x2F;strong&gt; builds a combined most-common-values list for column tuples. The most powerful but most expensive option. Helps with multi-column WHERE conditions on correlated values.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;You can create extended statistics with any combination of these types. Start with &lt;code&gt;dependencies&lt;&#x2F;code&gt; as it&#x27;s cheapest, add &lt;code&gt;mcv&lt;&#x2F;code&gt; when multi-column filter estimates are consistently wrong.&lt;&#x2F;p&gt;
&lt;p&gt;Extended statistics are worth creating when you see &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; estimates that are consistently wrong on multi-column filters, and the columns are logically correlated. They are computed during step 5 of the ANALYZE process and stored in &lt;code&gt;pg_statistic_ext_data&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;diagnosing-bad-estimates&quot;&gt;Diagnosing bad estimates&lt;a class=&quot;zola-anchor&quot; href=&quot;#diagnosing-bad-estimates&quot; aria-label=&quot;Anchor link for: diagnosing-bad-estimates&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When a query is slow, the first question should always be: did the planner estimate correctly? Compare the estimate to reality with &lt;code&gt;EXPLAIN ANALYZE&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Estimate off by handful of rows means statistics are fine. But when you see estimates off by 10x or more, that&#x27;s where planning goes wrong. A nested loop that looks cheap for 100 rows becomes a disaster at 10,000.&lt;&#x2F;p&gt;
&lt;p&gt;The statistics tell you what the planner believed, and comparing that with reality tells you what to do next. Either run &lt;code&gt;ANALYZE&lt;&#x2F;code&gt;, consider tuning the statistics target for a specific column, or creating extended statistics if multiple columns are involved.&lt;&#x2F;p&gt;
&lt;p&gt;The planner is only as good as what it reads from the catalog. When estimates go wrong, don&#x27;t blame the planner. Check the data it&#x27;s working with.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Instant database clones with PostgreSQL 18</title>
        <published>2025-12-22T23:53:16+00:00</published>
        <updated>2025-12-22T23:53:16+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/instant-database-clones/"/>
        <id>https://boringsql.com/posts/instant-database-clones/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/instant-database-clones/">&lt;p&gt;Have you ever watched a &lt;a href=&quot;&#x2F;posts&#x2F;how-not-to-change-postgresql-column-type&#x2F;&quot;&gt;long running migration script&lt;&#x2F;a&gt;, wondering if it&#x27;s about
to wreck your data? Or wish you can &quot;just&quot; spin a fresh copy of database for
each test run? Or wanted to have reproducible snapshots to reset between
runs of your test suite, (and yes, because you are reading boringSQL) needed
to reset the learning environment?&lt;&#x2F;p&gt;
&lt;p&gt;When your database is a few megabytes, &lt;code&gt;pg_dump&lt;&#x2F;code&gt; and restore works fine. But
what happens when you&#x27;re dealing with hundreds of megabytes&#x2F;gigabytes - or more?
Suddenly &quot;just make a copy&quot; becomes a burden.&lt;&#x2F;p&gt;
&lt;p&gt;You&#x27;ve probably noticed that PostgreSQL connects to &lt;code&gt;template1&lt;&#x2F;code&gt; by default. What
you might have missed is that there&#x27;s a whole templating system hiding in plain
sight. Every time you run&lt;&#x2F;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;CREATE DATABASE dbname;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;PostgreSQL quietly clones standard system database &lt;code&gt;template1&lt;&#x2F;code&gt; behind the
scenes. Making it same as if you would use&lt;&#x2F;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;CREATE DATABASE dbname TEMPLATE template1;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The real power comes from the fact that you can replace &lt;code&gt;template1&lt;&#x2F;code&gt; with any
database. You can find more at &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;manage-ag-templatedbs.html&quot;&gt;Template Database
documentation&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;In this article, we will cover a few tweaks that turn this templating system
into an instant, zero-copy database cloning machine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;create-database-strategy&quot;&gt;CREATE DATABASE ... STRATEGY&lt;a class=&quot;zola-anchor&quot; href=&quot;#create-database-strategy&quot; aria-label=&quot;Anchor link for: create-database-strategy&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Before PostgreSQL 15, when you created a new database from a template, it
operated strictly on the file level. This was effective, but to make it
reliable, Postgres had to flush all pending operations to disk (using
&lt;code&gt;CHECKPOINT&lt;&#x2F;code&gt;) before taking a consistent snapshot. This created a massive I&#x2F;O
spike - a &quot;Checkpoint Storm&quot; - that could stall your production traffic.&lt;&#x2F;p&gt;
&lt;p&gt;Version 15 of PostgreSQL introduced new parameter &lt;code&gt;CREATE DATABASE ... STRATEGY = [strategy]&lt;&#x2F;code&gt; and at the same time changed the default behaviour how the new
databases are created from templates. The new default become &lt;code&gt;WAL_LOG&lt;&#x2F;code&gt; which
copies block-by-block via the Write-Ahead Log (WAL), making I&#x2F;O sequential (and
much smoother) — operations that also &lt;a href=&quot;&#x2F;posts&#x2F;explain-buffers&#x2F;&quot;&gt;show up in EXPLAIN buffer statistics&lt;&#x2F;a&gt; — and support for concurrency without facing latency spike. This
prevented the need to CHECKPOINT but made the database cloning operation
potentially significantly slower. For an empty &lt;code&gt;template1&lt;&#x2F;code&gt;, you won&#x27;t notice the
difference. But if you try to clone a 500GB database using WAL_LOG, you are
going to be waiting a long time.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;STRATEGY&lt;&#x2F;code&gt; parameter allows us to switch back to the original method
&lt;code&gt;FILE_COPY&lt;&#x2F;code&gt; to keep the behaviour, and speed. And since PostgreSQL 18, this
opens the whole new set of options.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;file-copy&quot;&gt;FILE_COPY&lt;a class=&quot;zola-anchor&quot; href=&quot;#file-copy&quot; aria-label=&quot;Anchor link for: file-copy&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Because the &lt;code&gt;FILE_COPY&lt;&#x2F;code&gt; strategy is a proxy to operating system file operations,
we can change how the OS handles those files.&lt;&#x2F;p&gt;
&lt;p&gt;When using standard file system (like &lt;code&gt;ext4&lt;&#x2F;code&gt;), PostgreSQL reads every byte of
the source file and writes it to a new location. It&#x27;s a physical copy. However
starting with PostgreSQL 18 - &lt;code&gt;file_copy_method&lt;&#x2F;code&gt; gives you options to switch
that logic; while default option remains &lt;code&gt;copy&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;With modern filesystems (like ZFS, XFS with reflinks, APFS, etc.) you can switch
it to &lt;code&gt;clone&lt;&#x2F;code&gt; and leverage &lt;code&gt;CLONE&lt;&#x2F;code&gt; (&lt;code&gt;FICLONE&lt;&#x2F;code&gt; on Linux) operation for almost
instant operation. And it won&#x27;t take any additional space.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p&gt;&lt;strong&gt;Quick setup checklist:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Linux with XFS or ZFS, macOS with APFS, or FreeBSD with ZFS&lt;&#x2F;li&gt;
&lt;li&gt;PostgreSQL 18+ cluster on that filesystem&lt;&#x2F;li&gt;
&lt;li&gt;Set &lt;code&gt;file_copy_method = clone&lt;&#x2F;code&gt; in your config&lt;&#x2F;li&gt;
&lt;li&gt;Reload configuration&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;the-benchmark&quot;&gt;The benchmark&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-benchmark&quot; aria-label=&quot;Anchor link for: the-benchmark&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;We need some dummy data to copy. This is the only part of the tutorial where you
have to wait. Let&#x27;s generate a ~6GB database.&lt;&#x2F;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 DATABASE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; source_db&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\c source_db&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; boring_data&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;serial PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    payload &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- generate 50m rows&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; boring_data (payload)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; md5(random()::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span&gt; md5(random()::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;text&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50000000&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- force a checkpoint&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CHECKPOINT&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You can verify the database now has roughly 6GB of data.&lt;&#x2F;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;Name              | source_db&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Owner             | postgres&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Encoding          | UTF8&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Locale Provider   | libc&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Collate           | en_US.UTF-8&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Ctype             | en_US.UTF-8&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Locale            |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ICU Rules         |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Access privileges |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Size              | 6289 MB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Tablespace        | pg_default&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Description       |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While enabling &lt;code&gt;\timing&lt;&#x2F;code&gt; you can test the default (WAL_LOG) strategy. And on my
test volume (relatively slow storage) I get&lt;&#x2F;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;CREATE DATABASE slow_copy TEMPLATE source_db;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;CREATE DATABASE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Time: 67000.615 ms (01:07.001)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now, let&#x27;s verify our configuration is set for speed:&lt;&#x2F;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;show file_copy_method;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; file_copy_method&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; clone&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(1 row)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Let&#x27;s request the semi-instant clone of the same database, without taking
extra disk space at the same time.&lt;&#x2F;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;CREATE DATABASE fast_clone TEMPLATE source_db STRATEGY=FILE_COPY;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;CREATE DATABASE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Time: 212.053 ms&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s a quite an improvement, isn&#x27;t it?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;working-with-cloned-data&quot;&gt;Working with cloned data&lt;a class=&quot;zola-anchor&quot; href=&quot;#working-with-cloned-data&quot; aria-label=&quot;Anchor link for: working-with-cloned-data&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;That was the simple part. But what is happening behind the scenes?&lt;&#x2F;p&gt;
&lt;p&gt;When you clone a database with &lt;code&gt;file_copy_method = clone&lt;&#x2F;code&gt;, PostgreSQL doesn&#x27;t
duplicate any data. The filesystem creates new metadata entries that point to
the same physical &lt;a href=&quot;&#x2F;posts&#x2F;introduction-to-buffers&#x2F;&quot;&gt;8KB pages&lt;&#x2F;a&gt;. Both databases share identical storage.&lt;&#x2F;p&gt;
&lt;p&gt;This can create some initial confusion. If you ask PostgreSQL for the size:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_database_size(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;source_db&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; source,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_database_size(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;fast_clone&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; clone;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;PostgreSQL reports both as ~6GB because that&#x27;s the logical size - how much data
each database &quot;contains&quot; - i.e. logical size.&lt;&#x2F;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;-[ RECORD 1 ]------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;source | 6594041535&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;clone  | 6594041535&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The interesting part happens when you start writing. PostgreSQL doesn&#x27;t update
tuples in place. When you UPDATE a row, it writes a new tuple version somewhere
(often a different page entirely) and marks the old one as dead. The filesystem
doesn&#x27;t care about PostgreSQL internals - it just sees writes to &lt;a href=&quot;&#x2F;posts&#x2F;inside-the-8kb-page&#x2F;&quot;&gt;8KB pages&lt;&#x2F;a&gt;. Any
write to a shared page triggers a copy of that entire page.&lt;&#x2F;p&gt;
&lt;p&gt;A single UPDATE will therefore trigger copy-on-write on multiple pages:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;the page holding the old tuple&lt;&#x2F;li&gt;
&lt;li&gt;the page receiving the new tuple&lt;&#x2F;li&gt;
&lt;li&gt;index pages if any indexed columns changed&lt;&#x2F;li&gt;
&lt;li&gt;FSM and visibility map pages as PostgreSQL tracks free space&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And later, &lt;a href=&quot;&#x2F;posts&#x2F;vacuum-is-lie&#x2F;&quot;&gt;VACUUM touches even more pages while cleaning up dead tuples&lt;&#x2F;a&gt;. In this
case diverging quickly from the linked storage.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;xfs-proof&quot;&gt;XFS proof&lt;a class=&quot;zola-anchor&quot; href=&quot;#xfs-proof&quot; aria-label=&quot;Anchor link for: xfs-proof&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Using the database OID and relfilenode we can verify the both databases are now
sharing physical blocks.&lt;&#x2F;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;root@clone-demo:&#x2F;var&#x2F;lib&#x2F;postgresql# sudo filefrag -v &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16402&#x2F;16404&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Filesystem type is: 58465342&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File size of &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16402&#x2F;16404 is 1073741824 (262144 blocks of 4096 bytes)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ext:     logical_offset:        physical_offset: length:   expected: flags:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   0:        0..    2031:   10471550..  10473581:   2032:             shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   1:     2032..   16367:   10474098..  10488433:  14336:   10473582: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   2:    16368..   32751:   10497006..  10513389:  16384:   10488434: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   3:    32752..   65519:   10522066..  10554833:  32768:   10513390: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   4:    65520..  129695:   10571218..  10635393:  64176:   10554834: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   5:   129696..  195231:   10635426..  10700961:  65536:   10635394: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   6:   195232..  262143:   10733730..  10800641:  66912:   10700962: last,shared,eof&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16402&#x2F;16404: 7 extents found&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;root@clone-demo:&#x2F;var&#x2F;lib&#x2F;postgresql#&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;root@clone-demo:&#x2F;var&#x2F;lib&#x2F;postgresql#&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;root@clone-demo:&#x2F;var&#x2F;lib&#x2F;postgresql# sudo filefrag -v &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16418&#x2F;16404&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Filesystem type is: 58465342&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File size of &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16418&#x2F;16404 is 1073741824 (262144 blocks of 4096 bytes)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ext:     logical_offset:        physical_offset: length:   expected: flags:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   0:        0..    2031:   10471550..  10473581:   2032:             shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   1:     2032..   16367:   10474098..  10488433:  14336:   10473582: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   2:    16368..   32751:   10497006..  10513389:  16384:   10488434: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   3:    32752..   65519:   10522066..  10554833:  32768:   10513390: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   4:    65520..  129695:   10571218..  10635393:  64176:   10554834: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   5:   129696..  195231:   10635426..  10700961:  65536:   10635394: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   6:   195232..  262143:   10733730..  10800641:  66912:   10700962: last,shared,eof&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16418&#x2F;16404: 7 extents found&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;All it takes is to update some rows using&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; boring_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;set&lt;&#x2F;span&gt;&lt;span&gt; payload &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;new value&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;where&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;select&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;from&lt;&#x2F;span&gt;&lt;span&gt; boring_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;limit&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 20&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;and the situation will start to change.&lt;&#x2F;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;root@clone-demo:&#x2F;var&#x2F;lib&#x2F;postgresql# sudo filefrag -v &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16402&#x2F;16404&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Filesystem type is: 58465342&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File size of &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16402&#x2F;16404 is 1073741824 (262144 blocks of 4096 bytes)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ext:     logical_offset:        physical_offset: length:   expected: flags:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   0:        0..      39:   10471550..  10471589:     40:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   1:       40..    2031:   10471590..  10473581:   1992:             shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   2:     2032..   16367:   10474098..  10488433:  14336:   10473582: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   3:    16368..   32751:   10497006..  10513389:  16384:   10488434: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   4:    32752..   65519:   10522066..  10554833:  32768:   10513390: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   5:    65520..  129695:   10571218..  10635393:  64176:   10554834: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   6:   129696..  195231:   10635426..  10700961:  65536:   10635394: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   7:   195232..  262143:   10733730..  10800641:  66912:   10700962: last,shared,eof&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16402&#x2F;16404: 7 extents found&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;root@clone-demo:&#x2F;var&#x2F;lib&#x2F;postgresql# sudo filefrag -v &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16418&#x2F;16404&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Filesystem type is: 58465342&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;File size of &#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16418&#x2F;16404 is 1073741824 (262144 blocks of 4096 bytes)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; ext:     logical_offset:        physical_offset: length:   expected: flags:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   0:        0..      39:   10297326..  10297365:     40:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   1:       40..    2031:   10471590..  10473581:   1992:   10297366: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   2:     2032..   16367:   10474098..  10488433:  14336:   10473582: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   3:    16368..   32751:   10497006..  10513389:  16384:   10488434: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   4:    32752..   65519:   10522066..  10554833:  32768:   10513390: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   5:    65520..  129695:   10571218..  10635393:  64176:   10554834: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   6:   129696..  195231:   10635426..  10700961:  65536:   10635394: shared&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   7:   195232..  262143:   10733730..  10800641:  66912:   10700962: last,shared,eof&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;var&#x2F;lib&#x2F;postgresql&#x2F;18&#x2F;main&#x2F;base&#x2F;16418&#x2F;16404: 8 extents found&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;root@clone-demo:&#x2F;var&#x2F;lib&#x2F;postgresql#&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In this case extent 0 no longer has shared flag, first 40 blocks size (with
default size 4KB) now diverge, making it total of 160KB. Each database now has
its own copy at different physical address. The remaining extents are still
shared.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;things-to-be-aware-of&quot;&gt;Things to be aware of&lt;a class=&quot;zola-anchor&quot; href=&quot;#things-to-be-aware-of&quot; aria-label=&quot;Anchor link for: things-to-be-aware-of&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Cloning is tempting but there&#x27;s one serious limitation you need to be aware if
you ever attempt to do it in production. The source database can&#x27;t have any
active connections during cloning. This is a PostgreSQL limitation, not a
filesystem one. For production use, this usually means you create a dedicated
template database rather than cloning your live database directly. Or given the
relatively short time the operation takes you have to schedule the cloning in
times where you can temporary block&#x2F;terminate all connections.&lt;&#x2F;p&gt;
&lt;p&gt;Other limitation is that the cloning only works within a single filesystem. If
your databases spans multiple table spaces on different mount points, cloning
will fall back to regular physical copy.&lt;&#x2F;p&gt;
&lt;p&gt;Finally, in most managed cloud environments (AWS RDS, Google Cloud SQL), you
will not have access to the underlying filesystem to configure this. You are
stuck with their proprietary (and often billed) functionality. But for your own
VMs or bare metal? Go ahead and try it.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>VACUUM Is a Lie (About Your Indexes)</title>
        <published>2025-12-11T23:34:00+00:00</published>
        <updated>2025-12-11T23:34:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/vacuum-is-lie/"/>
        <id>https://boringsql.com/posts/vacuum-is-lie/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/vacuum-is-lie/">&lt;p&gt;There is common misconception that troubles most developers using PostgreSQL:
tune VACUUM or run VACUUM, and your database will stay healthy. Dead tuples will
get cleaned up. Transaction IDs recycled. Space reclaimed. Your database will
live happily ever after.&lt;&#x2F;p&gt;
&lt;p&gt;But there are couple of dirty &quot;secrets&quot; people are not aware of. First of them
being &lt;strong&gt;VACUUM is lying to you about your indexes&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-anatomy-of-storage&quot;&gt;The anatomy of storage&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-anatomy-of-storage&quot; aria-label=&quot;Anchor link for: the-anatomy-of-storage&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When you delete a row in PostgreSQL, it is just marked as a &#x27;dead tuple&#x27;.
Invisible for new transactions but still physically present. Only when all
transactions referencing the row are finished, VACUUM can come along and actually
remove them - reclamining the space in the heap (table) space.&lt;&#x2F;p&gt;
&lt;p&gt;To understand why this matters differently for tables versus indexes, you need
to picture how PostgreSQL actually stores your data.&lt;&#x2F;p&gt;
&lt;p&gt;Your table data lives in the heap - a collection of 8 KB pages where rows are
stored wherever they fit. There&#x27;s no inherent order. When you INSERT a row,
PostgreSQL finds a page with enough free space and slots the row in. Delete a
row, and there&#x27;s a gap. Insert another, and it might fill that gap - or not - they
might fit somewhere else entirely.&lt;&#x2F;p&gt;
&lt;p&gt;This is why &lt;code&gt;SELECT * FROM users&lt;&#x2F;code&gt; without an ORDER BY can return rows in order
initially, and after some updates in seemingly random order, and that order can
change over time. The heap is like Tetris. Rows drop into whatever space is
available, leaving gaps when deleted.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;posts&#x2F;heap_page.png&quot; alt=&quot;Heap Page&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;When VACUUM runs, it removes those dead tuples and compacts the remaining
rows within each page. If an entire page becomes empty, PostgreSQL can reclaim
it entirely.&lt;&#x2F;p&gt;
&lt;p&gt;And while indexes are on surface the same collection of 8KB pages, they are
different. A B-tree index must maintain sorted order - that&#x27;s the
whole point of their existence and the reason why &lt;code&gt;WHERE id = 12345&lt;&#x2F;code&gt; is so
fast — though &lt;a href=&quot;&#x2F;posts&#x2F;why-postgresql-indexes-are-ignored&#x2F;&quot;&gt;even indexes can be ignored&lt;&#x2F;a&gt; under certain conditions. PostgreSQL can binary-search down the tree instead of scanning every
possible row. You can learn more about the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;use-the-index-luke.com&#x2F;sql&#x2F;anatomy&#x2F;the-tree&quot;&gt;fundamentals of B-Tree Indexes and
what makes them fast&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;But if the design of the indexes is what makes them fast, it&#x27;s also their
biggest responsibility. While PostgreSQL can fit rows into whatever space is
available, it can&#x27;t move the entries in index pages to fit as much as possible.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;posts&#x2F;leaf_page.png&quot; alt=&quot;Leaf Page&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;VACUUM can remove dead index entries. But it doesn&#x27;t restructure the B-tree.
When VACUUM processes the heap, it can compact rows within a page and reclaim
empty pages. The heap has no ordering constraint - rows can be anywhere. But
B-tree pages? They&#x27;re locked into a structure. VACUUM can remove dead index
entries, yes.&lt;&#x2F;p&gt;
&lt;p&gt;Many developers assume VACUUM treats all pages same. No matter whether they are
heap or index pages. VACUUM is supposed to remove the dead entries, right?&lt;&#x2F;p&gt;
&lt;p&gt;Yes. But here&#x27;s what it doesn&#x27;t do - &lt;strong&gt;it doesn&#x27;t restructure the B-tree&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;What VACUUM actually does&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Removes dead tuple pointers from index pages&lt;&#x2F;li&gt;
&lt;li&gt;Marks completely empty pages as reusable&lt;&#x2F;li&gt;
&lt;li&gt;Updates the free space map&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;What VACUUM cannot do&lt;&#x2F;strong&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Merge sparse pages together (can do it for empty pages)&lt;&#x2F;li&gt;
&lt;li&gt;Reduce tree depth&lt;&#x2F;li&gt;
&lt;li&gt;Deallocate empty-but-still-linked pages&lt;&#x2F;li&gt;
&lt;li&gt;Change the physical structure of the B-tree&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Your heap is Tetris, gaps can get filled. Your B-tree is a sorted bookshelf.
VACUUM can pull books out, but can&#x27;t slide the remaining ones together. You&#x27;re
left walking past empty slots every time you scan.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-experiment&quot;&gt;The experiment&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-experiment&quot; aria-label=&quot;Anchor link for: the-experiment&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Let&#x27;s get hands-on and create a table, fill it, delete most of it and watch what happens.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; EXTENSION &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;&#x2F;span&gt;&lt;span&gt; pgstattuple;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; demo&lt;&#x2F;span&gt;&lt;span&gt; (id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;integer PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;data text&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- insert 100,000 rows&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; demo (id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;data&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; g, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Row number &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; g &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; with some extra data&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;&#x2F;span&gt;&lt;span&gt;) g;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ANALYZE demo;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;At this point, our index is healthy. Let&#x27;s capture the baseline:&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    relname,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(pg_relation_size(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;)) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; file_size,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty((pgstattuple(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;)).tuple_len) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; actual_data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; relname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo_pkey&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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  | file_size | actual_data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------+-----------+-------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo      | 7472 kB   | 6434 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo_pkey | 2208 kB   | 1563 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now remove some data, 80% to be precise - somewhere in the middle:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; demo &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BETWEEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10001&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 90000&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The goal is to simulate a common real-world pattern: data retention policies,
bulk cleanup operations, or the aftermath of a &lt;a href=&quot;&#x2F;posts&#x2F;deletes-are-difficult&#x2F;&quot;&gt;data migration gone wrong&lt;&#x2F;a&gt;.&lt;&#x2F;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;VACUUM demo;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    relname,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(pg_relation_size(oid)) as file_size,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty((pgstattuple(oid)).tuple_len) as actual_data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FROM pg_class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WHERE relname IN (&amp;#39;demo&amp;#39;, &amp;#39;demo_pkey&amp;#39;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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  | file_size | actual_data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------+-----------+-------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo      | 7472 kB   | 1278 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo_pkey | 2208 kB   | 313 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The table shrunk significantly, while index remained unchanged You now have
20,000 rows indexed by a structure built to handle 100,000.&lt;&#x2F;p&gt;
&lt;p&gt;But notice something important: even though &lt;code&gt;actual_data&lt;&#x2F;code&gt; dropped to ~1.3 MB
(reflecting our 20,000 remaining rows), the &lt;code&gt;file_size&lt;&#x2F;code&gt; for the index is still
2208 kB. VACUUM cleaned out the dead tuple data, but it doesn&#x27;t return space to
the OS or compact index structures - it only marks pages as reusable within
PostgreSQL.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;EDIT: the original table had a size without running VACCUM, which I must left
out during what&#x27;s sometime cumbersome process of authoring blog posts. Big
thanks come to Creston Jamison of &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.scalingpostgres.com&#x2F;&quot;&gt;Scaling
PostgreSQL&lt;&#x2F;a&gt; to notice it, and actually take
what must have been a lot of time to analyze my posts.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;This experiment is really an extreme case, but demonstrates the problem.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;understanding-page-states&quot;&gt;Understanding page states&lt;a class=&quot;zola-anchor&quot; href=&quot;#understanding-page-states&quot; aria-label=&quot;Anchor link for: understanding-page-states&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Leaf pages have several states:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Full page (&amp;gt;80% density)&lt;&#x2F;strong&gt;, when the page contains many index entries,
efficiently utilizing space. Each &lt;a href=&quot;&#x2F;posts&#x2F;inside-the-8kb-page&#x2F;&quot;&gt;8KB page&lt;&#x2F;a&gt; read returns substantial useful data.
This is optimal state.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Partial page (40-80% density)&lt;&#x2F;strong&gt; with some wasted space, but still reasonably
efficient. Common at tree edges or after light churn. Nothing to be worried about.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Sparse page (&amp;lt;40% density)&lt;&#x2F;strong&gt; is mostly empty. You&#x27;re reading an 8KB page to
find a handful of entries. The I&#x2F;O cost is the same as a full page, but you get
far less value.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Empty page (0% density)&lt;&#x2F;strong&gt; with zero live entries, but the page still exists in
the tree structure. Pure overhead. You might read this page during a range scan
and find absolutely nothing useful.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;a-note-on-fillfactor&quot;&gt;A note on fillfactor&lt;a class=&quot;zola-anchor&quot; href=&quot;#a-note-on-fillfactor&quot; aria-label=&quot;Anchor link for: a-note-on-fillfactor&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;You might be wondering how can fillfactor help with this? It&#x27;s the setting you
can apply both for heap and leaf pages, and controls how full PostgreSQL packs the
pages during the data storage. The &lt;strong&gt;default value for B-tree indexes is 90%&lt;&#x2F;strong&gt;. This
leaves 10% of free space on each leaf page for future insertions.&lt;&#x2F;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 INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; demo_index&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; demo(id) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; (fillfactor &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 70&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A lower fillfactor (like 70%) leaves more room, which can reduce page splits
when you&#x27;re inserting into the middle of an index - useful for tables random index
column inserts or those with heavily updated index columns.&lt;&#x2F;p&gt;
&lt;p&gt;But if you followed carefully the anatomy of storage section, it doesn&#x27;t help
with the bloat problem. Quite the opposite. If you set lower fillfactor and then
delete majority of your rows, you actually start with more pages, and bigger
chance to end up with more sparse pages than partial pages.&lt;&#x2F;p&gt;
&lt;p&gt;Leaf page fillfactor is about optimizing for updates and inserts. It&#x27;s not a
solution for deletion or index-column update bloat.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-the-planner-gets-fooled&quot;&gt;Why the planner gets fooled&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-the-planner-gets-fooled&quot; aria-label=&quot;Anchor link for: why-the-planner-gets-fooled&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;PostgreSQL&#x27;s query planner &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&#x2F;&quot;&gt;estimates costs based on physical
statistics&lt;&#x2F;a&gt;, including the number of pages in an index.&lt;&#x2F;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 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; demo &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BETWEEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10001&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 90000&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------------------------------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Index Scan using demo_pkey on demo  (cost=0.29..29.29 rows=200 width=41) (actual time=0.111..0.112 rows=0 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Index Cond: ((id &amp;gt;= 10001) AND (id &amp;lt;= 90000))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Planning Time: 1.701 ms&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Execution Time: 0.240 ms&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(4 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While the execution is almost instant, you need to look behind the scenes. The
planner estimated 200 rows and got zero. It traversed the B-tree structure
expecting data that doesn&#x27;t exist. On a single query with warm cache, this is
trivial. Under production load with thousands of queries and cold pages,
you&#x27;re paying I&#x2F;O cost for nothing. Again and again.&lt;&#x2F;p&gt;
&lt;p&gt;If you dig further you discover much bigger problem.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; relname, reltuples::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint as&lt;&#x2F;span&gt;&lt;span&gt; row_estimate, relpages &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; page_estimate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_class &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; relname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo_pkey&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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  | row_estimate | page_estimate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------+--------------+---------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo      |        20000 |           934&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo_pkey |        20000 |           276&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;relpages&lt;&#x2F;code&gt; value comes from the physical file size divided by the &lt;a href=&quot;&#x2F;posts&#x2F;introduction-to-buffers&#x2F;&quot;&gt;8 KB page
size&lt;&#x2F;a&gt;. PostgreSQL updates it during VACUUM and ANALYZE, but it reflects the
actual file on disk - not how much useful data is inside. Our index file is still
2.2 MB (276 pages × 8 KB), even though most pages are empty.&lt;&#x2F;p&gt;
&lt;p&gt;The planner sees 276 pages for 20,000 rows and calculates a very low
rows-per-page ratio. This is when planner can come to conclusion - &lt;em&gt;this index
is very sparse - let&#x27;s do a sequential scan instead&lt;&#x2F;em&gt;. Oops.&lt;&#x2F;p&gt;
&lt;p&gt;&quot;But wait,&quot; you say, &quot;doesn&#x27;t ANALYZE fix statistics?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Yes and no. &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; updates the row count estimate. It will no longer think you
have 100,000 rows but 20,000. But it does not shrink relpages, because that
reflects the physical file size on disk. &lt;code&gt;ANALYZE&lt;&#x2F;code&gt; can&#x27;t change that.&lt;&#x2F;p&gt;
&lt;p&gt;The planner now has accurate row estimates but wildly inaccurate page estimates.
The useful data is packed into just ~57 pages worth of entries, but the planner
doesn&#x27;t know that.&lt;&#x2F;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;cost = random_page_cost × pages + cpu_index_tuple_cost × tuples ([visible in EXPLAIN output](&#x2F;posts&#x2F;explain-buffers&#x2F;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With a bloated index:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;pages are oversized (276 instead of ~57)&lt;&#x2F;li&gt;
&lt;li&gt;The per-page cost gets multiplied by empty pages&lt;&#x2F;li&gt;
&lt;li&gt;Total estimated cost is artificially high&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-hollow-index&quot;&gt;The hollow index&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-hollow-index&quot; aria-label=&quot;Anchor link for: the-hollow-index&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;We can dig even more into the index problem when we look at internal stats:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pgstatindex(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo_pkey&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;version            | 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;tree_level         | 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;index_size         | 2260992&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;root_block_no      | 3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;internal_pages     | 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;leaf_pages         | 57&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;empty_pages        | 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;deleted_pages      | 217&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;avg_leaf_density   | 86.37&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;leaf_fragmentation | 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Wait, what? The avg_leaf_density is 86% and it looks perfectly healthy. That&#x27;s a
trap. Due to the hollow index (we removed 80% right in the middle) we have 57
well-packed leaf pages, but the index still contains 217 deleted pages.&lt;&#x2F;p&gt;
&lt;p&gt;This is why &lt;code&gt;avg_leaf_density&lt;&#x2F;code&gt; alone is misleading. The density of used pages
looks great, but 79% of your index file is dead weight.&lt;&#x2F;p&gt;
&lt;p&gt;The simplest way to spot index bloat is comparing actual size to expected size.&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; index_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(pg_relation_size(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;)) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; actual_size,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty((&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;reltuples&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 40&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;bigint&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; expected_size,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    round&lt;&#x2F;span&gt;&lt;span&gt;((pg_relation_size(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&#x2F;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; nullif&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;reltuples&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 40&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;))::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; bloat_ratio&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_class c&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;JOIN&lt;&#x2F;span&gt;&lt;span&gt; pg_index i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; i&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;indexrelid&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relkind&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;i&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt; &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;reltuples&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;relname&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT LIKE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;pg_%&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span&gt; pg_relation_size(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;c&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1024&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1024&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  -- only indexes &amp;gt; 1 MB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; bloat_ratio &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DESC NULLS LAST&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;index_name | actual_size | expected_size | bloat_ratio&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------+-------------+---------------+-------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo_pkey  | 2208 kB     | 781 kB        |         2.8&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A &lt;code&gt;bloat_ratio&lt;&#x2F;code&gt; of 2.8 means the index is nearly 3x larger than expected. Anything
above 1.8 - 2.0 deserves investigation.&lt;&#x2F;p&gt;
&lt;p&gt;We filter to indexes over 1 MB - bloat on tiny indexes doesn&#x27;t matter that much.
Please, adjust the threshold based on your environment; for large databases, you
might only care about indexes over 100 MB.&lt;&#x2F;p&gt;
&lt;p&gt;But here comes &lt;strong&gt;BIG WARNING&lt;&#x2F;strong&gt;: pgstatindex() we used earlier physically reads
the entire index. On a 10 GB index, that&#x27;s 10 GB of I&#x2F;O. Don&#x27;t run it against
all indexes on a production server - unless you know what you are doing!&lt;&#x2F;p&gt;
&lt;h2 id=&quot;reindex&quot;&gt;REINDEX&lt;a class=&quot;zola-anchor&quot; href=&quot;#reindex&quot; aria-label=&quot;Anchor link for: reindex&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;How to actually fix index bloat problem? &lt;code&gt;REINDEX&lt;&#x2F;code&gt; is s straightforward solution as
it rebuilds the index from scratch.&lt;&#x2F;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;REINDEX &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INDEX&lt;&#x2F;span&gt;&lt;span&gt; CONCURRENTLY demo_pkey ;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;After which we can check the index health:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pgstatindex(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo_pkey&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;version            | 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;tree_level         | 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;index_size         | 466944&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;root_block_no      | 3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;internal_pages     | 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;leaf_pages         | 55&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;empty_pages        | 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;deleted_pages      | 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;avg_leaf_density   | 89.5&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;leaf_fragmentation | 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    relname,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(pg_relation_size(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;)) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; file_size,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty((pgstattuple(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;oid&lt;&#x2F;span&gt;&lt;span&gt;)).tuple_len) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; actual_data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_class&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; relname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo_pkey&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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  | file_size | actual_data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-----------+-----------+-------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo      | 7472 kB   | 1278 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;demo_pkey | 456 kB    | 313 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Our index shrunk from 2.2 MB to 456 KB - 79% reduction (not a big surprise
though).&lt;&#x2F;p&gt;
&lt;p&gt;As you might have noticed we have used &lt;code&gt;CONCURRENTLY&lt;&#x2F;code&gt; to avoid using ACCESS
EXCLUSIVE lock. This is available since PostgreSQL 12+, and while there&#x27;s an
option to omit it - the pretty much only reason to do so is during planned
maintenance to speed up the index rebuild time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pg-squeeze&quot;&gt;pg_squeeze&lt;a class=&quot;zola-anchor&quot; href=&quot;#pg-squeeze&quot; aria-label=&quot;Anchor link for: pg-squeeze&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;If you look above at the file_size of our relations, we have managed to reclaim
the disk space for the affected index (it was &lt;code&gt;REINDEX&lt;&#x2F;code&gt; after all), but the table
space was not returned back to the operating system.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s where &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;cybertec-postgresql&#x2F;pg_squeeze&quot;&gt;pg_squeeze&lt;&#x2F;a&gt;
shines. Unlike trigger-based alternatives, pg_squeeze uses logical decoding,
resulting in lower impact on your running system. It rebuilds both the table and
all its indexes online, with minimal locking:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; EXTENSION pg_squeeze;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; squeeze&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;squeeze_table&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;public&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The exclusive lock is only needed during the final swap phase, and its duration
can be configured. Even better, pg_squeeze is designed for regular automated
processing - you can register tables and let it handle maintenance whenever bloat
thresholds are met.&lt;&#x2F;p&gt;
&lt;p&gt;pg_squeeze makes sense when both table and indexes are bloated, or when you want
automated management. REINDEX CONCURRENTLY is simpler when only indexes need
work.&lt;&#x2F;p&gt;
&lt;p&gt;There&#x27;s also older tool pg_repack - for a deeper comparison of bloat-busting
tools, see article &lt;a href=&quot;&#x2F;posts&#x2F;the-bloat-busters-pg-repack-pg-squeeze&#x2F;&quot;&gt;The Bloat Busters: pg_repack vs
pg_squeeze&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;vacuum-full-the-nuclear-option&quot;&gt;VACUUM FULL (The nuclear option)&lt;a class=&quot;zola-anchor&quot; href=&quot;#vacuum-full-the-nuclear-option&quot; aria-label=&quot;Anchor link for: vacuum-full-the-nuclear-option&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;VACUUM FULL&lt;&#x2F;code&gt; rewrites the entire table and all indexes. While it fixes
everything it comes with a big but - it requires an ACCESS EXCLUSIVE
lock - completely blocking all reads and writes for the entire duration. For a
large table, this could mean hours of downtime.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Generally avoid this in production&lt;&#x2F;strong&gt;. Use pg_squeeze instead for the same
result without the downtime.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-to-act-and-when-to-chill&quot;&gt;When to act, and when to chill&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-to-act-and-when-to-chill&quot; aria-label=&quot;Anchor link for: when-to-act-and-when-to-chill&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Before you now go and &lt;code&gt;REINDEX&lt;&#x2F;code&gt; everything in sight, let&#x27;s talk about when index
bloat actually matters.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;B-trees expand and contract with your data&lt;&#x2F;strong&gt;. With random insertions affecting
index columns - UUIDs, hash keys, etc. the page splits happen constantly. Index
efficiency might get hit at occassion and also settle around 70 - 80% over
different natural cycles of your system usage. That&#x27;s not bloat. That&#x27;s the tree
finding its natural shape for your data.&lt;&#x2F;p&gt;
&lt;p&gt;The bloat we demonstrated - 57 useful pages drowning in 217 deleted ones - is
extreme. It came from deleting 80% of contiguous data. You won&#x27;t see this
from normal day to day operations.&lt;&#x2F;p&gt;
&lt;p&gt;When do you need to act immediately:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;after a massive DELETE (retention policy, GDPR purge, failed migration cleanup)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;bloat_ratio&lt;&#x2F;code&gt; exceeds 2.0 and keeps climbing&lt;&#x2F;li&gt;
&lt;li&gt;query plans suddenly prefer sequential scans on indexed columns&lt;&#x2F;li&gt;
&lt;li&gt;index size is wildly disproportionate to row count&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;But in most cases you don&#x27;t have to panic. Monitor weekly and when indexes bloat
ratio continously grow above warning levels, schedule a &lt;code&gt;REINDEX CONCURRENTLY&lt;&#x2F;code&gt;
during low traffic period.&lt;&#x2F;p&gt;
&lt;p&gt;Index bloat isn&#x27;t an emergency until it is. Know the signs, have the tools
ready, and don&#x27;t let VACUUM&#x27;s silence fool you into thinking everything&#x27;s fine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;a class=&quot;zola-anchor&quot; href=&quot;#conclusion&quot; aria-label=&quot;Anchor link for: conclusion&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;VACUUM is essential for PostgreSQL. Run it. Let autovacuum do its job. But
understand its limitations: it cleans up dead tuples, not index structure.&lt;&#x2F;p&gt;
&lt;p&gt;The truth about PostgreSQL maintenance is that VACUUM handles heap bloat
reasonably well, but index bloat requires explicit intervention. Know when your
indexes are actually sick versus just breathing normally - and when to reach for
REINDEX.&lt;&#x2F;p&gt;
&lt;p&gt;VACUUM handles heap bloat. Index bloat is your problem. Know the difference.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>RegreSQL: Regression Testing for PostgreSQL Queries</title>
        <published>2025-11-13T22:47:00+00:00</published>
        <updated>2025-11-13T22:47:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/regresql-testing-queries/"/>
        <id>https://boringsql.com/posts/regresql-testing-queries/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/regresql-testing-queries/">&lt;p&gt;&lt;strong&gt;TL;DR&lt;&#x2F;strong&gt; - &lt;em&gt;RegreSQL brings PostgreSQL&#x27;s regression testing methodology to your application queries, catching both correctness bugs and performance regressions before production.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;As puzzling as it might seem, the common problem with production changes is the ever-present &quot;AHA&quot; moment when things start slowing down or crashing straight away. Testing isn&#x27;t easy as it is, but there&#x27;s a widespread practice gap when it comes to testing SQL queries. Some might pretend to &quot;fix it&quot; by using ORMs to abstract away the problem. Others treat SQL as &quot;just glue code&quot; that doesn&#x27;t deserve systematic testing. Most settle for integration tests that verify the application layer works, never actually testing whether their queries will survive the next schema change or index modification.&lt;&#x2F;p&gt;
&lt;p&gt;For PostgreSQL development itself, the project has a robust regression test suite that has been preventing disasters in core development for decades. The database itself knows how to test SQL systematically - we just don&#x27;t use those same techniques for our own queries. Enter &lt;a href=&quot;&#x2F;products&#x2F;regresql&#x2F;&quot;&gt;RegreSQL&lt;&#x2F;a&gt;, a tool originally created by Dimitri Fontaine for &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;theartofpostgresql.com&quot;&gt;&lt;em&gt;The Art of PostgreSQL&lt;&#x2F;em&gt;&lt;&#x2F;a&gt; book (which is excellent for understanding and mastering PostgreSQL as a database system), designed to bring the same regression testing framework to our application queries.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;ve been trying to use it for some time, but due to missing features and limitations gave up several times. Until now. I decided to fork the project and spend the time needed to take it to the next level.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;a class=&quot;zola-anchor&quot; href=&quot;#introduction&quot; aria-label=&quot;Anchor link for: introduction&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;strong&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;products&#x2F;regresql&#x2F;&quot;&gt;RegreSQL&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt; promise starts with the biggest strength and perceived weakness of SQL queries. They are just strings. And unless you use something like &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;sqlc.dev&quot;&gt;sqlc&lt;&#x2F;a&gt; (for Go), &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;darioteixeira&#x2F;pgocaml&quot;&gt;PG&#x27;OCaml&lt;&#x2F;a&gt; or Rust&#x27;s &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;launchbadge&#x2F;sqlx&quot;&gt;SQLx&lt;&#x2F;a&gt; toolkit giving you compile-time checking, your queries are validated only when they are executed. Which in better case mean either usually slow-ish test suite or integration tests, in worst scenario only when deployed. ORMs are another possibility - completely abstracting away SQL (but more on that later).&lt;&#x2F;p&gt;
&lt;p&gt;But even with compile-time checking, you are only checking for one class of problems: schema mismatches. What about behavior changes after schema migration or performance regressions? What about understanding whether your optimization actually made things faster or just moved the problem elsewhere?&lt;&#x2F;p&gt;
&lt;p&gt;This is where RegreSQL comes in. Rather than trying to turn SQL into something else, RegreSQL embraces &quot;SQL as strings&quot; reality and applies the same testing methodology PostgreSQL itself uses: regression testing. You write (or generate - continue reading) your SQL queries, provide input data, and RegreSQL verifies that future changes don&#x27;t break those expectations.&lt;&#x2F;p&gt;
&lt;p&gt;The features don&#x27;t stop there though - it tracks performance baselines, detects common query plan regressions (like sequential scans), and gives you framework for systematic experimentation with the schema changes and query change management.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;basic-regression-testing&quot;&gt;Basic regression testing&lt;a class=&quot;zola-anchor&quot; href=&quot;#basic-regression-testing&quot; aria-label=&quot;Anchor link for: basic-regression-testing&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Enough with theory. Let&#x27;s jump in straight into the action and see what a sample run of RegreSQL looks like&lt;&#x2F;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;$ regresql text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Connecting to &amp;#39;postgres:&#x2F;&#x2F;radim:password123@192.168.139.28&#x2F;cdstore_test&amp;#39;… ✓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Running regression tests...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-by-artist_list-albums-by-artist.1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-by-artist_list-albums-by-artist.2.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-tracks_list-tracks-by-albumid.2.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-tracks_list-tracks-by-albumid.1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ artist_top-artists-by-album.1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-topn_genre-top-n.top-1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-topn_genre-top-n.top-3.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-tracks_tracks-by-genre.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Results: 8 passed, 0 failed, 8 skipped (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In this example based on &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;lerocha&#x2F;chinook-database&quot;&gt;Chinook database&lt;&#x2F;a&gt; (as used originally in The Art of PostgreSQL book), RegreSQL scans the current directory (or one provided by &lt;code&gt;-C &#x2F;path&#x2F;to&#x2F;project&lt;&#x2F;code&gt;) for &lt;code&gt;*.sql&lt;&#x2F;code&gt; files and attempts to run all queries against the configured PostgreSQL connection.&lt;&#x2F;p&gt;
&lt;p&gt;The individual files can contain either single or multiple sql queries. Like following example&lt;&#x2F;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;-- name: top-artists-by-album&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Get the list of the N artists with the most albums&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    artist&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; albums&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    artist&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    LEFT JOIN&lt;&#x2F;span&gt;&lt;span&gt; album &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; (artist_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    artist&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;name&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    albums &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DESC&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIMIT&lt;&#x2F;span&gt;&lt;span&gt; :n;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The syntax for the queries supports both positional arguments (like &lt;code&gt;$1&lt;&#x2F;code&gt; known from libpq library) or (preferred) &lt;code&gt;psql&lt;&#x2F;code&gt; style variable (&lt;code&gt;:varname&lt;&#x2F;code&gt;). The each identified query (not file) is then executed for 0..N times, based on number of predefined plans and verified to the expected results - validating the expected data matches the one returned. The support for SQL files handling is available separately with https:&#x2F;&#x2F;github.com&#x2F;boringSQL&#x2F;queries (Go version only for now).&lt;&#x2F;p&gt;
&lt;p&gt;This gives you what original RegreSQL tool has introduced - change your schema, refactor a query, run &lt;code&gt;regresql test&lt;&#x2F;code&gt; and see immediately what broke. The test suite now has ability to catch regressions before they are committed &#x2F; shipped. The current version built on top of it, giving you better console formatter instead of TAP style output, as well as jUnit, JSON and GitHub actions formatters for better integration into your CI&#x2F;CD pipelines.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;performance-regression-testing&quot;&gt;Performance regression testing&lt;a class=&quot;zola-anchor&quot; href=&quot;#performance-regression-testing&quot; aria-label=&quot;Anchor link for: performance-regression-testing&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Basic regression testing catches correctness issues - wrong results, broken queries, schema mismatches. But there&#x27;s another class of production issues it misses. Performance regressions. No matter how unbelievable it might sound but queries get deployed without appropriate indexes — which &lt;a href=&quot;&#x2F;posts&#x2F;why-postgresql-indexes-are-ignored&#x2F;&quot;&gt;might then be ignored&lt;&#x2F;a&gt; entirely — or they change over time. Simple fix - both for handwritten SQL or ORM code - can switch from milliseconds to seconds. You add index that helps one query, but tanks another. You modify conditionals and accidently force a &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-statistics&quot;&gt;sequential scan of millions of rows&lt;&#x2F;a&gt;. This is where it hurts.&lt;&#x2F;p&gt;
&lt;p&gt;RegreSQL addresses this by tracking performance baselines alongside correctness. Once baselines are generated&lt;&#x2F;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;$ regresql baseline&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Connecting to &amp;#39;postgres:&#x2F;&#x2F;appuser:password123@192.168.139.28&#x2F;cdstore_test&amp;#39;… ✓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Creating baselines directory: regresql&#x2F;baselines&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Creating directory &amp;#39;regresql&#x2F;baselines&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Creating baselines for queries:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  .&#x2F;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: album-by-artist_list-albums-by-artist.1.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: album-by-artist_list-albums-by-artist.2.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: album-tracks_list-tracks-by-albumid.1.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: album-tracks_list-tracks-by-albumid.2.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: artist_top-artists-by-album.1.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: genre-topn_genre-top-n.top-1.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: genre-topn_genre-top-n.top-3.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Created baseline: genre-tracks_tracks-by-genre.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Baselines have been created successfully!&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Baseline files are stored in: regresql&#x2F;baselines&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;the test command not only tests the regressions to the captured times, but also detects the common bad patterns in &lt;a href=&quot;&#x2F;posts&#x2F;explain-buffers&#x2F;&quot;&gt;query execution plans&lt;&#x2F;a&gt;. For now it provides warnings for detection of sequential scans - both on their and&#x2F;or with nested loops and multiple sort operations. I believe this alone might provide a valuable insights and reduce the mishaps in production. It&#x27;s also a place where further development of RegreSQL will take place.&lt;&#x2F;p&gt;
&lt;p&gt;To demonstrate this, let&#x27;s review the test output with the baselines.&lt;&#x2F;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;Connecting to &amp;#39;postgres:&#x2F;&#x2F;appuser:password123@192.168.139.28&#x2F;cdstore_test&amp;#39;… ✓&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Running regression tests...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-by-artist_list-albums-by-artist.1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-by-artist_list-albums-by-artist.2.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-by-artist_list-albums-by-artist.1.cost (22.09 &amp;lt;= 22.09 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Sequential scan detected on table &amp;#39;artist&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Consider adding an index if this table is large or this query is frequently executed&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Nested loop join with sequential scan detected&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Add index on join column to avoid repeated sequential scans&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-by-artist_list-albums-by-artist.2.cost (22.09 &amp;lt;= 22.09 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Sequential scan detected on table &amp;#39;artist&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Consider adding an index if this table is large or this query is frequently executed&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Nested loop join with sequential scan detected&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Add index on join column to avoid repeated sequential scans&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-tracks_list-tracks-by-albumid.1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-tracks_list-tracks-by-albumid.2.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-tracks_list-tracks-by-albumid.1.cost (8.23 &amp;lt;= 8.23 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ album-tracks_list-tracks-by-albumid.2.cost (8.23 &amp;lt;= 8.23 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ artist_top-artists-by-album.1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ artist_top-artists-by-album.1.cost (35.70 &amp;lt;= 35.70 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Multiple sequential scans detected on tables: album, artist&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Review query and consider adding indexes on filtered&#x2F;joined columns&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-topn_genre-top-n.top-1.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-topn_genre-top-n.top-3.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-topn_genre-top-n.top-1.cost (6610.59 &amp;lt;= 6610.59 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Multiple sequential scans detected on tables: genre, artist&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Review query and consider adding indexes on filtered&#x2F;joined columns&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Multiple sort operations detected (2 sorts)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Consider composite indexes for ORDER BY clauses to avoid sorting&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Nested loop join with sequential scan detected&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Add index on join column to avoid repeated sequential scans&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-topn_genre-top-n.top-3.cost (6610.59 &amp;lt;= 6610.59 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Multiple sequential scans detected on tables: artist, genre&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Review query and consider adding indexes on filtered&#x2F;joined columns&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Multiple sort operations detected (2 sorts)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Consider composite indexes for ORDER BY clauses to avoid sorting&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Nested loop join with sequential scan detected&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Add index on join column to avoid repeated sequential scans&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-tracks_tracks-by-genre.json (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;✓ genre-tracks_tracks-by-genre.cost (37.99 &amp;lt;= 37.99 * 110%) (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  ⚠️  Multiple sequential scans detected on tables: genre, track&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    Suggestion: Review query and consider adding indexes on filtered&#x2F;joined columns&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;Results: 16 passed (0.00s)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;As you can see, despite from not having baseline, RegreSQL is able to detect the basic bad patterns that should be addressed before queries can be considered &quot;production ready&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;In some cases, having the detection of sequential scans, or just tracking query costs baselines might be considered undesirable, which would lead to false positives. RegreSQL enables this to be addressed by query metadata as demonstrated below.&lt;&#x2F;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;-- name: query_name&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- metadata: key1=value1, key2=value2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; ...;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;At this point RegreSQL recognizes&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;notest&lt;&#x2F;code&gt; to skip the query testing altogether (not just cost tracking)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;nobaseline&lt;&#x2F;code&gt; to skip cost tracking&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;noseqscanwarn&lt;&#x2F;code&gt; to keep cost tracking but disable sequential scan warnings&lt;&#x2F;li&gt;
&lt;li&gt;and &lt;code&gt;difffloattolerance&lt;&#x2F;code&gt; to cost failure threshold (default 10% at the moment).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&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;-- name: query_name&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- regresql: notest, nobaseline&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- regresql: noseqscanwarn&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- regresql: difffloattolerance:0.25&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- query that can vary in cost by 20% without being considered a failure&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; ...;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;orm-enters-the-room&quot;&gt;ORM enters the room&lt;a class=&quot;zola-anchor&quot; href=&quot;#orm-enters-the-room&quot; aria-label=&quot;Anchor link for: orm-enters-the-room&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;ORMs abstract away SQL, but they still generate it - much like &lt;a href=&quot;&#x2F;posts&#x2F;view-inlining&#x2F;&quot;&gt;view inlining&lt;&#x2F;a&gt; where the planner rewrites your SQL behind the scenes - and that generated SQL can have performance problems you won&#x27;t catch until production. Consider this common scenario: you start with a simple SQLAlchemy query that works fine, then months later add eager loading for related data:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;python&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;orders&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    session.query(Order)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    .filter(Order.user_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span&gt; user_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    .options(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        joinedload(Order.user),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        joinedload(Order.shipping_address),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        selectinload(Order.items)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # NEW: Load order items&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    .all()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That innocent &lt;code&gt;selectinload(Order.items)&lt;&#x2F;code&gt; generates a separate query - and without an index on &lt;code&gt;order_items.order_id&lt;&#x2F;code&gt;, it performs a sequential scan.&lt;&#x2F;p&gt;
&lt;p&gt;RegreSQL can catch this by intercepting ORM-generated SQL using SQLAlchemy&#x27;s event system:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;python&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;@event.listens_for&lt;&#x2F;span&gt;&lt;span&gt;(engine,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;before_cursor_execute&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;def&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; capture_sql&lt;&#x2F;span&gt;&lt;span&gt;(conn, cursor, statement,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span&gt;args):&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    captured_queries.append(statement)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Run your ORM code, capture the SQL, save it as a .sql file, and test it with RegreSQL. The performance baseline testing will flag the missing index before it hits production. This is currently experimental, but ORM integration is a key area for RegreSQL&#x27;s future development.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;test-data-management&quot;&gt;Test Data Management&lt;a class=&quot;zola-anchor&quot; href=&quot;#test-data-management&quot; aria-label=&quot;Anchor link for: test-data-management&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Up until now we have covered how RegreSQL verifies query correctness and tracks performance regressions. But there&#x27;s a critical prerequisite we&#x27;ve only skimmed through.  Every regression test needs consistent, reproducible data. Change the data, change their cardinality, and your expected results become meaningless. Your performance  baselines drift. Your tests become flaky.&lt;&#x2F;p&gt;
&lt;p&gt;Traditional approach to create test data might involve&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Database dumps&lt;&#x2F;strong&gt; become unmanageable - 500MB files you can&#x27;t review, can&#x27;t understand, that break with every schema migration, and whose data becomes stale as production evolves. Which version of the dump are your tests even using?&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;SQL scripts&lt;&#x2F;strong&gt; might be better than dumps, but still imperative and hard to maintain. You end up with INSERT statements scattered across multiple files, managing foreign keys manually, and debugging constraint violations.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Factories in application code&lt;&#x2F;strong&gt; might work great for integration tests, but we&#x27;re testing SQL directly. Do you really want to maintain parallel data generation in your application language just for SQL tests?&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Shared test database&lt;&#x2F;strong&gt; is the synonym for classic &quot;works on my machine&quot; problem. State leaks between tests. Parallel execution becomes impossible. Debugging is a nightmare.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;What we need is something that&#x27;s declarative (what data, not how to insert it), reproducible (similar data every time), composable (build complex scenarios from simple pieces), and scalable (from 10 rows to 100,000).&lt;&#x2F;p&gt;
&lt;p&gt;This is where next improvement in RegreSQL&#x27;s fixture system comes in. Think of it as infrastructure-as-code for your test data. You describe the data you need in YAML files, and RegreSQL handles the rest - dependencies, cleanup, foreign keys, and even realistic data generation at scale.&lt;&#x2F;p&gt;
&lt;p&gt;RegreSQL&#x27;s fixture system lets you define test data in YAML files stored in &lt;code&gt;regresql&#x2F;fixtures&#x2F;&lt;&#x2F;code&gt;. Here&#x27;s a simple example&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  fixture&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; basic_users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  description&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; a handful of test users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  cleanup&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; rollback&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  data&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; table&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      rows&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          email&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; alice@example.com&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          name&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; Alice Anderson&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          created_at&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2024-01-15&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          email&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; bob@example.com&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          name&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; Bob Builder&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          created_at&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2024-02-20&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;To use this fixture in your tests, reference it in the query&#x27;s plan file (&lt;code&gt;regresql&#x2F;plans&#x2F;get-user.yaml&lt;&#x2F;code&gt;) you can just reference the fixture&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  fixtures&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; basic_users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;  &amp;quot;1&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;    email&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; alice@example.com&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;  &amp;quot;2&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;    email&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; bob@example.com&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And when you run &lt;code&gt;regresql test&lt;&#x2F;code&gt;, the fixture is automatically loaded before the query executes, and cleaned up afterward. No manual setup scripts, no state leakage between tests. But it does not stop with static fixtures. When you want to test queries against realistic volumes you can use range of &lt;strong&gt;data generators&lt;&#x2F;strong&gt; including&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;sequences, random integer, decimal, string, uuid, email and name generators&lt;&#x2F;li&gt;
&lt;li&gt;date_between for generating random timestamps within a range&lt;&#x2F;li&gt;
&lt;li&gt;foreign key references to be able to reuse data from other table&#x27;s fixtures&lt;&#x2F;li&gt;
&lt;li&gt;range to select value from predefined sources&lt;&#x2F;li&gt;
&lt;li&gt;Go template support&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; fixture&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; realistic_orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  generate&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; table&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; customers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      count&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      columns&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; sequence&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          start&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        email&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; email&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          domain&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; shop.example.com&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        name&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; name&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          type&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; full&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        created_at&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; date_between&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          start&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;2023-01-01&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          end&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;2024-12-31&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; table&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      count&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 5000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      columns&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; sequence&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          start&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        customer_id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          min&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          max&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        amount&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; decimal&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          min&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10.00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          max&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 999.99&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          precision&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        order_date&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; date_between&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          start&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;2023-01-01&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          end&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;2024-12-31&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This generates 1,000 customers and 5,000 orders with realistic-looking data - names, emails, dates, and amounts that feel production-like.&lt;&#x2F;p&gt;
&lt;p&gt;The fixtures are also &lt;strong&gt;stackable&lt;&#x2F;strong&gt; and can be build on top of each other. For example if you need to make sure users fixtures are created before orders fixtures, just declare the dependency (the already planned improvement is to include the support automatic foreign-key detection to avoid ID hard-coding). RegreSQL loads fixtures in dependency order and handles cleanup in reverse.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  fixture&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; orders_with_shipping&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  depends_on&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; basic_users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  data&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; table&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      rows&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 101&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          user_id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # References Alice from basic_users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          total&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 99.99&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          status&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; shipped&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Should the available options for fixtures (manual data or data generators) not be enough, you always have options to use good old SQL based data generation.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  fixture&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; mixed_setup&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  description&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; Combine SQL with YAML and generated data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  cleanup&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; rollback&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # SQL executes first (either as file or inline)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  sql&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; file&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; sql&#x2F;setup_schema.sql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; inline&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;INSERT INTO config (key, value) VALUES (&amp;#39;version&amp;#39;, &amp;#39;1.0&amp;#39;);&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # followed YAML data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  data&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; table&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      rows&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          email&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; admin@example.com&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # and finally generated data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;  generate&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; table&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      count&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 100&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;      columns&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; sequence&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          start&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;        user_id&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          generator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; int&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          min&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;          max&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;RegreSQL provides commands to inspect and validate your fixtures&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # List all available fixtures&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;  regresql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; fixtures list&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # Show fixture details and dependencies&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;  regresql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; fixtures show realistic_orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # Validate fixture definitions&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;  regresql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; fixtures validate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # Show dependency graph&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;  regresql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; fixtures deps&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;  # Apply fixture manually (for debugging)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;  regresql&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; fixtures apply basic_users&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The fixture system has been design to transforms test data from a maintenance burden into a documented, version-controlled process. Your YAML files become the single source of truth for what data your tests need, making it easy to understand test scenarios and maintain test data as the application evolves.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;(EDIT 2025-11-20)&lt;&#x2F;strong&gt; The dynamically generated fixtures on each tests will break the correctness testing of
RegreSQL. You have to use fixtures according to your use&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Fixed generated fixtures re-used between tests&lt;&#x2F;li&gt;
&lt;li&gt;Use RegreSQL to test fixtures before&#x2F;after migration&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I&#x27;m working on next release of RegreSQL to address DX of fixtures usage.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;regresql-future&quot;&gt;RegreSQL future&lt;a class=&quot;zola-anchor&quot; href=&quot;#regresql-future&quot; aria-label=&quot;Anchor link for: regresql-future&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Introducing a new open source project is an ambitious goal, and &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;products&#x2F;regresql&#x2F;&quot;&gt;RegreSQL&lt;&#x2F;a&gt; is just starting up. Despite the fork being in works for almost 2 years. In coming weeks and months I plan further improvements, as well as better documentation and more tutorials. The project is maintained as part of my &lt;strong&gt;boringSQL&lt;&#x2F;strong&gt; brand, where it&#x27;s vital component (together with pgTap) for building &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;labs.boringsql.com&quot;&gt;SQL Labs&lt;&#x2F;a&gt; which (as I sincerely hope) will provide a foundation for its further development.&lt;&#x2F;p&gt;
&lt;p&gt;At the same time &lt;strong&gt;RegreSQL&lt;&#x2F;strong&gt; is an attempt to give back to welcoming PostgreSQL community, make developer user experience slightly better if possible and (just maybe) provide one more argument against the case that SQL queries are not testable.&lt;&#x2F;p&gt;
&lt;p&gt;RegreSQL is available at &lt;a href=&quot;&#x2F;products&#x2F;regresql&#x2F;&quot;&gt;boringsql.com&#x2F;products&#x2F;regresql&lt;&#x2F;a&gt; - feel free to open issue, or drop me email about the project at &lt;a href=&quot;mailto:radim@boringsql.com&quot;&gt;radim@boringsql.com&lt;&#x2F;a&gt; or connect on &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.linkedin.com&#x2F;in&#x2F;1radim&#x2F;&quot;&gt;LinkedIn&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Beyond Start and End: PostgreSQL Range Types</title>
        <published>2025-11-02T21:40:00+00:00</published>
        <updated>2025-11-02T21:40:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/beyond-start-end-columns/"/>
        <id>https://boringsql.com/posts/beyond-start-end-columns/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/beyond-start-end-columns/">&lt;p&gt;One of the most read articles at boringSQL is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;posts&#x2F;know-the-time-in-postgresql&#x2F;&quot;&gt;Time to Better Know The Time in PostgreSQL&lt;&#x2F;a&gt; where we dived into the complexities of storing and handling time operations in PostgreSQL. While the article introduced the range data types, there&#x27;s so much more to them. And not only for handling time ranges. In this article we will cover why to consider range types and how to work with them.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bug-not-invented-here&quot;&gt;Bug Not Invented Here&lt;a class=&quot;zola-anchor&quot; href=&quot;#bug-not-invented-here&quot; aria-label=&quot;Anchor link for: bug-not-invented-here&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;But before we can talk about the range types, let&#x27;s try to understand why we should look at them in the first place. Let&#x27;s imagine a booking platform for large flash sales of the seats, that goes live at 10pm and will be taken by storm by thousands of people who want to get their tickets.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; seat_holds&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    hold_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    seat_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; seats(id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_session_id UUID &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- define the hold period explicitly&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    hold_started_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMPTZ NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    hold_expires_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMPTZ NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMPTZ NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; seat_holds_on_seat_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; seat_holds(seat_id);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; seat_holds_on_expiration&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; seat_holds(hold_expires_at);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While the table design looks perfectly reasonable, it has one &lt;strong&gt;serious flaw&lt;&#x2F;strong&gt; - there&#x27;s no database-level atomicity guarantee there to prevent two holds for the same &lt;code&gt;seat_id&lt;&#x2F;code&gt; at the same time. The table design requires on application logic to check for the existing holds before inserting a new hold, and at the same time it does not provide any high-concurrency guarantee.&lt;&#x2F;p&gt;
&lt;p&gt;If all you have in your toolbelt are those two columns you will end up increasing the complexity to make it work. You started with one problem and soon your application developers might want to ask you to add caching layer (most likely external K&#x2F;V store) to place the holds there and very soon you have N-problems when you will resort to building a complex, custom application-side locking mechanism that is bug-prone and difficult to maintain.&lt;&#x2F;p&gt;
&lt;p&gt;Other possibility is to bring all the operations on seat holds into more complex transaction management. Which is literally invitation for disaster in &lt;strong&gt;extreme contention&lt;&#x2F;strong&gt; situation like flash ticket sales. No matter which blocking strategy you use &lt;code&gt;SERIALIZABLE&lt;&#x2F;code&gt; transaction isoliation or pessimistic locking using &lt;code&gt;SELECT ... FOR UPDATE&lt;&#x2F;code&gt; will create a large overhead in application logic (retries, massive contention on database resources, etc.).&lt;&#x2F;p&gt;
&lt;p&gt;And as we are going to talk about range data types, there&#x27;s a first possible option to solve the problem directly on database level.&lt;&#x2F;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;-- needed to add GiST support to equality of integers (for seat_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; EXTENSION &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IF NOT EXISTS&lt;&#x2F;span&gt;&lt;span&gt; btree_gist;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; seat_holds &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_no_overlap&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    EXCLUDE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; gist (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        seat_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH =&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        tsrange(hold_started_at, hold_expires_at) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; &amp;amp;&amp;amp;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    );&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Which will add the constraint directly on the table level and enable atomic conflict detection for your seat holds with minimum locking overhead. This guarantees that the database will never allow two overlapping holds to exist for the same seat, irrespective of how many concurrent users are attempting to book. The real win here is data integrity - you&#x27;re making it impossible to have invalid state in your database, not just unlikely. While you&#x27;ll still need retry logic in your application when conflicts occur, you&#x27;ve moved the correctness guarantee from application code (where bugs hide) into the database schema (where it&#x27;s enforced). The GiST (Generalized Search Tree) index is the crucial component which makes checking for overlapping time ranges effective even under extreme load.&lt;&#x2F;p&gt;
&lt;p&gt;But really, if you look at the proposed fix, it&#x27;s still a workaround - we&#x27;re converting two separate &lt;code&gt;TIMESTAMPTZ&lt;&#x2F;code&gt; columns into a range type on the fly, when range types already include native GiST support out of the box.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;introducing-the-data-range-types&quot;&gt;Introducing the Data Range Types&lt;a class=&quot;zola-anchor&quot; href=&quot;#introducing-the-data-range-types&quot; aria-label=&quot;Anchor link for: introducing-the-data-range-types&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;You’ve seen the power of the &lt;code&gt;EXCLUDE&lt;&#x2F;code&gt; constraint to solve the concurrency problem, but why to settle for workaround (unless it&#x27;s temporary as part of the bigger refactoring) instead of going all the way in?&lt;&#x2F;p&gt;
&lt;p&gt;This brings us to the core of the matter: &lt;strong&gt;PostgreSQL&#x27;s native Range Types&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL provides a set of built-in range types, all following the pattern of type and range:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;int4range&lt;&#x2F;code&gt; for integer&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;int8range&lt;&#x2F;code&gt; for bigint&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;numrange&lt;&#x2F;code&gt; for numeric&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;tsrange&lt;&#x2F;code&gt; for timestamp without time zone&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;tstzrange&lt;&#x2F;code&gt; for timestamp with time zone (which we briefly saw above)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;daterange&lt;&#x2F;code&gt; for date&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And it does not stop there. You can easily define your &lt;strong&gt;own custom range types&lt;&#x2F;strong&gt; over any basic data type.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;first-win-cleaner-schema&quot;&gt;First win: cleaner schema&lt;a class=&quot;zola-anchor&quot; href=&quot;#first-win-cleaner-schema&quot; aria-label=&quot;Anchor link for: first-win-cleaner-schema&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When using &lt;code&gt;start&lt;&#x2F;code&gt; and &lt;code&gt;end&lt;&#x2F;code&gt; columns you are not explicitely telling the database that these two columns are single concept representing time span. The logic to work with those two columns resides only in your queries and application code.&lt;&#x2F;p&gt;
&lt;p&gt;When you refactor our sample table to embrace the native range type, it becomes more expressive and inherently correct.  The application code no longer needs to manage two separate boundaries.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; seat_holds_native&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    hold_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    seat_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; seats(id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_session_id UUID &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    hold_period TSTZRANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMPTZ NOT NULL DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is the power of a &lt;strong&gt;first-class data type&lt;&#x2F;strong&gt;. We&#x27;ve shifted the burden from the application logic to the database schema, making the table definition itself communicate its intent more clearly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;second-win-atomicity-guaranteed&quot;&gt;Second win: Atomicity guaranteed&lt;a class=&quot;zola-anchor&quot; href=&quot;#second-win-atomicity-guaranteed&quot; aria-label=&quot;Anchor link for: second-win-atomicity-guaranteed&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While the new schema is cleaner, the real database win comes from enforcing our concurrency guarantee - preventing two seats from being double-booked. To achieve this you can reuse the &lt;strong&gt;exclusion constraint&lt;&#x2F;strong&gt; as demonstrated previously.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_no_overlap&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    EXCLUDE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; gist (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        seat_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH =&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        hold_period &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; &amp;amp;&amp;amp;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    );&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This time you can use &lt;code&gt;hold_period&lt;&#x2F;code&gt; directly without need to explicitely convert it. This constraint enforces two rules at once:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;seat_id WITH =&lt;&#x2F;code&gt; ensures the constraint only applies to holds for the same seat.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;hold_period WITH &amp;amp;&amp;amp;&lt;&#x2F;code&gt; checking the overlap of hold periods with the operator &lt;code&gt;&amp;amp;&amp;amp;&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Finally &lt;code&gt;EXCLUDE USING gist&lt;&#x2F;code&gt;  is the crucial technical detail, telling PostgreSQL to use GiST index to enforce the constraint.  This is not specific to range types, as &lt;code&gt;EXCLUDE&lt;&#x2F;code&gt; constraint can&#x27;t exist without an index to enforce it (common use cases might include arrays, geometric data, etc.).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;range-boundaries-a-quick-math-refresher&quot;&gt;Range boundaries: A quick math refresher&lt;a class=&quot;zola-anchor&quot; href=&quot;#range-boundaries-a-quick-math-refresher&quot; aria-label=&quot;Anchor link for: range-boundaries-a-quick-math-refresher&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Before we dive into the operators, let&#x27;s take a moment to understand how PostgreSQL represents range boundaries. If you remember your high school math, range types use the same notation as intervals in mathematics.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL ranges can have four different boundary types:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Inclusive boundaries -&lt;&#x2F;strong&gt; &lt;code&gt;[&lt;&#x2F;code&gt; and &lt;code&gt;]&lt;&#x2F;code&gt; An inclusive boundary includes the endpoint value in the range. Think of it as &quot;less than or equal to&quot; or &quot;greater than or equal to&quot;.&lt;&#x2F;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;-- [10, 20] includes both 10 and 20&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- represents 10 ≤ x ≤ 20&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; int4range(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;20&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Exclusive boundaries -&lt;&#x2F;strong&gt; &lt;code&gt;(&lt;&#x2F;code&gt; and &lt;code&gt;)&lt;&#x2F;code&gt; An exclusive boundary excludes the endpoint value from the range. This is &quot;less than&quot; or &quot;greater than&quot; without the equality.&lt;&#x2F;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;-- (10, 20) excludes both 10 and 20&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- represents 10 &amp;lt; x &amp;lt; 20&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; int4range(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;20&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;()&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Mixed boundaries -&lt;&#x2F;strong&gt; &lt;code&gt;[)&lt;&#x2F;code&gt; or &lt;code&gt;(]&lt;&#x2F;code&gt; You can mix and match. The most common and &lt;strong&gt;default pattern&lt;&#x2F;strong&gt; in PostgreSQL is &lt;code&gt;[)&lt;&#x2F;code&gt; - inclusive lower bound, exclusive upper bound. This is particularly useful for timestamps and dates because it naturally represents &quot;from the start of one period up to (but not including) the start of the next&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;As mentioned, the default boundary &lt;code&gt;[)&lt;&#x2F;code&gt; eliminates the natural ambiguity when representing consecutive periods.&lt;&#x2F;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;-- these ranges are adjacent, not overlapping&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Week 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[2025-11-01, 2025-11-08)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Week 2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[2025-11-08, 2025-11-15)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With this notation, the end of one period is exactly the start of the next, with no gaps or overlaps. This makes it perfect for time-based ranges, inventory availability windows, or any scenario where you&#x27;re dividing a continuum into distinct segments.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;canonicalization-and-range-set-operations&quot;&gt;Canonicalization and Range Set Operations&lt;a class=&quot;zola-anchor&quot; href=&quot;#canonicalization-and-range-set-operations&quot; aria-label=&quot;Anchor link for: canonicalization-and-range-set-operations&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Boundaries are not the only aspect of the ranges that behave like mathematical sets, allowing arithmetic operations and canonicalization of the discrete ranges.&lt;&#x2F;p&gt;
&lt;p&gt;For &lt;strong&gt;discrete&lt;&#x2F;strong&gt; range types (int4range, int8range, daterange), multiple representations can actually mean the exact same set of values. For example, for integers, the range [10, 20] (inclusive on both ends) is the same set as (9, 21) (exclusive on both ends) or the default PostgreSQL canonical form [10, 21) (inclusive lower, exclusive upper).&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL uses a &lt;strong&gt;canonicalization function&lt;&#x2F;strong&gt; to convert all equivalent discrete ranges into a single, uniform representation (default &lt;code&gt;[)&lt;&#x2F;code&gt; boundary mentioned above), which is essential for accurate equality checks and indexing.&lt;&#x2F;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;-- [10, 20] includes integers 10, 11, ..., 20.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; int4range(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;20&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; original_range;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Result: [10,21)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- (9, 21) includes integers 10, 11, ..., 20.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; int4range(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;9&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;21&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;()&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; original_range;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Result: [10,21)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Exception are &lt;strong&gt;continuous ranges&lt;&#x2F;strong&gt; (think floats and timestamps with fractional seconds) where PostgreSQL won&#x27;t use canonicalization because a boundary change always means a change in the contained values as there&#x27;s no easy to define &quot;next value&quot;. I.e. there&#x27;s no next value for 20.0 (i.e. not 20.0001, nor 20.000001, etc.) and changing boundary would change it&#x27;s meaning.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-operator-toolkit&quot;&gt;The operator toolkit&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-operator-toolkit&quot; aria-label=&quot;Anchor link for: the-operator-toolkit&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The type ranges and by their definition GiST (in this instance range_ops) and GIN (array_ops) indexes come with number of operator that makes your life easier.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Overlap operator - &lt;code&gt;&amp;amp;&amp;amp;&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;
As mentioned already above the overlap operator is the most fundamental one. It simply checks whether two ranges share any common data points.&lt;&#x2F;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;-- find holds active at any point between 10:00 and 11:00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period &amp;amp;&amp;amp; &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-12-25 10:00, 2025-12-25 11:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Contains operator - &lt;code&gt;@&amp;gt;&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;
For checking against specific moments in time, we might turn to the Contains operator. It verifies whatever the range on the left completely containts the element on the right (which might be both underlying data type or range type).&lt;&#x2F;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;-- find holds that are active at the specific momement&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-11-05 15:00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- find holds that are active at the specific time range&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[2025-12-25 10:00, 2025-12-25 10:15)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Contained By operator - &lt;code&gt;&amp;lt;@&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;
In contrast, the Contained By operator checks the reverse relationship - whatever the range on the left is entirely contained by the range on the right.&lt;&#x2F;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;-- find holds that are within November &amp;#39;25&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span&gt;@ &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-11-01, 2025-12-01)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Strictly Before&#x2F;After operators - &lt;code&gt;&amp;lt;&amp;lt;&lt;&#x2F;code&gt; and &lt;code&gt;&amp;gt;&amp;gt;&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; operators allow you to query ranges that are completely separated from the reference range (i.e. don&#x27;t even touch the boundaries).&lt;&#x2F;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;-- find holds that finished strictly before 10 November&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SELECT * FROM seat_holds_native WHERE hold_period &amp;lt;&amp;lt; &amp;#39;[2025-11-10, 2025-11-15)&amp;#39;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Boundary Extension operators - &lt;code&gt;&amp;amp;&amp;lt;&lt;&#x2F;code&gt; and &lt;code&gt;&amp;amp;&amp;gt;&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; let you reason about range boundaries independently, checking whether one range extends beyond another&#x27;s endpoints (i.e. it can start&#x2F;end anywhere within the given range).&lt;&#x2F;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;-- find holds that end before or at the same time as reference range ends&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period &amp;amp;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[2025-11-08 17:00, 2025-11-08 18:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- find holds that start at or after reference range starts&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period &amp;amp;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-11-08 09:00, 2025-11-08 18:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Finally the &lt;strong&gt;Adjecent operator - &lt;code&gt;-|-&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; checks if two ranges are perfectly contiguous - they MUST touch at exactly one boundary point, but do not overlap. This might be invaluable when checking if a customer can extend an existing hold without any gap or conflict.&lt;&#x2F;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;-- find holds that are immediately adjacent (touching) to given range&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;|&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[2025-11-08 17:00, 2025-11-08 18:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;to-infinity-and-beyond&quot;&gt;To Infinity and Beyond&lt;a class=&quot;zola-anchor&quot; href=&quot;#to-infinity-and-beyond&quot; aria-label=&quot;Anchor link for: to-infinity-and-beyond&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Similar to base types ranges in PostgreSQL can handle NULL values, but it does not stop there. There are also special states specifically applicable to data type ranges: &lt;code&gt;empty&lt;&#x2F;code&gt; and &lt;code&gt;infinity&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s start with &lt;strong&gt;infinite bounds&lt;&#x2F;strong&gt;, the bound that shows the real power of the ranges. You can define range that extend infinitely in either direction (or both at the same time).&lt;&#x2F;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;-- range that never expires (upper bound is infinite)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[2025-11-01 10:00, infinity)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- range that has always been valid (lower bound is infinite)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[-infinity, 2025-11-01 10:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- range covering all time&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;[-infinity, infinity)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This gives you ability to describe the &quot;from this points forward&quot; use cases. As we will cover later we can easily define lifetime subscription.&lt;&#x2F;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;-- lifetime subscription that never expires&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; subscriptions (user_id, plan, active_period)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;42&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;lifetime&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-11-01, infinity)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- all active subscriptions right now&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; subscriptions&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; active_period @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt; NOW&lt;&#x2F;span&gt;&lt;span&gt;();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Using &lt;code&gt;infinity&lt;&#x2F;code&gt; is far more elegant solution that using NULL values or &quot;special&quot; values like &lt;code&gt;2099-31-12&lt;&#x2F;code&gt; - it&#x27;s explicit and clearly communicates the data intent.&lt;&#x2F;p&gt;
&lt;p&gt;At any point you can validate whatever range has infinite bounds:&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  lower_inf(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-11-01, infinity)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; lower_is_infinite,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  upper_inf(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-11-01, infinity)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; upper_is_infinite;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;understanding-null-vs-empty-schrodinger-s-range&quot;&gt;Understanding NULL vs empty: Schrödinger&#x27;s Range&lt;a class=&quot;zola-anchor&quot; href=&quot;#understanding-null-vs-empty-schrodinger-s-range&quot; aria-label=&quot;Anchor link for: understanding-null-vs-empty-schrodinger-s-range&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Ranges can be NULL or empty, and these are completely different things. NULL is Schrödinger&#x27;s range - you haven&#x27;t looked in the box yet, so it could be anything or nothing. Empty is when you&#x27;ve opened the box and confirmed it&#x27;s empty.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s see this in practice:&lt;&#x2F;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;-- NULL range: we don&amp;#39;t know what the period is&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native (seat_id, user_session_id, hold_period)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;42&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;abc-123&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- empty range: we know the period is explicitly &amp;quot;nothing&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native (seat_id, user_session_id, hold_period)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;43&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;def-456&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;empty&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The main difference between them is when it comes to handling.&lt;&#x2F;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 NULL&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange &amp;amp;&amp;amp; &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-11-01, 2025-11-08)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Result: NULL (not true, not false—we don&amp;#39;t know)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;empty&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange &amp;amp;&amp;amp; &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-11-01, 2025-11-08)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Result: false (we know it doesn&amp;#39;t overlap)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And you can check for them in your queries using built-in function &lt;code&gt;isempty&lt;&#x2F;code&gt;.&lt;&#x2F;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;-- check for NULL (like any column)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; hold_period &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IS NULL&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- check for empty (special function)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; seat_holds_native &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; isempty(hold_period);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In practice, you&#x27;ll mostly use &lt;code&gt;NOT NULL&lt;&#x2F;code&gt; constraints to prevent NULL ranges entirely. Empty ranges are useful but rare - usually for representing cancelled&#x2F;void periods you need to keep for special purposes - like audit trail.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;practical-integer-ranges-for-tiered-pricing&quot;&gt;Practical Integer ranges for Tiered pricing&lt;a class=&quot;zola-anchor&quot; href=&quot;#practical-integer-ranges-for-tiered-pricing&quot; aria-label=&quot;Anchor link for: practical-integer-ranges-for-tiered-pricing&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While we introduced ranges we mostly paid attention to the date&#x2F;time handling the usefulness of range types goes well beyond that. One of the practical applications where integer ranges provide real values can be demostrated on tiered pricing.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; quantity_discounts&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    discount_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    product_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    quantity_range INT4RANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    discount_percentage &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NUMERIC&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- no overlapping tiers&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    EXCLUDE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; GIST (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        product_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH =&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        quantity_range &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; &amp;amp;&amp;amp;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; quantity_discounts (product_id, quantity_range, discount_percentage) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- 1-9 units: no discount&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[1,10)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- 10-49 units: 5% off&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[10,50)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- 50-99 units: 10% off&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[50,100)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;,   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- 100+ units: 15% off&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[100,1000)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;15&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;00&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- verify what discount we offer for ordering 75 units?&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; discount_percentage&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; quantity_discounts&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; product_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  AND&lt;&#x2F;span&gt;&lt;span&gt; quantity_range @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 75&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Result: 10.00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;making-bad-data-impossible&quot;&gt;Making Bad Data Impossible&lt;a class=&quot;zola-anchor&quot; href=&quot;#making-bad-data-impossible&quot; aria-label=&quot;Anchor link for: making-bad-data-impossible&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;If the introduction of the range types provided the case for cleaner schema you can go ahead and make hard limits structurally impossible. While this is not advocating for the transition of the full business logic into database schema, you can eliminate the edge cases that should never make it to the database.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; promotional_campaigns&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    campaign_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name TEXT NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    active_period TSTZRANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    budget_range NUMRANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    discount_percentage &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NUMERIC&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- campaigns must be at least 1 days long&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; campaigns_minimum_duration&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        CHECK&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;upper&lt;&#x2F;span&gt;&lt;span&gt;(active_period) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; lower&lt;&#x2F;span&gt;&lt;span&gt;(active_period) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;=&lt;&#x2F;span&gt;&lt;span&gt; INTERVAL &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 days&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- budget must be between $1000 and $100000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; campaigns_valid_budget&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        CHECK&lt;&#x2F;span&gt;&lt;span&gt; (budget_range &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span&gt;@ numrange(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1000&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;100000&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- active period must not be empty&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; campaigns_valid_period&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        CHECK&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT&lt;&#x2F;span&gt;&lt;span&gt; isempty(active_period))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While this example demonstrates hypothetical example within the schema definition, please remember they shouldn&#x27;t be used to implement business process. The goal of the constraints is to enforce data integrity, i.e. structure requirements (minimum duration, non-empty data), physical or domain boundaries. Any other logic should make it&#x27;s way either to application logic or parts that are easier to modify (think functions).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;multiranges-when-one-range-is-not-enough&quot;&gt;Multiranges: When one range is not enough&lt;a class=&quot;zola-anchor&quot; href=&quot;#multiranges-when-one-range-is-not-enough&quot; aria-label=&quot;Anchor link for: multiranges-when-one-range-is-not-enough&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Up until now, we&#x27;ve been working with single continuous ranges. But what happens when you need to represent fragmented ranges? In past you needed a separate table with a foreign key relationship. With multiranges, you can store multiple non-contiguous ranges in a single column.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 14 introduced multirange types for all the built-in range types:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;int4multirange&lt;&#x2F;code&gt;, &lt;code&gt;int8multirange&lt;&#x2F;code&gt;, &lt;code&gt;nummultirange&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;tsmultirange&lt;&#x2F;code&gt;, &lt;code&gt;tstzmultirange&lt;&#x2F;code&gt;, &lt;code&gt;datemultirange&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The real power of multiranges lies in &lt;strong&gt;schema density&lt;&#x2F;strong&gt; and &lt;strong&gt;query efficiency&lt;&#x2F;strong&gt;. We can prove this by comparing the cost of storing and querying the exact same data using two different valid range schemas.&lt;&#x2F;p&gt;
&lt;p&gt;Let&#x27;s consider storing 20 fragmented and non-contiguous periods - a pattern common for historical data.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; user_periods_single_range&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    period_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    active_period TSTZRANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; user_periods_single_range_gist_idx&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    ON&lt;&#x2F;span&gt;&lt;span&gt; user_periods_single_range&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    USING&lt;&#x2F;span&gt;&lt;span&gt; gist (active_period);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- 20 fragmented periods for user_id 42 (20 rows total)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; user_periods_single_range (user_id, active_period)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    42&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    tstzrange(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;        &amp;#39;2025-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;&#x2F;span&gt;&lt;span&gt; (i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; days&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)::interval,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;        &amp;#39;2025-01-01&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz +&lt;&#x2F;span&gt;&lt;span&gt; (i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;*&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39; days&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)::interval&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;20&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; s(i);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;compared to 1 row for the same 20 periods aggregated using &lt;code&gt;range_agg&lt;&#x2F;code&gt; function to consolidate data.&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; user_periods_multirange&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    active_periods TSTZMULTIRANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; user_periods_multirange_gist_idx&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    ON&lt;&#x2F;span&gt;&lt;span&gt; user_periods_multirange&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    USING&lt;&#x2F;span&gt;&lt;span&gt; gist (active_periods);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- consolidate the 20 TSTZRANGE rows into 1 TSTZMULTIRANGE row&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; user_periods_multirange (user_id, active_periods)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- range_agg function automatically handles merging adjacent ranges&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    range_agg(active_period)::TSTZMULTIRANGE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; user_periods_single_range&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 42&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; user_id;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;consider now the difference between&lt;&#x2F;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&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; period_id, user_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; user_periods_single_range&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 42&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    AND&lt;&#x2F;span&gt;&lt;span&gt; active_period @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-20 12:00:00+00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                           QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Bitmap Heap Scan on user_periods_single_range  (cost=4.19..13.67 rows=1 width=12)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Recheck Cond: (active_period @&amp;gt; &amp;#39;2025-01-20 13:00:00+01&amp;#39;::timestamp with time zone)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (user_id = 42)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Bitmap Index Scan on user_periods_single_range_gist_idx  (cost=0.00..4.19 rows=6 width=0)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Index Cond: (active_period @&amp;gt; &amp;#39;2025-01-20 13:00:00+01&amp;#39;::timestamp with time zone)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;and same version of the consolidated data&lt;&#x2F;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&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; user_id, active_periods&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; user_periods_multirange&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 42&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    AND&lt;&#x2F;span&gt;&lt;span&gt; active_periods @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-20 12:00:00+00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;                                                 QUERY PLAN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-------------------------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Index Scan using user_periods_multirange_pkey on user_periods_multirange  (cost=0.15..8.17 rows=1 width=36)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Index Cond: (user_id = 42)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (active_periods @&amp;gt; &amp;#39;2025-01-20 13:00:00+01&amp;#39;::timestamp with time zone)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Giving you significant reduction in the query cost. And while the example was simple enough for the demonstration purposes you can easily define the helper schema for better indexable data access and opportunities to reduce the storage requirements.&lt;&#x2F;p&gt;
&lt;p&gt;One important note is that subtle change with the &lt;code&gt;&amp;amp;&amp;amp;&lt;&#x2F;code&gt; operators. Whereas with single range &lt;code&gt;&amp;amp;&amp;amp;&lt;&#x2F;code&gt; operator checks if two continues ranges overlap, for multiranges the operator checks if ANY range in multirange overlaps.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;creating-custom-range-types&quot;&gt;Creating custom range types&lt;a class=&quot;zola-anchor&quot; href=&quot;#creating-custom-range-types&quot; aria-label=&quot;Anchor link for: creating-custom-range-types&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While PostgreSQL provides built-in range types for common data types, you can create custom range types for any data type that has a meaningful ordering. Let&#x27;s demostrate this with a type for IP address ranges.&lt;&#x2F;p&gt;
&lt;p&gt;To create a custom range type, you need to provide a subtype difference function that tells PostgreSQL how to calculate the &quot;distance&quot; between two values:&lt;&#x2F;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;-- function to calculate difference between two IP addresses using bigint&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE OR REPLACE FUNCTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; inet_diff&lt;&#x2F;span&gt;&lt;span&gt;(x &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INET&lt;&#x2F;span&gt;&lt;span&gt;, y &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INET&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;RETURNS&lt;&#x2F;span&gt;&lt;span&gt; FLOAT8 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; $$&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        (host(x)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;inet -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;0.0.0.0&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;inet&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        (host(y)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;inet -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;0.0.0.0&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;inet&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    )::float8;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$$ &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LANGUAGE SQL&lt;&#x2F;span&gt;&lt;span&gt; IMMUTABLE STRICT;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- custom inetrange type&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TYPE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; inetrange&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS RANGE&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    subtype &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= inet&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    subtype_diff &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; inet_diff&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- convert CIDR ranges to inetranges&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE OR REPLACE FUNCTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; cidr_to_inetrange&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;cidr CIDR&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;RETURNS&lt;&#x2F;span&gt;&lt;span&gt; inetrange &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; $$&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    SELECT&lt;&#x2F;span&gt;&lt;span&gt; inetrange(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        host(network($&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;))::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;inet&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        host(broadcast($&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;))::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;inet&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;        &amp;#39;[]&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    );&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;$$ &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LANGUAGE SQL&lt;&#x2F;span&gt;&lt;span&gt; IMMUTABLE STRICT;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now you can use these custom types just like the built-in ones:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; ip_blocklists&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    blocklist_name &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    blocked_range inetrange &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; ip_blocklists (blocklist_name, blocked_range) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;attack #434401&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, cidr_to_inetrange(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;192.168.1.0&#x2F;24&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;attack #434401 (1)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[203.0.113.50,203.0.113.99]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;attack #434401 (2)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[203.0.113.143,203.0.113.159]&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;and locate which attack has been assigned to particular malicious IP address.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; blocklist_name, blocked_range&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; ip_blocklists&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; blocked_range @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;192.168.1.25&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INET&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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; blocklist_name |        blocked_range&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------+-----------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; attack #434401 | [192.168.1.0,192.168.1.255]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But wait a second, wasn&#x27;t the fragmented nature of the ranges used in case for multiranges? Building real-life production and auto adaptive block list would most likely soon create extremely fragmented set of targets to block.&lt;&#x2F;p&gt;
&lt;p&gt;Can we defined it for our custom range types? Well no, because PostgreSQL is amazing! Since PostgreSQL 14 every time you define custom range type, it will automatically create corresponding multirange! Making it easy to consolidate the fragmented data corresponding to individual attack to multiranges.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; typname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_type &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; typname &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LIKE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;inet%range&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;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;    typname&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;----------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; inetmultirange&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; inetrange&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Danger zone! When creating a custom range type the &lt;strong&gt;subtype_diff&lt;&#x2F;strong&gt; function is more than just simple helper. It plays &lt;strong&gt;important role in indexing and query performance&lt;&#x2F;strong&gt;. It really tells PostgreSQL planner how far apart the values in range are, which is crucial when building GiST indexes for ranges.&lt;&#x2F;p&gt;
&lt;p&gt;In our example above, if &lt;code&gt;inet_diff&lt;&#x2F;code&gt; returned &lt;code&gt;0&lt;&#x2F;code&gt; for every pair of IP addresses, PostgreSQL would think all ranges are &quot;equally close&quot;. This would lead to un-balanced indexes, with large hotposts in the indexes. End result would be that range operators would effectively be almost as slow as sequantial scans.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;performance-deep-dive-gist-vs-gin&quot;&gt;Performance deep dive: GiST vs GIN&lt;a class=&quot;zola-anchor&quot; href=&quot;#performance-deep-dive-gist-vs-gin&quot; aria-label=&quot;Anchor link for: performance-deep-dive-gist-vs-gin&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Throughout this article, we&#x27;ve been using GiST indexes almost exclusively, particularly when enforcing exclusion constraints. But PostgreSQL also supports GIN indexes for range types, and understanding when to use each can make the difference between a query that completes in milliseconds versus one that grinds your database to a halt.&lt;&#x2F;p&gt;
&lt;p&gt;Before we deep dive, let&#x27;s recap what those two indexes do. &lt;strong&gt;GiST (Generalized Search Tree)&lt;&#x2F;strong&gt; is a balanced tree structure that organizes ranges by their bounding boxes, and grouping those that are &quot;close together&quot; in same tree nodes. While &lt;strong&gt;GIN (Generalized Inverted Index)&lt;&#x2F;strong&gt; would decompose ranges into their components and indexing those. Therefore GiST works for continous ranges (timestamps and numerical values), while GIN works for discrete ranges (as you can&#x27;t generate unpredictable range of values of floats for example). Given this characteristics you can almost certainly say GIN indexes are almost always going to be bigger compared to the GiST ones, as they are always trying to index a continous space.&lt;&#x2F;p&gt;
&lt;p&gt;The most important thing to know upfront? As we already used without explicitely mentioning it - &lt;strong&gt;you can&#x27;t use GIN with&lt;&#x2F;strong&gt; EXCLUDE &lt;strong&gt;constraints.&lt;&#x2F;strong&gt; GiST is mandatory there.&lt;&#x2F;p&gt;
&lt;p&gt;Therefore while GiST index is going to be preferred for cases like&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; seat_holds&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    hold_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    seat_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    hold_period TSTZRANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;the GIN index is actually preferred for&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; venue_blackouts&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    venue_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    blocked_dates DATERANGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The reason is simple - dates are discrete type. There&#x27;s only 365 (or 366) combinations in a given year. While timestamps have microsecond precision - millions of possible values per day.&lt;&#x2F;p&gt;
&lt;p&gt;The complexity of the index types is far beyond this article scope, so let&#x27;s just iterate over basic rules what index type to use. Most applications should just use GiST and move on. The performance difference rarely matters until you&#x27;re dealing with millions of rows and very specific query patterns. Don&#x27;t prematurely optimize - GiST is the safe, versatile default that works well for almost everything. You can always add a GIN index later if profiling shows it would help. PostgreSQL&#x27;s query planner is smart enough to pick the better index when both are available.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;a class=&quot;zola-anchor&quot; href=&quot;#conclusion&quot; aria-label=&quot;Anchor link for: conclusion&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;From my experience range types represent one of PostgreSQL&#x27;s most underutilized features, yet they offer immediate benefits: cleaner schemas, built-in data integrity, and query patterns that feel natural once you embrace them. What started as a solution to prevent double-booking seats evolved into a comprehensive look at how treating ranges as first-class citizens transforms your database design.&lt;&#x2F;p&gt;
&lt;p&gt;But we&#x27;ve really just scratched the surface. Timestamp ranges in particular open an entire world of possibilities we haven&#x27;t touched - &lt;strong&gt;temporal tables&lt;&#x2F;strong&gt;. The ability to maintain complete historical records with automatic versioning, query data &quot;as of&quot; any point in time, and track changes without cluttering your schema with audit columns deserves its own deep dive. That&#x27;s a topic for a future article.&lt;&#x2F;p&gt;
&lt;p&gt;For now, the next time you reach for separate &lt;code&gt;start&lt;&#x2F;code&gt; and &lt;code&gt;end&lt;&#x2F;code&gt; columns, stop and ask yourself: &quot;Should this be a range?&quot; More often than not, the answer is yes. Your future self - the one debugging a problems in least convenient time - will thank you.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>PostgreSQL maintenance without superuser</title>
        <published>2025-09-13T20:55:00+00:00</published>
        <updated>2025-09-13T20:55:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/postgresql-predefined-roles/"/>
        <id>https://boringsql.com/posts/postgresql-predefined-roles/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/postgresql-predefined-roles/">&lt;p&gt;How many people&#x2F;services have superuser access to your PostgreSQL cluster(s)?
Did you ever ask why your software engineers might need it? Or your BI team?
Why those use cases require same privileges as someone who can drop your
databases?&lt;&#x2F;p&gt;
&lt;p&gt;The answer isn&#x27;t because these operations are inherently dangerous - it&#x27;s
because PostgreSQL historically offered limited options for operational access
or simply because not enough people are aware of the options. So the common practice
is to either got basic permissions or handover the keys to the kingdom.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL&#x27;s &lt;strong&gt;built-in predefined roles&lt;&#x2F;strong&gt; solve this problem by providing
purpose-built privileges for common maintenance tasks. Instead of granting
superuser access for routine operations, you can delegate specific capabilities - monitoring teams get comprehensive observability access, backup services get data reading capabilities, and maintenance scripts get precisely the permissions
they need, nothing more.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-are-predefined-roles&quot;&gt;What are Predefined Roles?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-are-predefined-roles&quot; aria-label=&quot;Anchor link for: what-are-predefined-roles&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;PostgreSQL&#x27;s built-in administrative roles are purpose-built permission sets
that solve the superuser dilemma for common maintenance tasks. Out of the box,
there are 15 predefined roles that provide granular access to specific
operational capabilities without requiring full superuser privileges.&lt;&#x2F;p&gt;
&lt;p&gt;While you can view their list and description in &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;predefined-roles.html&quot;&gt;official
documentation&lt;&#x2F;a&gt;,
in this article we will explore them bit more thoroughly and at the same time
look into system catalogs to understand them better. The individual roles can be
grouped by their functionality and most of them are quite easy to grasp, ranging
from simple monitoring access to powerful filesystem operations that require
careful consideration.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Data Access Role&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_database_owner&lt;&#x2F;code&gt; - Database-specific ownership (special case)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_read_all_data&lt;&#x2F;code&gt; - Read access to all tables, views, sequences&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_write_all_data&lt;&#x2F;code&gt; - Write access to all tables, views, sequences&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Monitoring &amp;amp; Observability&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_monitor&lt;&#x2F;code&gt; - Which is actually monitoring meta role that contains 3 roles listed below&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_read_all_settings&lt;&#x2F;code&gt; - Configuration access&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_read_all_stats&lt;&#x2F;code&gt; - Statistics views&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_stat_scan_tables&lt;&#x2F;code&gt; - Table scanning for stats&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;System Operations&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_signal_backend&lt;&#x2F;code&gt; - Cancel queries, terminate sessions&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_checkpoint&lt;&#x2F;code&gt; - Run CHECKPOINT command&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_maintain&lt;&#x2F;code&gt; - VACUUM, ANALYZE, REINDEX operations (PostgreSQL 17+)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_signal_autovacuum_worker&lt;&#x2F;code&gt; - Signal autovacuum worker (PostgreSQL 18+)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;File System Access&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_read_server_files&lt;&#x2F;code&gt; - Read files from server filesystem&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_write_server_files&lt;&#x2F;code&gt; - Write files to server filesystem&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_execute_server_program&lt;&#x2F;code&gt; - Execute programs on server&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And &lt;strong&gt;specialised use cases&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_create_subscription&lt;&#x2F;code&gt; - Logical replication management&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_use_reserved_connections&lt;&#x2F;code&gt; - Connection reservation (PostgreSQL 16+)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;why-use-predefined-roles&quot;&gt;Why Use Predefined Roles?&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-use-predefined-roles&quot; aria-label=&quot;Anchor link for: why-use-predefined-roles&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The primary benefit of predefined roles is &lt;strong&gt;expanding the pool of users who can
safely manage PostgreSQL databases&lt;&#x2F;strong&gt;. Traditional PostgreSQL administration
created an artificial binary choice: either users had basic access with limited
capabilities, or they required full superuser privileges for operational tasks.
This forced many organizations to grant excessive permissions simply to perform
routine operations like monitoring, backups, or maintenance.&lt;&#x2F;p&gt;
&lt;p&gt;Predefined roles break this limitation by allowing more granular control over
operational tasks without distributing superuser privileges. Instead of having a
small number of highly privileged superusers handling all administrative work,
organizations can now delegate specific capabilities to appropriate operational
roles - monitoring teams get monitoring access, backup services get data reading
capabilities, and maintenance scripts get precisely the permissions they need.&lt;&#x2F;p&gt;
&lt;p&gt;The deeper benefit is &lt;strong&gt;abstraction&lt;&#x2F;strong&gt;. Not only do you switch from manually
managing permissions on individual system objects, you switch to managing
logical capability sets. This creates a clear separation between &lt;em&gt;what&lt;&#x2F;em&gt; you want
to allow and &lt;em&gt;how&lt;&#x2F;em&gt; it&#x27;s actually implemented.&lt;&#x2F;p&gt;
&lt;p&gt;The advantage is clearly demonstrated in the following example&lt;&#x2F;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;-- granting select on each schema separately&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT&lt;&#x2F;span&gt;&lt;span&gt; USAGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON SCHEMA&lt;&#x2F;span&gt;&lt;span&gt; finance, hr, app, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;audit TO&lt;&#x2F;span&gt;&lt;span&gt; analytics_team;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT SELECT ON&lt;&#x2F;span&gt;&lt;span&gt; ALL TABLES &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN SCHEMA&lt;&#x2F;span&gt;&lt;span&gt; finance &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; analytics_team;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT SELECT ON&lt;&#x2F;span&gt;&lt;span&gt; ALL SEQUENCES &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN SCHEMA&lt;&#x2F;span&gt;&lt;span&gt; finance &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; analytics_team;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- versus one time setup&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT&lt;&#x2F;span&gt;&lt;span&gt; pg_read_all_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; analytics_team;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The predefined roles approach automatically covers:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;All current tables, views and sequences&lt;&#x2F;li&gt;
&lt;li&gt;All schemas&lt;&#x2F;li&gt;
&lt;li&gt;Future objects created after the &lt;code&gt;GRANT&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And the good news is the benefits don&#x27;t end here. The real thing on operational level comes with the scope of those predefined roles - they &lt;strong&gt;apply at cluster level&lt;&#x2F;strong&gt; (instead on database level). Only notable exception is role &lt;code&gt;pg_database_owner&lt;&#x2F;code&gt; which we will cover next.&lt;&#x2F;p&gt;
&lt;p&gt;Before going there, let&#x27;s briefly mention second benefit, and that&#x27;s the fact any new operational features in PostgreSQL will be covered by those predefined roles automatically.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-evolution-of-predefined-roles&quot;&gt;The Evolution of Predefined Roles&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-evolution-of-predefined-roles&quot; aria-label=&quot;Anchor link for: the-evolution-of-predefined-roles&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Understanding when and why predefined roles were introduced provides important
insight into PostgreSQL&#x27;s operational priorities and helps explain some of the
design decisions. The journey from a single role in PostgreSQL 9.6 to today&#x27;s
comprehensive set reflects real-world pain points that PostgreSQL developers
encountered in production environments.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 9.6 (2016)&lt;&#x2F;strong&gt; introduced first predefined role &lt;code&gt;pg_signal_backend&lt;&#x2F;code&gt; to
address common frustration - inability to cancel running query without giving
away superuser rights.&lt;&#x2F;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;-- This became possible in 9.6 without superuser privileges&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_cancel_backend(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;12345&lt;&#x2F;span&gt;&lt;span&gt;);  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Cancel a query&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_terminate_backend(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;12345&lt;&#x2F;span&gt;&lt;span&gt;);  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Terminate a session&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 10 (2017)&lt;&#x2F;strong&gt; brought biggest expansion of predefined roles with,
adding four monitoring specific roles to improve database observability, and
support growing importance of database monitoring in production environments.&lt;&#x2F;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;-- Create the monitoring user&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE USER&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; postgres_exporter&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WITH PASSWORD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;monitoring_password&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Grant comprehensive monitoring access&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT&lt;&#x2F;span&gt;&lt;span&gt; pg_monitor &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; postgres_exporter;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Giving access to variety of metrics and settings. With single &lt;code&gt;GRANT&lt;&#x2F;code&gt; you
can access data:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;From pg_read_all_settings (inherited via pg_monitor) to &lt;code&gt;SELECT * FROM pg_settings&lt;&#x2F;code&gt; - Configuration parameters for alerting on misconfigurations
and configuration metrics like shared_buffers, work_mem, max_connections&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;From pg_read_all_stats (inherited via pg_monitor):&lt;&#x2F;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;-- Database-wide statistics (connections, transactions, blocks read&#x2F;written)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_database&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Current query activity and connection states&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_activity&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Replication lag and status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_replication&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Background writer performance&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_bgwriter&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Lock contention monitoring&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_locks&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;From pg_stat_scan_tables (inherited via pg_monitor):&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Enhanced table statistics collection with sequential scan information&lt;&#x2F;li&gt;
&lt;li&gt;More detailed I&#x2F;O statistics for table access patterns&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The &lt;code&gt;pg_monitor&lt;&#x2F;code&gt; role is particularly clever in its design. Rather than being a
single monolithic permission set, it&#x27;s actually a composite of the other three
roles. This allows for granular access when needed - you can grant
pg_read_all_stats for basic monitoring without including configuration access,
or grant the full pg_monitor bundle for comprehensive monitoring capabilities.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 11 (2018)&lt;&#x2F;strong&gt; laid the foundation for secure file system operations
with the introduction of three powerful roles that fundamentally changed how
PostgreSQL handles server-side file access:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_read_server_files&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_write_server_files&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_execute_server_program&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Addressing many long-standing issues with providing file system access, like ETL
processes that needs to read CSV files and data processing pipelines often involving
external programs.&lt;&#x2F;p&gt;
&lt;p&gt;The file system roles represented a significant security advancement. Before
PostgreSQL 11, operations like server-side COPY FROM file were superuser-only,
forcing administrators to either grant excessive privileges or find workarounds.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 14 (2021)&lt;&#x2F;strong&gt; introduced three significant roles that addressed different
data access patterns:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_read_all_data&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_write_all_data&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_database_owner&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The first two solved a common backup and analytics challenge. The special case is &lt;code&gt;pg_database_owner&lt;&#x2F;code&gt; which we will cover later.&lt;&#x2F;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;-- create ETL extraction user&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE USER&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; etl_extractor&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WITH PASSWORD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;extract_password&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT&lt;&#x2F;span&gt;&lt;span&gt; pg_read_all_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; etl_extractor;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- create ETL loader with write access&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE USER&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; etl_loader&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WITH PASSWORD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;loader_password&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT&lt;&#x2F;span&gt;&lt;span&gt; pg_write_all_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; etl_loader;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One caveat that comes with those ALL data roles is that they still (despite what the name might suggest) respect Row Level Security policies (RLS). This is a design choice of PostgreSQL which prioritises security by default, even for users with broad data access. The only way to avoid RLS is to explicitly grant the &lt;code&gt;BYPASSRLS&lt;&#x2F;code&gt; role attribute.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 15 (2022)&lt;&#x2F;strong&gt; expanded set with &lt;code&gt;pg_checkpoint&lt;&#x2F;code&gt;, reflecting the
reality that checkpoint operations are sometimes needed for maintenance but
don&#x27;t require full superuser privileges.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 16 (2023)&lt;&#x2F;strong&gt; expanded the list by introduction of&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg_use_reserved_connections&lt;&#x2F;code&gt; - resolving a critical problem with high-traffic
databases, where previously only superusers could access reserved connections.
Introduction of this roles expanded the pool of potential users who might be
able to resolve incidents or high-load situations. For example with &lt;code&gt;pg_signal_backend&lt;&#x2F;code&gt; grant.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pg_create_subscription&lt;&#x2F;code&gt; - with ability to create &lt;a href=&quot;&#x2F;guides&#x2F;mastering-logical-replication&#x2F;&quot;&gt;Logical Replication&lt;&#x2F;a&gt; subscriptions and confirming the place of PostgreSQL in the distributed database scenarios.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 17 (2024)&lt;&#x2F;strong&gt; finally saw &lt;code&gt;pg_maintain&lt;&#x2F;code&gt;, role that had a turbulent
development history (initially committed for PostgreSQL 16), allowing
non-superusers to perform crucial maintenance tasks like VACUUM, ANALYZE, REINDEX,
REFRESH MATERIALIZED VIEW, CLUSTER, and LOCK TABLE.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;pg_maintain&lt;&#x2F;code&gt; role is particularly valuable for:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Automated maintenance scripts that can now run with minimal privileges, reducing the security
footprint of scheduled maintenance operations.&lt;&#x2F;li&gt;
&lt;li&gt;Operational teams managing multiple databases who need consistent maintenance capabilities without
requiring superuser access to each database.&lt;&#x2F;li&gt;
&lt;li&gt;Cloud and managed environments where maintenance operations need to be delegated to operations staff without
granting broader system access.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL 18 (2025)&lt;&#x2F;strong&gt; continues the trend with &lt;code&gt;pg_signal_autovacuum_worker&lt;&#x2F;code&gt;
expanding the ability for non-superusers to signal autovacuum workers
specifically, enabling them to cancel vacuum operations on specific tables or
terminate autovacuum sessions that may be causing performance issues during
critical periods.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-all-data-actually-means&quot;&gt;What &quot;all data&quot; actually means&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-all-data-actually-means&quot; aria-label=&quot;Anchor link for: what-all-data-actually-means&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Per &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;postgres&#x2F;postgres&#x2F;commit&#x2F;6c3ffd697e22&quot;&gt;Stephen Frost&#x27;s introducing commit&lt;&#x2F;a&gt;, the design intent of &lt;code&gt;pg_read_all_data&lt;&#x2F;code&gt; is &lt;em&gt;&quot;running pg_dump as a non-superuser without explicitly granting access to all tables, schemas, et al.&quot;&lt;&#x2F;em&gt; — and the role&#x27;s reach follows from that, going beyond the &quot;tables, views, sequences&quot; the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;predefined-roles.html&quot;&gt;official documentation&lt;&#x2F;a&gt; calls out.&lt;&#x2F;p&gt;
&lt;p&gt;A complete &lt;code&gt;pg_dumpall&lt;&#x2F;code&gt; includes more than application data: stored password material for every role, foreign-server credentials, subscription connection strings, and the contents of large objects. The role permits reading all of it, regardless of the ordinary REVOKEs and view-layer masks PostgreSQL applies elsewhere. This is intentional, since each is something a complete dump genuinely needs to ship.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;&lt;code&gt;pg_read_all_data&lt;&#x2F;code&gt; is closer to a &quot;complete cluster dump&quot; role than a &quot;general read-only user&quot; role.&lt;&#x2F;strong&gt;&lt;br&#x2F;&gt;&lt;br&#x2F;&gt;
For analytics, BI, monitoring, or audit identities, narrower mechanisms (&lt;code&gt;pg_monitor&lt;&#x2F;code&gt;, explicit per-schema &lt;code&gt;GRANT SELECT&lt;&#x2F;code&gt;, application-specific roles) are usually what you actually want.
&lt;&#x2F;div&gt;
&lt;h2 id=&quot;the-magic-of-pg-database-owner&quot;&gt;The Magic of pg_database_owner&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-magic-of-pg-database-owner&quot; aria-label=&quot;Anchor link for: the-magic-of-pg-database-owner&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;If you paid attention, there&#x27;s one role that stands apart from all others -
&lt;code&gt;pg_database_owner&lt;&#x2F;code&gt;. While it does not provide any specific privileges that
wouldn&#x27;t be possible without superuser access, it serves as a marker for the
owner of the database. This role is crucial for maintaining database ownership
and ensuring that the database is managed by the correct user.&lt;&#x2F;p&gt;
&lt;p&gt;Before PostgreSQL 15 you would have &lt;code&gt;public&lt;&#x2F;code&gt; schema owned by &lt;code&gt;postgres&lt;&#x2F;code&gt; user.&lt;&#x2F;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;demo=&amp;gt; \dn&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  List of schemas&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Name  |  Owner&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------+----------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; public | postgres&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It came with the baggage of relaxed default permission, allowing every user
(PUBLIC) to create objects. Making this a maintenance nightmare.&lt;&#x2F;p&gt;
&lt;p&gt;Starting with PostgreSQL 15 and above, the &lt;code&gt;public&lt;&#x2F;code&gt; schema is owned by &lt;code&gt;pg_database_owner&lt;&#x2F;code&gt; role.&lt;&#x2F;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;demo=# \dn&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      List of schemas&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  Name  |       Owner&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--------+-------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; public | pg_database_owner&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Unlike any other roles, &lt;code&gt;pg_database_owner&lt;&#x2F;code&gt; has a unique behaviour - it
membership changes with the current database. As the code snippet above shows
the &quot;shape-shifting nature&quot; of this role fixed the &lt;code&gt;public&lt;&#x2F;code&gt; schema and
complexity of the permissions associated with it.&lt;&#x2F;p&gt;
&lt;p&gt;Another special behaviour is that the role itself does not come with any
permissions. Let that sink in - zero permissions. Its power only comes from what
you grant it or inherit. It opens up system for elaborate &lt;em&gt;template
inheritance&lt;&#x2F;em&gt;.&lt;&#x2F;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;-- define functionality in template1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\c template1 postgres&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE OR REPLACE FUNCTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; db_owner_stats&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; RETURN&lt;&#x2F;span&gt;&lt;span&gt; ...  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SECURITY&lt;&#x2F;span&gt;&lt;span&gt; DEFINER;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT EXECUTE ON FUNCTION&lt;&#x2F;span&gt;&lt;span&gt; db_owner_stats()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; TO&lt;&#x2F;span&gt;&lt;span&gt; pg_database_owner;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- and let it automatically apply to all new databases&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE DATABASE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; app_prod&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; OWNER&lt;&#x2F;span&gt;&lt;span&gt; first_app;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE DATABASE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; demo_prod&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; OWNER&lt;&#x2F;span&gt;&lt;span&gt; second_app;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- both first_app and second_app automatically inherit db_owner_stats function on their databases&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And when we are talking about magic, the special status of the role prevents
this role to be granted to any other role.&lt;&#x2F;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;demo&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt;#&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; GRANT&lt;&#x2F;span&gt;&lt;span&gt; pg_database_owner &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; labs_app;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ERROR:  &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;role&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;pg_database_owner&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt; cannot have &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;explicit&lt;&#x2F;span&gt;&lt;span&gt; members&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;a class=&quot;zola-anchor&quot; href=&quot;#conclusion&quot; aria-label=&quot;Anchor link for: conclusion&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Predefined roles transform PostgreSQL administration from ad-hoc permission
management into systematic capability delegation. They solve the fundamental
problem of needing operational access without operational trust.&lt;&#x2F;p&gt;
&lt;p&gt;The evolution continues - each PostgreSQL release adds new predefined roles
addressing real-world operational challenges. The pattern is established:
identify common pain points, create focused roles, eliminate the need for
superuser privileges in routine operations.&lt;&#x2F;p&gt;
&lt;p&gt;The next time you reach for superuser access to solve an operational problem,
check if PostgreSQL has already provided a predefined role for exactly that
purpose. You&#x27;ll likely find it already has one.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;em&gt;Update, 27 May 2026: Added &lt;a href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;posts&#x2F;postgresql-predefined-roles&#x2F;#what-all-data-actually-means&quot;&gt;What &quot;all data&quot; actually means&lt;&#x2F;a&gt; on &lt;code&gt;pg_read_all_data&lt;&#x2F;code&gt;&#x27;s reach beyond tables, views, and sequences.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Beyond the Basics of Logical Replication</title>
        <published>2025-06-24T07:10:00+00:00</published>
        <updated>2025-06-24T07:10:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/logical-replication-beyond-the-basics/"/>
        <id>https://boringsql.com/posts/logical-replication-beyond-the-basics/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/logical-replication-beyond-the-basics/">&lt;p&gt;With &lt;a href=&quot;&#x2F;posts&#x2F;logication-replication-introduction&#x2F;&quot;&gt;First Steps with Logical Replication&lt;&#x2F;a&gt; we set up a basic working replication between a publisher and a subscriber and were introduced to the fundamental concepts. This part covers the operational side: initial data copies, monitoring, evolving publications, and how logical decoding actually works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;initial-data-copy&quot;&gt;Initial Data Copy&lt;a class=&quot;zola-anchor&quot; href=&quot;#initial-data-copy&quot; aria-label=&quot;Anchor link for: initial-data-copy&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As we demonstrated in the first part, when setting up the subscriber, you can opt out of the initial data copy with &lt;code&gt;WITH (copy_data = false)&lt;&#x2F;code&gt;. The default copy is convenient, but it has characteristics you should understand before relying on it in a production environment.&lt;&#x2F;p&gt;
&lt;p&gt;The mechanism effectively asks the publisher to copy the table data by taking a snapshot (courtesy of MVCC), sending it to the subscriber, and thanks to the replication slot &quot;bookmark,&quot; seamlessly continues streaming the changes from the point the snapshot was taken.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Simplicity&lt;&#x2F;strong&gt; is the key feature here, as a single command handles the snapshot, transfer, and transition to ongoing streaming.&lt;&#x2F;p&gt;
&lt;p&gt;The trade-off is &lt;strong&gt;performance&lt;&#x2F;strong&gt;, because each table is copied by a single worker using &lt;code&gt;COPY&lt;&#x2F;code&gt;. Small tables finish quickly, but tables with gigabytes of data will take noticeably long.&lt;&#x2F;p&gt;
&lt;p&gt;Although parallelism can be controlled by the &lt;code&gt;max_sync_workers_per_subscription&lt;&#x2F;code&gt; configuration parameter, it still might leave you waiting for hours (and days) for any real-life database to get replicated. You can monitor whether the tables have already been synchronized or are still waiting&#x2F;in progress using the &lt;code&gt;pg_subscription_rel&lt;&#x2F;code&gt; catalog.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; srrelid::regclass &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; table_name, srsubstate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_subscription_rel;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each table will have one of the following states:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;i&lt;&#x2F;code&gt; not yet started&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;d&lt;&#x2F;code&gt; copy is in progress&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;f&lt;&#x2F;code&gt; finished the table copy&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;s&lt;&#x2F;code&gt; synchronized with the main apply stream&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;r&lt;&#x2F;code&gt; ready, replicating normally&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Tables reach &lt;code&gt;r&lt;&#x2F;code&gt; independently, so changes stream to already-synchronized tables even while others are still copying. Nevertheless, the &lt;strong&gt;replication slot retains the LSN position&lt;&#x2F;strong&gt;, which means that the publisher will &lt;strong&gt;retain WAL files&lt;&#x2F;strong&gt; until the subscriber has caught up.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;manual-synchronization-for-production-workloads&quot;&gt;Manual Synchronization for Production Workloads&lt;a class=&quot;zola-anchor&quot; href=&quot;#manual-synchronization-for-production-workloads&quot; aria-label=&quot;Anchor link for: manual-synchronization-for-production-workloads&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As mentioned above, the implicit copy might bring performance trade-offs that are simply too much for production use, unless you consider it while designing your logical replication topology. In all other scenarios, going with manual synchronization is the way forward.&lt;&#x2F;p&gt;
&lt;p&gt;The entire process has one leading principle: the data you load manually must be consistent with the point in time from which the logical replication stream begins. This is achieved by &lt;strong&gt;creating a logical replication slot before (or synchronized with) the point at which you restore the data (PITR)&lt;&#x2F;strong&gt; and enabling it once the data is transferred. Only this will allow you to correctly apply all subsequent changes on the subscriber.&lt;&#x2F;p&gt;
&lt;p&gt;There are several ways to achieve this, and you will have to evaluate the mechanism based on the available constraints in your particular use case:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Use the backup &amp;amp; restore mechanism that supports Point-in-Time Recovery (PITR) if the amount of data to be synchronized approaches the total size of the backed-up data (i.e., most of the tables).&lt;&#x2F;li&gt;
&lt;li&gt;Orchestrate the data change ingestion on the publisher with the backup, by stopping the incoming changes (for example, during a planned maintenance window) and only restoring them when a consistent snapshot is available to a known LSN. This is for cases where you can control table ingestion and the amount of data being transferred fits the maintenance window.&lt;&#x2F;li&gt;
&lt;li&gt;If none of this is available, you might be able to manually advance replication slots to a predefined LSN. This is a last resort; advancing slots by hand is easy to get wrong.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The recommended way for the most reliable backup and restore handling is the use of &lt;strong&gt;pgBackRest&lt;&#x2F;strong&gt;, as it allows you to restore a particular backup in advance and apply only changes needed later to advance to the selected Point-in-Time, hence significantly reducing the time required for the initial sync. On the other hand, if you are only using a small portion of the data within the backup on your subscriber, it might create resource constraints not acceptable for the setup.&lt;&#x2F;p&gt;
&lt;p&gt;While &lt;strong&gt;pg_dump can&#x27;t be considered a reliable backup tool&lt;&#x2F;strong&gt;, it can handle a consistent copy of a small subset of tables. The catch is where the snapshot comes from: a snapshot exported with &lt;code&gt;pg_export_snapshot()&lt;&#x2F;code&gt; is not synchronized with a replication slot&#x27;s start point. To get a slot-consistent snapshot, create the slot over a replication connection with &lt;code&gt;CREATE_REPLICATION_SLOT ... EXPORT_SNAPSHOT&lt;&#x2F;code&gt;, keep that session open, and pass the returned snapshot name to &lt;code&gt;pg_dump&lt;&#x2F;code&gt;:&lt;&#x2F;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;pg_dump -d publisher_db --snapshot=&amp;quot;00000004-000004A2-1&amp;quot; --data-only -Fc -f initial_data.dump&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The topic of consistent backups and Point-in-Time Recovery is beyond the scope of this guide.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;monitoring-logical-replication&quot;&gt;Monitoring Logical Replication&lt;a class=&quot;zola-anchor&quot; href=&quot;#monitoring-logical-replication&quot; aria-label=&quot;Anchor link for: monitoring-logical-replication&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Once you set up your publishers and subscribers and have the initial data in place, it&#x27;s time to start thinking about how to keep that process running.&lt;&#x2F;p&gt;
&lt;p&gt;For day-to-day operations, the basic building block for monitoring is the &lt;code&gt;pg_replication_slots&lt;&#x2F;code&gt; catalog. Sample query:&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    slot_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    plugin,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    slot_type,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    active,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; wal_retained_size&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_replication_slots&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    slot_type &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;logical&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;giving you a result like:&lt;&#x2F;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;-[ RECORD 1 ]-----+-----------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;slot_name         | my_sample_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;plugin            | pgoutput&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;slot_type         | logical&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;active            | t&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;wal_retained_size | 49 MB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You should look for two things:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;active&lt;&#x2F;code&gt; indicates whether the slot is being used by any connection. An inactive slot is acceptable for short durations, but a slot that stays inactive is a sign that the subscriber is down or not configured properly.&lt;&#x2F;li&gt;
&lt;li&gt;The computed &lt;code&gt;wal_retained_size&lt;&#x2F;code&gt; is a critical metric. An increasing value indicates problems consuming changes by the subscriber, and your cluster might be at risk of running out of disk space.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The &lt;code&gt;active&lt;&#x2F;code&gt; flag also catches dangling slots, where the subscriber has been destroyed (or simply wiped) without properly dropping the subscription, leaving the replication slot behind. In such cases, the only direct response is dropping the slot by its name:&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; pg_drop_replication_slot(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;my_dangling_slot&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If you want to dig deeper, you can also use &lt;code&gt;pg_stat_replication&lt;&#x2F;code&gt; that provides more (albeit volatile) data about the replication status. It can give you other metrics, like &lt;code&gt;replay_lag&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;To wrap up the monitoring part, you can either design your custom checks or use a modified version of the query above:&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  slot_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; active&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  END as&lt;&#x2F;span&gt;&lt;span&gt; active_status,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; wal_bytes_retained,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  extract(epoch &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;from now&lt;&#x2F;span&gt;&lt;span&gt;()) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as time&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_replication_slots&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; slot_type &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;logical&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;as part of a Grafana alerting rule:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;yaml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;Alert Rule&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;Inactive Replication Slot&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;Condition&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; Query&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; active_status&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; Reducer&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; last()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; Evaluator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; below 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; For&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; 30m&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;   # if inactive for 30+ minutes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt;Additional conditions (AND)&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; Query&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; wal_bytes_retained&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; Reducer&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; last()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  -&lt;&#x2F;span&gt;&lt;span style=&quot;color: #85E89D;&quot;&gt; Evaluator&lt;&#x2F;span&gt;&lt;span&gt;:&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; above 1073741824&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;   # 1GB limit&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Nevertheless, you need to evaluate the monitoring details applicable for your particular use case(s). There might also be other options, for example, if your architecture allows for it, to use &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;postgresqlco.nf&#x2F;doc&#x2F;en&#x2F;param&#x2F;max_slot_wal_keep_size&#x2F;&quot;&gt;max_slot_wal_keep_size&lt;&#x2F;a&gt; and mark the replication slot invalid and release the WAL files when a particular slot falls behind the configured value (for example, 100GB). While this might sound reasonable, you must consider whether your application or use case can recover from such data loss.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;evolving-publications&quot;&gt;Evolving Publications&lt;a class=&quot;zola-anchor&quot; href=&quot;#evolving-publications&quot; aria-label=&quot;Anchor link for: evolving-publications&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Going back to the first part of our journey into logical replication, there&#x27;s an important distinction to make. While we have described the initial use case, in real life your publisher schema will change as time goes by. Tables will be created and included implicitly (via &lt;code&gt;FOR ALL TABLES&lt;&#x2F;code&gt;) or explicitly added&#x2F;removed.&lt;&#x2F;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&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION my_publication &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; public&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;new_table&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION my_publication&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; DROP TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; public&lt;&#x2F;span&gt;&lt;span&gt;.old_table;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Adding a new table to the publication changes nothing for an existing subscription on its own. The only way for the subscriber to reflect the changes in the underlying configuration is to &lt;strong&gt;refresh the subscription&lt;&#x2F;strong&gt;. It&#x27;s a process where PostgreSQL compares the current subscription table list with the publication table list, (if configured to do so) starts the initial synchronization process and once finished goes to apply streamed changes.&lt;&#x2F;p&gt;
&lt;p&gt;As mentioned, you can refresh a subscription with or without copying the initial data. The &lt;code&gt;copy_data&lt;&#x2F;code&gt; option controls this behavior and is currently the only supported setting.&lt;&#x2F;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&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    REFRESH PUBLICATION;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    REFRESH PUBLICATION &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; (copy_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; false);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When you remove a table from the publisher, the situation is much simpler as the changes for that particular table are no longer considered for logical decoding.&lt;&#x2F;p&gt;
&lt;p&gt;But what happens if you add a new table to the publication without refreshing the subscription? Even though the WAL includes changes for all tables and logical decoding processes &lt;strong&gt;all published table changes&lt;&#x2F;strong&gt;, there is no additional WAL retention to consider. The subscriber advances &lt;code&gt;confirmed_flush_lsn&lt;&#x2F;code&gt; as before: the initial state for the newly added table has not yet been recorded, so its streamed changes are (for the moment) ignored.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;logical-decoding&quot;&gt;Logical Decoding&lt;a class=&quot;zola-anchor&quot; href=&quot;#logical-decoding&quot; aria-label=&quot;Anchor link for: logical-decoding&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;If you paid attention throughout our journey or already worked a bit with logical replication, you might have come across something called the &lt;code&gt;pgoutput&lt;&#x2F;code&gt; plugin. This is the built-in default mechanism responsible for logical decoding, the process that goes through the WAL stream and transforms it into a higher-level format.&lt;&#x2F;p&gt;
&lt;p&gt;Where physical replication ships &quot;byte X at offset Y, in page Z&quot;, logical decoding translates the WAL stream into row-level changes. It uses the catalog of the source database to map object identifiers to schema, table, and column names, along with the respective values (both old and new), and reassembles the changes into the correct transaction flow.&lt;&#x2F;p&gt;
&lt;p&gt;The plugin&#x27;s job is to format that output in a particular way and by itself is not aware of:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;any transport layer details&lt;&#x2F;li&gt;
&lt;li&gt;replication slots&lt;&#x2F;li&gt;
&lt;li&gt;any retry logic&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The built-in plugins are just a start, and developers can create custom output plugins. We can also look inside the decoded stream manually.&lt;&#x2F;p&gt;
&lt;p&gt;Consider this example:&lt;&#x2F;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;-- create new publication&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION all_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR&lt;&#x2F;span&gt;&lt;span&gt; ALL TABLES;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- create new replication slot with pgoutput plugin&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_create_logical_replication_slot(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;plugin_demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;pgoutput&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- sample table&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; IF&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT EXISTS&lt;&#x2F;span&gt;&lt;span&gt; demo_table (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name TEXT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- generate some changes with insert&#x2F;update&#x2F;delete&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; demo_table (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, email) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;John Doe&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;john@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;&#x2F;span&gt;&lt;span&gt; demo_table &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;doe@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; demo_table &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- get binary changes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_logical_slot_get_binary_changes(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;plugin_demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;proto_version&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;publication_names&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;all_data&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- drop the replication slot and the publication&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_drop_replication_slot(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;plugin_demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP TABLE&lt;&#x2F;span&gt;&lt;span&gt; demo_table;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION all_data;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Giving us a peek into the recently performed changes (output is shortened):&lt;&#x2F;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;    lsn       | xid  |                                                                 data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------+------+---------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D6997380 | 1038 | \x4200000001d69974180&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D6997380 | 1038 | \x52000042057075626c696300646...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D6997380 | 1038 | \x49000042054e000374000...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D6997448 | 1038 | \x430000000001d6997...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D6997448 | 1039 | \x4200000001d6997...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D6997448 | 1039 | \x49000042054e0....&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D6997510 | 1039 | \x430000000001d6...&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is correct, but won&#x27;t help us demonstrate the flow: &lt;code&gt;pgoutput&lt;&#x2F;code&gt; is the plugin used for actual replication, and its output is binary. PostgreSQL also ships the &lt;code&gt;test_decoding&lt;&#x2F;code&gt; plugin, which decodes the changes into a human-readable text representation.&lt;&#x2F;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;-- create replication slot with test_decoding plugin&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- (unlike pgoutput, test_decoding doesn&amp;#39;t use publications, so none is needed)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_create_logical_replication_slot(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;plugin_demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;test_decoding&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; IF&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; NOT EXISTS&lt;&#x2F;span&gt;&lt;span&gt; demo_table (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name TEXT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- generate some changes with insert&#x2F;update&#x2F;delete&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; demo_table (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, email) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;John Doe&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;john@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;&#x2F;span&gt;&lt;span&gt; demo_table &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;doe@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; demo_table &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- get human readable changes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_logical_slot_get_changes(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;plugin_demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- drop the replication slot and the table&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_drop_replication_slot(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;plugin_demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP TABLE&lt;&#x2F;span&gt;&lt;span&gt; demo_table;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This time the output is actually going to be helpful.&lt;&#x2F;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;    lsn       | xid  |                                                        data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;------------+------+-----------------------------------------------------------------------------------------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCC30 | 1048 | BEGIN 1048&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCC98 | 1048 | table public.demo_table: INSERT: id[integer]:1 name[text]:&amp;#39;John Doe&amp;#39; email[text]:&amp;#39;john@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCDC0 | 1048 | COMMIT 1048&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCDC0 | 1049 | BEGIN 1049&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCDC0 | 1049 | table public.demo_table: UPDATE: id[integer]:1 name[text]:&amp;#39;John Doe&amp;#39; email[text]:&amp;#39;doe@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCE50 | 1049 | COMMIT 1049&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCE50 | 1050 | BEGIN 1050&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCE50 | 1050 | table public.demo_table: DELETE: id[integer]:1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 1&#x2F;D69CCEC0 | 1050 | COMMIT 1050&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Just reading it, you can easily follow the sequence of the transaction (auto-commit in psql) and track individual changes.&lt;&#x2F;p&gt;
&lt;p&gt;We can also reiterate the importance of &lt;code&gt;REPLICA IDENTITY&lt;&#x2F;code&gt; with this example. The &lt;code&gt;id&lt;&#x2F;code&gt; column of &lt;code&gt;demo_table&lt;&#x2F;code&gt; is declared &lt;code&gt;PRIMARY KEY&lt;&#x2F;code&gt;, and the default replica identity uses exactly that to identify rows. Switching the table to &lt;code&gt;REPLICA IDENTITY FULL&lt;&#x2F;code&gt; shows, via &lt;code&gt;test_decoding&lt;&#x2F;code&gt;, how much more verbose the decoded stream becomes.&lt;&#x2F;p&gt;
&lt;p&gt;(This continues the previous example; setup and teardown omitted.)&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; demo_table &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REPLICA IDENTITY&lt;&#x2F;span&gt;&lt;span&gt; FULL;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; demo_table (id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, email) OVERRIDING &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SYSTEM VALUE VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;999&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;John Doe&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;john@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;&#x2F;span&gt;&lt;span&gt; demo_table &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; email &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;doe@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 999&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; demo_table &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 999&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_logical_slot_get_changes(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;plugin_demo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NULL&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And you can observe the changes. While by its nature &lt;code&gt;INSERT&lt;&#x2F;code&gt; gives you the same output (all values are being sent), &lt;code&gt;UPDATE&lt;&#x2F;code&gt; and &lt;code&gt;DELETE&lt;&#x2F;code&gt; are no longer relying on the primary key. Instead, they have to provide all values, for the &lt;code&gt;UPDATE&lt;&#x2F;code&gt; case both old and new data:&lt;&#x2F;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;lsn  | 1&#x2F;D69F8770&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;xid  | 1062&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;data | table public.demo_table: UPDATE: old-key: id[integer]:999 name[text]:&amp;#39;John Doe&amp;#39; email[text]:&amp;#39;john@example.com&amp;#39; new-tuple: id[integer]:999 name[text]:&amp;#39;John Doe&amp;#39; email[text]:&amp;#39;doe@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;and for &lt;code&gt;DELETE&lt;&#x2F;code&gt; the old data.&lt;&#x2F;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;lsn  | 1&#x2F;D69F8828&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;xid  | 1063&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;data | table public.demo_table: DELETE: id[integer]:999 name[text]:&amp;#39;John Doe&amp;#39; email[text]:&amp;#39;doe@example.com&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It&#x27;s obvious how a misconfigured replica identity increases the amount of data decoded and replicated.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;fine-grained-publication-control&quot;&gt;Fine-grained Publication Control&lt;a class=&quot;zola-anchor&quot; href=&quot;#fine-grained-publication-control&quot; aria-label=&quot;Anchor link for: fine-grained-publication-control&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Before we introduce the advanced replication topologies in later parts of this series, it&#x27;s worth knowing how precisely you can control what data reaches the subscribers. Filtering what you publish cuts replication overhead and network traffic, and keeps data the subscriber shouldn&#x27;t see out of the stream.&lt;&#x2F;p&gt;
&lt;p&gt;The first option to filter the data is by explicit &lt;code&gt;column_list&lt;&#x2F;code&gt;, allowing you to exclude sensitive (PII or similar) or unnecessary data. The column list must include the replica identity columns (typically the primary key) if the publication replicates &lt;code&gt;UPDATE&lt;&#x2F;code&gt; or &lt;code&gt;DELETE&lt;&#x2F;code&gt;; otherwise those operations fail on the publisher.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; PUBLICATION hr_analytics&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FOR TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; hr&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;employees&lt;&#x2F;span&gt;&lt;span&gt; (employee_id, first_name, last_name, department, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;start_date&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Similar to column selection, you can control what operations a publication replicates. The available options are &#x27;insert&#x27;, &#x27;update&#x27;, &#x27;delete&#x27;, and &#x27;truncate&#x27;.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; PUBLICATION hr_analytics&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    FOR TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; hr&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;employees&lt;&#x2F;span&gt;&lt;span&gt; (employee_id, first_name, last_name, department, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;start_date&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        WITH&lt;&#x2F;span&gt;&lt;span&gt; (publish &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;insert,delete&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;securing-logical-replication&quot;&gt;Securing Logical Replication&lt;a class=&quot;zola-anchor&quot; href=&quot;#securing-logical-replication&quot; aria-label=&quot;Anchor link for: securing-logical-replication&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Limiting what a publication carries only helps if access is limited too. A restricted publication means little when the replication role can read (or another publication can expose) the same data without restrictions. So far in this series, we have relied for simplicity on &lt;code&gt;SUPERUSER&lt;&#x2F;code&gt; access. To design logical replication for real-life production use, you need a least-privilege model.&lt;&#x2F;p&gt;
&lt;p&gt;The permissions split across two roles. Creating a publication requires the &lt;code&gt;CREATE&lt;&#x2F;code&gt; privilege on the database, adding a table to one requires owning that table, and &lt;code&gt;FOR ALL TABLES&lt;&#x2F;code&gt; publications require a superuser. The role a subscription connects as is a separate concern: it needs the &lt;code&gt;REPLICATION&lt;&#x2F;code&gt; attribute, &lt;code&gt;CONNECT&lt;&#x2F;code&gt; on the database, &lt;code&gt;USAGE&lt;&#x2F;code&gt; on the schema, and &lt;code&gt;SELECT&lt;&#x2F;code&gt; on the published tables (or columns) for the initial data copy.&lt;&#x2F;p&gt;
&lt;p&gt;Row-level security deserves explicit attention: if the replication role lacks &lt;code&gt;SUPERUSER&lt;&#x2F;code&gt; and &lt;code&gt;BYPASSRLS&lt;&#x2F;code&gt;, publisher row security policies can execute during the initial copy. If the role does not trust all table owners, add &lt;code&gt;options=-crow_security=off&lt;&#x2F;code&gt; to the connection string; replication then halts on tables with a row security policy instead of executing it.&lt;&#x2F;p&gt;
&lt;p&gt;Using the fine-grained example used above, we can demonstrate the setup. First, creating a role with &lt;code&gt;LOGIN&lt;&#x2F;code&gt; and &lt;code&gt;REPLICATION&lt;&#x2F;code&gt; clauses and granting database and schema access:&lt;&#x2F;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 ROLE&lt;&#x2F;span&gt;&lt;span&gt; hr_analytics_role &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH LOGIN&lt;&#x2F;span&gt;&lt;span&gt; REPLICATION &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;PASSWORD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;a_strong_password&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT CONNECT ON DATABASE&lt;&#x2F;span&gt;&lt;span&gt; my_publisher_db &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; hr_analytics_role;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;GRANT&lt;&#x2F;span&gt;&lt;span&gt; USAGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON SCHEMA&lt;&#x2F;span&gt;&lt;span&gt; hr &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; hr_analytics_role;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The crucial step is to correctly define the &lt;code&gt;GRANT&lt;&#x2F;code&gt; for selecting the data.&lt;&#x2F;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;GRANT SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (employee_id, first_name, last_name, department, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;start_date&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ON&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; hr&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;employees&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; TO&lt;&#x2F;span&gt;&lt;span&gt; hr_analytics_role;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This way, even if someone gains access to the replication role, they cannot read data beyond what was explicitly granted.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>First steps with Logical Replication in PostgreSQL</title>
        <published>2025-06-11T00:00:00+00:00</published>
        <updated>2025-06-11T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/logication-replication-introduction/"/>
        <id>https://boringsql.com/posts/logication-replication-introduction/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/logication-replication-introduction/">&lt;p&gt;PostgreSQL&#x27;s logical replication &lt;strong&gt;streams row-level changes&lt;&#x2F;strong&gt; from one PostgreSQL instance to another using a &lt;strong&gt;publish-subscribe&lt;&#x2F;strong&gt; model. Unlike physical replication, which copies the whole cluster byte for byte, it lets you pick what to replicate and where to send it, which makes it a building block for scaling out, distributing load, and integrating PostgreSQL with the rest of your architecture.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;physical-vs-logical-replication&quot;&gt;Physical vs. Logical Replication&lt;a class=&quot;zola-anchor&quot; href=&quot;#physical-vs-logical-replication&quot; aria-label=&quot;Anchor link for: physical-vs-logical-replication&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Both kinds of replication in PostgreSQL are built on top of the &lt;strong&gt;Write-Ahead Log (WAL)&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The WAL is a sequential, append-only log that records every change made to the cluster data. For durability purposes, all modifications are first written to the WAL before the modified data pages are flushed to disk. This allows PostgreSQL to recover from crashes by replaying logged changes.&lt;&#x2F;p&gt;
&lt;p&gt;Versioned changes, necessitated by concurrent transactions, are managed through &lt;strong&gt;Multi-Version Concurrency Control (MVCC)&lt;&#x2F;strong&gt;. Instead of overwriting data directly, MVCC creates multiple versions of rows, allowing each transaction to see a consistent snapshot of the database. It is the WAL that captures these versioned changes along with the transactional metadata to ensure data consistency at any given point in time.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Physical replication&lt;&#x2F;strong&gt; is built directly on the Write-Ahead Log. It enables streaming of the binary WAL data from the primary server to one or more standby servers (replicas), effectively creating a byte-for-byte copy of the entire cluster. This requirement makes the replicas read-only, making them ideal candidates for failover or scaling purposes.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Logical replication&lt;&#x2F;strong&gt;, while also built on top of the WAL data, takes a fundamentally different approach. Instead of streaming raw change data, it &lt;strong&gt;decodes the WAL into logical, row-level changes&lt;&#x2F;strong&gt; (&lt;code&gt;INSERT&lt;&#x2F;code&gt;, &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, and &lt;code&gt;DELETE&lt;&#x2F;code&gt;) and only then sends them to the subscribers using a publish-subscribe model. This allows selective replication and writable subscribers that are not strictly tied to a single publisher, at a cost: logical replication &lt;strong&gt;does not replicate DDL changes&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th&gt;Physical&lt;&#x2F;th&gt;&lt;th&gt;Logical&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Data Streamed&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;Binary WAL segments&lt;&#x2F;td&gt;&lt;td&gt;Row-level SQL changes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Scope&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;Byte-for-byte stream&lt;&#x2F;td&gt;&lt;td&gt;Selected tables&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Node Type&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;Read-only standby&lt;&#x2F;td&gt;&lt;td&gt;Fully writable instance&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;PostgreSQL Version&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;All servers must match major version&lt;&#x2F;td&gt;&lt;td&gt;Supports across versions&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Database Schema&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;Changes automatically replicated&lt;&#x2F;td&gt;&lt;td&gt;Changes must be applied on subscriber(s) separately&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Use Case&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;Failover, high-availability, and read scaling&lt;&#x2F;td&gt;&lt;td&gt;Integration, zero-downtime upgrades and schema migrations, complex topologies&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Physical replication is your go-to for &lt;strong&gt;high availability&lt;&#x2F;strong&gt; and &lt;strong&gt;disaster recovery&lt;&#x2F;strong&gt;, where you want a fast, exact copy of the entire database cluster that can take over in case of failure. It’s simple to set up and very efficient but limited in flexibility.&lt;&#x2F;p&gt;
&lt;p&gt;Logical replication fits when you need fine-grained control over what data is replicated, require writable replicas, or want to integrate PostgreSQL with other systems or versions. Typical uses are zero-downtime major-version upgrades, multi-region deployments, and feeding downstream systems such as a search index or a warehouse.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;setting-up-logical-replication&quot;&gt;Setting up Logical Replication&lt;a class=&quot;zola-anchor&quot; href=&quot;#setting-up-logical-replication&quot; aria-label=&quot;Anchor link for: setting-up-logical-replication&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Enough of boring theory for now. To get started, you will need two instances of PostgreSQL, whether you provision two virtual machines or two clusters running on the same computer. We will call one &lt;strong&gt;publisher&lt;&#x2F;strong&gt; and the other &lt;strong&gt;subscriber&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;preparing-the-publisher&quot;&gt;Preparing the Publisher&lt;a class=&quot;zola-anchor&quot; href=&quot;#preparing-the-publisher&quot; aria-label=&quot;Anchor link for: preparing-the-publisher&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;First, you need to prepare the publisher to emit the logical changes. You will need to modify &lt;code&gt;postgresql.conf&lt;&#x2F;code&gt; (or add particular &lt;code&gt;conf.d&lt;&#x2F;code&gt; configuration) with the following parameters:&lt;&#x2F;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_level = logical&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;max_replication_slots = 10&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;max_wal_senders = 10&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Your publisher also needs to be reachable by the subscriber, in most cases via a TCP socket.&lt;&#x2F;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;listen_addresses = &amp;#39;*&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Here, the configuration is:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;postgresqlco.nf&#x2F;doc&#x2F;en&#x2F;param&#x2F;wal_level&#x2F;&quot;&gt;&lt;code&gt;wal_level&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; set to &lt;code&gt;logical&lt;&#x2F;code&gt; is a crucial piece of the configuration. It tells PostgreSQL how much information to write to the WAL, in this case, to support logical decoding.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;postgresqlco.nf&#x2F;doc&#x2F;en&#x2F;param&#x2F;max_replication_slots&#x2F;&quot;&gt;&lt;code&gt;max_replication_slots&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; defines the maximum number of replication slots that can be created on the server. Each logical replication subscription needs its own slot.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;postgresqlco.nf&#x2F;doc&#x2F;en&#x2F;param&#x2F;max_wal_senders&#x2F;&quot;&gt;&lt;code&gt;max_wal_senders&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; should be set high enough to accommodate all expected concurrent replication connections from subscribers, and should be higher than or equal to &lt;code&gt;max_replication_slots&lt;&#x2F;code&gt;. Each active replication subscription consumes a wal_sender slot.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;You will also need to configure client authentication in &lt;code&gt;pg_hba.conf&lt;&#x2F;code&gt; to allow the subscriber to connect (for simplification, we allow all users). Note that the &lt;code&gt;replication&lt;&#x2F;code&gt; keyword in &lt;code&gt;pg_hba.conf&lt;&#x2F;code&gt; matches only physical replication connections; a logical replication subscriber connects to a regular database, so the entry must name that database (or &lt;code&gt;all&lt;&#x2F;code&gt;):&lt;&#x2F;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;# TYPE      DATABASE                    USER            ADDRESS                 METHOD&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;host        logical_demo_publisher      all             subscriber_ip&#x2F;32        scram-sha-256&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While for the purposes of the article we will assume the use of a superuser, making it convenient:&lt;&#x2F;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 USER&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; my_user_name&lt;&#x2F;span&gt;&lt;span&gt; SUPERUSER &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;PASSWORD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;my_secure_password&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It is recommended to use a dedicated replication user for real-life deployments:&lt;&#x2F;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 USER&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; replication_user&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WITH&lt;&#x2F;span&gt;&lt;span&gt; REPLICATION &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ENCRYPTED PASSWORD&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;my_secure_password&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Once configured, restart PostgreSQL for the configuration changes to take effect. After that, connect to the server and prepare the environment.&lt;&#x2F;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;-- example for psql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE DATABASE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; logical_demo_publisher&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\c logical_demo_publisher;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Create a sample table and seed initial data:&lt;&#x2F;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;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; products&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name TEXT NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    price &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DECIMAL&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    stock_quantity &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMP WITH TIME ZONE DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    updated_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMP WITH TIME ZONE DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    description TEXT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    is_active &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BOOLEAN DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; TRUE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- seed some data (10 records)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; products (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    price,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    stock_quantity,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    description&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    is_active&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;Product Batch &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; s&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS name&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CASE&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;s&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt; % &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        WHEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Electronics&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        WHEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Books&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        WHEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 2&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Home Goods&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        WHEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 3&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; THEN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Apparel&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Miscellaneous&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    END AS&lt;&#x2F;span&gt;&lt;span&gt; category,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    ROUND&lt;&#x2F;span&gt;&lt;span&gt;((RANDOM()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 500&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;numeric&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; price,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    FLOOR&lt;&#x2F;span&gt;&lt;span&gt;(RANDOM()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 200&lt;&#x2F;span&gt;&lt;span&gt;)::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;int AS&lt;&#x2F;span&gt;&lt;span&gt; stock_quantity,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;Auto-generated description for product ID &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; s&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;. Lorem ipsum dolor sit amet, consectetur adipiscing elit.&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS description&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;s&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;id&lt;&#x2F;span&gt;&lt;span&gt; % &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; &amp;lt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; is_active&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; generate_series&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; s(id);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The final step for the &lt;strong&gt;publisher&lt;&#x2F;strong&gt; is to create a publication to define what data we want to publish.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; PUBLICATION my_publication &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR TABLE&lt;&#x2F;span&gt;&lt;span&gt; products;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;preparing-the-subscriber&quot;&gt;Preparing the Subscriber&lt;a class=&quot;zola-anchor&quot; href=&quot;#preparing-the-subscriber&quot; aria-label=&quot;Anchor link for: preparing-the-subscriber&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Next, we will use our &lt;strong&gt;subscriber&lt;&#x2F;strong&gt; instance to receive the logical changes. There&#x27;s no need to make any configuration changes just for subscribing, as it&#x27;s not emitting changes itself.&lt;&#x2F;p&gt;
&lt;p&gt;Create the target database and schema. This step matters: logical replication does not create tables on the subscriber, so the target table must already exist and match the published one.&lt;&#x2F;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;-- example for psql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE DATABASE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; my_subscriber_db&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\c my_subscriber_db;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; products&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    name TEXT NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    category &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    price &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DECIMAL&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;10&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;NOT NULL&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    stock_quantity &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT DEFAULT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMP WITH TIME ZONE DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    updated_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMP WITH TIME ZONE DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; CURRENT_TIMESTAMP,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    description TEXT&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    is_active &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BOOLEAN DEFAULT&lt;&#x2F;span&gt;&lt;span&gt; TRUE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now we have the foundation for testing logical replication. The simplest way is to create a subscription using connection details and the name of the publication to subscribe to.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONNECTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;host=publisher_ip_address port=5432 user=your_replication_user password=my_secure_password dbname=logical_demo_publisher&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    PUBLICATION my_publication;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;my_subscription&lt;&#x2F;code&gt; should be a descriptive name for your subscription.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;CONNECTION&lt;&#x2F;code&gt; defines how to connect to the publisher (which is a regular connection string and could also be &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-service-definition&#x2F;&quot;&gt;a service&lt;&#x2F;a&gt;).&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;PUBLICATION&lt;&#x2F;code&gt; specifies the name of the publication you created earlier on the publisher.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If your connection was correct, the subscription will by default start with the initial data sync and listen for incoming changes. If you have used the queries above, you can validate that the data is now on the subscriber.&lt;&#x2F;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;#&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; products;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; count&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;---------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    10&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; row&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The number of records should match the number of records created using &lt;code&gt;generate_series&lt;&#x2F;code&gt; above (10 in our example). You can go ahead and insert a single row again on your publisher instance and validate the data being replicated to the subscriber.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s your first logical replication setup working.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;core-concepts-of-logical-replication&quot;&gt;Core Concepts of Logical Replication&lt;a class=&quot;zola-anchor&quot; href=&quot;#core-concepts-of-logical-replication&quot; aria-label=&quot;Anchor link for: core-concepts-of-logical-replication&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;With a working setup in place, here are the moving parts that made it work.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;publication&quot;&gt;Publication&lt;a class=&quot;zola-anchor&quot; href=&quot;#publication&quot; aria-label=&quot;Anchor link for: publication&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;As the name implies, &lt;strong&gt;a publication&lt;&#x2F;strong&gt; is where it all starts. It&#x27;s essentially a catalogue of data you offer from the publisher. You can &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;sql-createpublication.html&quot;&gt;publish&lt;&#x2F;a&gt; a number of objects:&lt;&#x2F;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;--- specific tables&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION my_publication &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR TABLE&lt;&#x2F;span&gt;&lt;span&gt; products, orders;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--- everything in your database (make sure you really want to do this)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION all_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR&lt;&#x2F;span&gt;&lt;span&gt; ALL TABLES;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--- replicate specific columns only (PostgreSQL 15 and higher)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION generic_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR TABLE&lt;&#x2F;span&gt;&lt;span&gt; products (id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, price);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--- filter published data (PostgreSQL 15 and higher)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION active_products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR TABLE&lt;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; (is_active &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; true);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--- filter different operations&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION my_publication &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR TABLE&lt;&#x2F;span&gt;&lt;span&gt; products, orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; (publish &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;insert&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--- or you can mix &amp;amp; match it to get exactly what you need&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; PUBLICATION eu_customers &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    TABLE&lt;&#x2F;span&gt;&lt;span&gt; customers (id, email, country, created_at)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        WHERE&lt;&#x2F;span&gt;&lt;span&gt; (country &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;DE&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;FR&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;IT&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;)),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    TABLE&lt;&#x2F;span&gt;&lt;span&gt; orders (id, customer_id, total_amount)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        WHERE&lt;&#x2F;span&gt;&lt;span&gt; (total_amount &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 100&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; (publish &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;insert, update&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;As you can see, logical replication really gives you quite a lot of options and is far from &lt;strong&gt;the rigid, byte-for-byte copying&lt;&#x2F;strong&gt; of physical replication. This is exactly the characteristic that allows you to build complex topologies.&lt;&#x2F;p&gt;
&lt;p&gt;Once you set up your publication(s), you can review it:&lt;&#x2F;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;--- in psql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\dRp&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\dRp+ my_publication&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--- using pg_catalog&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SELECT * FROM pg_publication_tables WHERE pubname = &amp;#39;my_publication&amp;#39;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;From these options, it&#x27;s easy to think of a publication as a customisable data feed.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;subscription&quot;&gt;Subscription&lt;a class=&quot;zola-anchor&quot; href=&quot;#subscription&quot; aria-label=&quot;Anchor link for: subscription&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The other side of logical replication is &lt;strong&gt;a subscription&lt;&#x2F;strong&gt;. It defines what and how to consume events from a publisher. You subscribe to a publication using connection details and a number of options.&lt;&#x2F;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;--- basic subscription with full connection detail&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONNECTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;host=publisher_host port=5432 user=repl_user password=secret dbname=source_db&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    PUBLICATION my_publication;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- subscription using service definition&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION realtime_only&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONNECTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;service=my_source_db&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    PUBLICATION my_publication;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The default behaviour of the subscription is to &lt;strong&gt;copy existing data&lt;&#x2F;strong&gt;, &lt;strong&gt;start immediately&lt;&#x2F;strong&gt;, and continue with streaming data changes. Once you start experimenting with logical replication, you can control that behaviour.&lt;&#x2F;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;--- subscribe without initial copy of the data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION streaming_only&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONNECTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;service=my_source_db&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    PUBLICATION my_publication&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WITH&lt;&#x2F;span&gt;&lt;span&gt; (copy_data &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; false);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--- or defer the start for later&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION manual_start&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    CONNECTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;service=my_source_db&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    PUBLICATION my_publication&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    WITH&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;enabled =&lt;&#x2F;span&gt;&lt;span&gt; false);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You can monitor your subscriptions:&lt;&#x2F;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;--- in psql&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\dRs&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;\dRs+ my_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;--- or their status using pg_catalog&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SELECT subname, received_lsn, latest_end_lsn, latest_end_time&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FROM pg_stat_subscription;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;replication-slot&quot;&gt;Replication Slot&lt;a class=&quot;zola-anchor&quot; href=&quot;#replication-slot&quot; aria-label=&quot;Anchor link for: replication-slot&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Publications and subscriptions establish the data flow, but there&#x27;s a critical piece missing: how does the publisher keep track of multiple subscribers reading the WAL at different speeds? &lt;strong&gt;Replication slots&lt;&#x2F;strong&gt; are the answer. They act as a persistent bookmark in the WAL stream that tracks exactly where each subscriber is (or was last time), &lt;strong&gt;ensuring no changes are lost&lt;&#x2F;strong&gt; even if the connection stops.&lt;&#x2F;p&gt;
&lt;p&gt;A replication slot can tell us quite a lot about itself.&lt;&#x2F;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;#&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_replication_slots;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;[ RECORD 1 ]&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-------+---------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;slot_name           | my_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;plugin              | pgoutput&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;slot_type           | logical&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;datoid              | &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;16390&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;database&lt;&#x2F;span&gt;&lt;span&gt;            | logical_demo_publisher&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;temporary           | f&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;active              | t&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;active_pid          | &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;3162&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;xmin                |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;catalog_xmin        | &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;777&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;restart_lsn         | &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&#x2F;&lt;&#x2F;span&gt;&lt;span&gt;1DEFBF0&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;confirmed_flush_lsn | &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&#x2F;&lt;&#x2F;span&gt;&lt;span&gt;1DEFC28&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;wal_status          | reserved&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;safe_wal_size       |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;two_phase           | f&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;inactive_since      |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;conflicting         | f&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;invalidation_reason |&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;failover&lt;&#x2F;span&gt;&lt;span&gt;            | f&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;synced              | f&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The most interesting attributes are:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;slot_name&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;plugin&lt;&#x2F;code&gt; used for logical replication&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;slot_type&lt;&#x2F;code&gt; confirming it&#x27;s for logical replication&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;active&lt;&#x2F;code&gt; indicates whether there&#x27;s a subscriber reading from this slot&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And most important of those being:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;restart_lsn&lt;&#x2F;code&gt; identifying the LSN where this slot &quot;holds&quot; WAL files from being released&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;confirmed_flush_lsn&lt;&#x2F;code&gt; being the last LSN position the subscriber confirmed it has successfully processed (i.e., applied).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;While we won&#x27;t go into details about WAL, it&#x27;s enough to say LSN (Log Sequence Number) is &lt;strong&gt;a unique address or position&lt;&#x2F;strong&gt; in the WAL that identifies the position in the stream of database changes.&lt;&#x2F;p&gt;
&lt;p&gt;In most cases, you &lt;strong&gt;don&#x27;t have to create replication slots manually&lt;&#x2F;strong&gt;, as subscriptions create and manage them. The persistent nature of the slots guarantees they survive the restart of both publisher and subscriber.&lt;&#x2F;p&gt;
&lt;p&gt;If the subscription is not actively consuming the changes, the publisher won&#x27;t release WAL files that contain changes a slot has not consumed. &lt;strong&gt;This prevents data loss, but can easily fill up disk if any of the subscribers fall too far behind.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You can monitor the WAL retention per each replication slot.&lt;&#x2F;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;#&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    slot_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    active,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; wal_retained&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_replication_slots;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;[ RECORD 1 ]&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;+&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--------------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;slot_name    | my_subscription&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;active       | t&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;wal_retained | &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;265&lt;&#x2F;span&gt;&lt;span&gt; MB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;replication-identity&quot;&gt;Replication Identity&lt;a class=&quot;zola-anchor&quot; href=&quot;#replication-identity&quot; aria-label=&quot;Anchor link for: replication-identity&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;As we have already covered, logical replication works with row-level changes, such as &lt;code&gt;INSERT&lt;&#x2F;code&gt;, &lt;code&gt;UPDATE&lt;&#x2F;code&gt;, and &lt;code&gt;DELETE&lt;&#x2F;code&gt;. And not all operations are equal. While &lt;code&gt;INSERT&lt;&#x2F;code&gt; is relatively straightforward (a new row is sent to the subscriber), for both &lt;code&gt;UPDATE&lt;&#x2F;code&gt; and &lt;code&gt;DELETE&lt;&#x2F;code&gt; operations, PostgreSQL needs to be able to uniquely identify the target row to modify on the subscriber.&lt;&#x2F;p&gt;
&lt;p&gt;This is managed by the &lt;code&gt;REPLICA IDENTITY&lt;&#x2F;code&gt; property of each published table. The default uses the table&#x27;s primary key (it has one, right?). You can also point it at another unique index with &lt;code&gt;USING INDEX&lt;&#x2F;code&gt;, or specify &lt;code&gt;FULL&lt;&#x2F;code&gt;, which sends all the old column values of the row to find the target row (and should generally be avoided). PostgreSQL never falls back to &lt;code&gt;FULL&lt;&#x2F;code&gt; on its own: without a primary key, the identity stays &lt;code&gt;DEFAULT&lt;&#x2F;code&gt; but records nothing, and any &lt;code&gt;UPDATE&lt;&#x2F;code&gt; or &lt;code&gt;DELETE&lt;&#x2F;code&gt; on the published table fails with an error until you set an identity explicitly.&lt;&#x2F;p&gt;
&lt;p&gt;In practice:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Prefer &lt;code&gt;REPLICA IDENTITY DEFAULT&lt;&#x2F;code&gt; (the primary key), the most efficient choice. If the table doesn&#x27;t have a primary key, consider adding one (a surrogate key is always an option).&lt;&#x2F;li&gt;
&lt;li&gt;Use &lt;code&gt;REPLICA IDENTITY USING INDEX index_name&lt;&#x2F;code&gt; if there is a better (or smaller) unique index matching the business logic or architecture of your database model.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;There are special cases of what not to do when it comes to replication identity, but we will touch on those cases in the advanced section of this guide.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;schema-changes&quot;&gt;Schema Changes&lt;a class=&quot;zola-anchor&quot; href=&quot;#schema-changes&quot; aria-label=&quot;Anchor link for: schema-changes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Although it was already mentioned, it&#x27;s vital to reiterate that logical replication, as it streams only row-level changes rather than full WAL segments, &lt;strong&gt;does not automatically replicate Data Definition Language (DDL) changes&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Any schema changes made on the publisher &lt;strong&gt;must be manually applied to all subscribers&lt;&#x2F;strong&gt; before data reflecting the change is replicated.&lt;&#x2F;p&gt;
&lt;p&gt;We will cover schema specifically in later parts of this guide, but to sum it up, this is the recommended order of operations when it comes to schema changes:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;(Optional) but recommended for breaking changes, pause replication where applicable.&lt;&#x2F;li&gt;
&lt;li&gt;Apply and verify DDL changes to the subscribers.&lt;&#x2F;li&gt;
&lt;li&gt;Apply and verify DDL changes on the publisher.&lt;&#x2F;li&gt;
&lt;li&gt;Resume the replication if applicable.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;While this might seem like a major drawback compared to physical replication, it&#x27;s the characteristic that gives us the most flexibility.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;other-considerations&quot;&gt;Other Considerations&lt;a class=&quot;zola-anchor&quot; href=&quot;#other-considerations&quot; aria-label=&quot;Anchor link for: other-considerations&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;As logical replication reliably handles row-level changes, it&#x27;s crucial to understand other specific limitations and behaviours. Specifically:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sequences&lt;&#x2F;strong&gt; are not replicated. While they are used to generate the value on the publisher, and their value advances there, the corresponding sequence (if it exists) on the subscriber won&#x27;t be updated.&lt;&#x2F;li&gt;
&lt;li&gt;Other database objects won&#x27;t be replicated. While it might be understandable for views, stored procedures, triggers, and rules, you also can&#x27;t rely on it for materialized views.&lt;&#x2F;li&gt;
&lt;li&gt;Special consideration (similar to schema changes) must be paid to user-defined data types. If a replicated table uses a user-defined type (e.g., a column using an &lt;code&gt;ENUM&lt;&#x2F;code&gt;), the type must already be available on the subscriber(s) and have exactly the same name and structure. As &lt;code&gt;ENUM&lt;&#x2F;code&gt;s are represented as ordered sets, even the order of the values matters.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;living-with-logical-replication&quot;&gt;Living with Logical Replication&lt;a class=&quot;zola-anchor&quot; href=&quot;#living-with-logical-replication&quot; aria-label=&quot;Anchor link for: living-with-logical-replication&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;In our previous example, we set up the publisher and subscriber, relied on the initial copy of the data, and let things run. Both of them will survive a restart, and thanks to the replication slot, they will keep going as soon as they come online.&lt;&#x2F;p&gt;
&lt;p&gt;But what if you need to perform maintenance or do regular things like a schema update on the subscriber or the publisher? Sometimes you might need to &lt;strong&gt;temporarily stop the replication&lt;&#x2F;strong&gt;. PostgreSQL makes this straightforward with subscription management, allowing you to stop consuming the changes.&lt;&#x2F;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&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DISABLE&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;As we hinted earlier, when disabled, the subscription stops consuming changes from the publisher, while keeping the replication slot. This means:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;No new changes are applied on the subscriber.&lt;&#x2F;li&gt;
&lt;li&gt;WAL files will start to accumulate on the publisher (since the replication slot holds its position).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;While disabled, you can&#x27;t really check the status from the subscriber, as only the replication slot has the data. You can use the query we mentioned above on the publisher to check the status.&lt;&#x2F;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;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    slot_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    active,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; wal_retained&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_replication_slots;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When ready, you can resume the subscription.&lt;&#x2F;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&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ENABLE&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;coordinating-schema-changes&quot;&gt;Coordinating Schema Changes&lt;a class=&quot;zola-anchor&quot; href=&quot;#coordinating-schema-changes&quot; aria-label=&quot;Anchor link for: coordinating-schema-changes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As we mentioned earlier, since logical replication does not automatically replicate DDL changes, you need to coordinate schema updates manually. Here&#x27;s a basic overview of how to perform such a change, step by step.&lt;&#x2F;p&gt;
&lt;p&gt;First, disable the replication on the subscriber and apply your schema changes.&lt;&#x2F;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;--- disable replication&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DISABLE&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;--- apply changes on the subscriber&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER TABLE&lt;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD&lt;&#x2F;span&gt;&lt;span&gt; COLUMN category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; products_by_category&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; products(category_id);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Only then can you apply the changes to the publisher.&lt;&#x2F;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;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD&lt;&#x2F;span&gt;&lt;span&gt; COLUMN category_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INTEGER&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;CREATE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; products_by_category&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; products(category_id);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And resume the replication.&lt;&#x2F;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&lt;&#x2F;span&gt;&lt;span&gt; SUBSCRIPTION my_subscription &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ENABLE&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If you applied the schema change to the publisher first, any new data using the new column would fail to replicate to the subscriber that doesn&#x27;t have the column yet.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;handling-incompatible-changes&quot;&gt;Handling Incompatible Changes&lt;a class=&quot;zola-anchor&quot; href=&quot;#handling-incompatible-changes&quot; aria-label=&quot;Anchor link for: handling-incompatible-changes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;PostgreSQL&#x27;s default conflict resolution is very simple. When a conflict occurs, &lt;strong&gt;logical replication stops&lt;&#x2F;strong&gt;, and the subscription enters an error state. For example, if you consider a product row already present on the subscriber, you will see something like this in the log files:&lt;&#x2F;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;ERROR: duplicate key value violates unique constraint &amp;quot;products_pkey&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DETAIL: Key (id)=(452) already exists.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;CONTEXT:  processing remote data for replication origin &amp;quot;pg_16444&amp;quot; during message type &amp;quot;INSERT&amp;quot; for replication target relation &amp;quot;public.products&amp;quot; in transaction 783, finished at 0&#x2F;1E020E8&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;LOG:  background worker &amp;quot;logical replication apply worker&amp;quot; (PID 457) exited with exit code 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;As you can see, logical replication stopped with an error code and won&#x27;t continue until you manually resolve the conflict. To do so, you need to identify the conflicting data and decide what to do with it.&lt;&#x2F;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;-- option 1: remove the conflicting data&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 452&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- option 2: update the data to match the expected state&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE&lt;&#x2F;span&gt;&lt;span&gt; products &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET name =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;New Product&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, price &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 29&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;99&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; WHERE&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 452&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Only after the conflict is resolved can the logical replication continue. This has been only a very simple example of a conflict that might arise during logical replication. Other examples might involve:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Data problems, constraint violations, or type mismatch&lt;&#x2F;li&gt;
&lt;li&gt;Schema conflicts (missing or renamed table&#x2F;column)&lt;&#x2F;li&gt;
&lt;li&gt;Permissions issues or row-level security&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In all those cases, you still need to go and fix the problem before logical replication can resume.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
