<?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 - sql</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/sql/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://boringsql.com"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-08-05T22:50:00+00:00</updated>
    <id>https://boringsql.com/tags/sql/atom.xml</id>
    <entry xml:lang="en">
        <title>The DISTINCT in your COUNT</title>
        <published>2026-08-05T22:50:00+00:00</published>
        <updated>2026-08-05T22:50:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/distinct-in-your-count/"/>
        <id>https://boringsql.com/posts/distinct-in-your-count/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/distinct-in-your-count/">&lt;p&gt;Here is a query that shows up in every analytics workload:&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: #F97583;&quot;&gt;DISTINCT&lt;&#x2F;span&gt;&lt;span&gt; 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;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It looks like the cheapest possible thing: count the distinct users. On a machine with cores to spare you would expect Postgres to throw a few parallel workers at it and be done. It does not. That one keyword, &lt;code&gt;DISTINCT&lt;&#x2F;code&gt;, switches off parallel query for the entire statement, and the larger your table the more it costs you. And to make it even worse there&#x27;s no setting or index changes that. The problem is in a way how aggregate has to execute.&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;Ten million events, about fifty thousand distinct users, a handful of countries. Nothing unusual.&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; events&lt;&#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&lt;&#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;int    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;    country &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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;    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;&#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; events (user_id, country, 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;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;50000&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 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&gt;       (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[&amp;#39;US&amp;#39;,&amp;#39;DE&amp;#39;,&amp;#39;GB&amp;#39;,&amp;#39;FR&amp;#39;,&amp;#39;JP&amp;#39;,&amp;#39;BR&amp;#39;,&amp;#39;IN&amp;#39;,&amp;#39;CA&amp;#39;])[(random()*7)::int + 1],&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: #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;10000000&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 events;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;max_parallel_workers_per_gather&lt;&#x2F;code&gt; is at its default of 2 on fresh cluster. For these examples I raised it to 4 and &lt;code&gt;work_mem&lt;&#x2F;code&gt; to 64MB, so there&#x27;s no resource starvation to blame for the plans below.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-counts-two-different-plans&quot;&gt;Two counts, two different plans&lt;a class=&quot;zola-anchor&quot; href=&quot;#two-counts-two-different-plans&quot; aria-label=&quot;Anchor link for: two-counts-two-different-plans&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Start with a plain &lt;code&gt;count(*)&lt;&#x2F;code&gt;, which has nothing to deduplicate:&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, 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; 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; events;&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; Finalize Aggregate (actual rows=1.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Gather (actual rows=5.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Workers Planned: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Workers Launched: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Partial Aggregate (actual rows=1.00 loops=5)&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 events (actual rows=2000000.00 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Four workers plus the leader (&lt;code&gt;loops=5&lt;&#x2F;code&gt;) each scan their slice and keep a running count, and the leader adds the five partial counts together at the end.&lt;&#x2F;p&gt;
&lt;p&gt;Now add one word:&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, COSTS &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;OFF&lt;&#x2F;span&gt;&lt;span&gt;, BUFFERS) &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: #F97583;&quot;&gt;DISTINCT&lt;&#x2F;span&gt;&lt;span&gt; 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;&#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; Aggregate (actual rows=1.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Buffers: shared hit=15915 read=47783, temp read=14681 written=14684&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Sort (actual rows=10000000.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Sort Key: user_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Sort Method: external merge  Disk: 117448kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Buffers: shared hit=15915 read=47783, temp read=14681 written=14684&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Seq Scan on events (actual rows=10000000.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Buffers: shared hit=15912 read=47783&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No &lt;code&gt;Gather&lt;&#x2F;code&gt;. No &lt;code&gt;Partial Aggregate&lt;&#x2F;code&gt;. No parallel scan. A single process reads all ten million rows, sorts every one of them by &lt;code&gt;user_id&lt;&#x2F;code&gt; so duplicates sit next to each other, then walks the sorted output counting the runs. The sort does not fit in 64MB of &lt;code&gt;work_mem&lt;&#x2F;code&gt;, so it spills 115MB to a temporary file on disk. One core, the whole table, plus disk IO that the parallel &lt;code&gt;count(*)&lt;&#x2F;code&gt; never touched.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-the-planner-can-t-split-it&quot;&gt;Why the planner can&#x27;t split it&lt;a class=&quot;zola-anchor&quot; href=&quot;#why-the-planner-can-t-split-it&quot; aria-label=&quot;Anchor link for: why-the-planner-can-t-split-it&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Parallel aggregation in Postgres works in two halves. Each worker runs a &lt;strong&gt;Partial Aggregate&lt;&#x2F;strong&gt; that builds &lt;em&gt;transition state&lt;&#x2F;em&gt;, a small running summary of the rows it has seen. For &lt;code&gt;count&lt;&#x2F;code&gt; that state is just a number. The leader then runs a &lt;strong&gt;Finalize Aggregate&lt;&#x2F;strong&gt; that merges those partial states with the aggregate&#x27;s &lt;em&gt;combine function&lt;&#x2F;em&gt;, the thing that knows how to fold two partial states into one. &lt;code&gt;count&lt;&#x2F;code&gt;&#x27;s combine function adds the partial counts. &lt;code&gt;sum&lt;&#x2F;code&gt;, &lt;code&gt;avg&lt;&#x2F;code&gt;, &lt;code&gt;min&lt;&#x2F;code&gt;, &lt;code&gt;max&lt;&#x2F;code&gt; all have one. Scan in parallel, combine at the end. That&#x27;s the whole logic behind parallel aggregation.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;count(DISTINCT user_id)&lt;&#x2F;code&gt; has no usable combine step, and not because nobody wrote one. Think about what a worker could hand back. To merge two workers&#x27; results into a correct global distinct count, the leader would need to know &lt;em&gt;which&lt;&#x2F;em&gt; users each worker saw, because a user that appears in worker 1&#x27;s slice and again in worker 2&#x27;s slice must be counted once, not twice. A partial count of distinct values cannot be combined. A hash table of groups can be merged across workers. While a distinct count, once it is a count, cannot.&lt;&#x2F;p&gt;
&lt;p&gt;You would have to ship the entire set of distinct values from every worker and union them. At which point you have moved all the data to one place anyway, which is exactly what parallel aggregation exists to avoid.&lt;&#x2F;p&gt;
&lt;p&gt;An aggregate carrying &lt;code&gt;DISTINCT&lt;&#x2F;code&gt; (or an inner &lt;code&gt;ORDER BY&lt;&#x2F;code&gt;) therefore cannot run in partial mode, the planner cannot place a Partial Aggregate under a Gather, and with no partial aggregate to feed, a parallel scan buys nothing. The whole plan falls back to serial.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;The check was run against PostgreSQL 17.10, 18.4, and 19beta1. Partial aggregation still does not cover distinct and ordered aggregates on any of them.&lt;&#x2F;div&gt;
&lt;p&gt;&lt;code&gt;debug_parallel_query&lt;&#x2F;code&gt; is a way to check this isn&#x27;t a cost estimate that happened to favor serial execution. Set to &lt;code&gt;on&lt;&#x2F;code&gt;, it makes the planner reach for a parallel plan wherever one is legal, even when the optimizer thinks serial is cheaper:&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; debug_parallel_query &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= 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;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; count&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DISTINCT&lt;&#x2F;span&gt;&lt;span&gt; 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;&#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; Gather&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Workers Planned: 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Single Copy: true&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Aggregate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Sort&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Sort Key: user_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 events&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A &lt;code&gt;Gather&lt;&#x2F;code&gt; shows up, but with &lt;code&gt;Workers Planned: 1&lt;&#x2F;code&gt; and &lt;code&gt;Single Copy: true&lt;&#x2F;code&gt;: one process runs the entire plan, sort included, and the Gather node only exists to route its output back through the executor&#x27;s parallel machinery. Nothing about the aggregate, the sort, or the scan actually splits across workers. That&#x27;s &lt;code&gt;debug_parallel_query&lt;&#x2F;code&gt; forcing parallel infrastructure onto a plan that has no partial aggregate to divide the work with, and finding nothing there for a second worker to do.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;FILTER&lt;&#x2F;code&gt; clause does not have this 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&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; 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&gt; country&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;US&amp;#39;&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; events;&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; 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: 4&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 events&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Same parallel shape as plain &lt;code&gt;count(*)&lt;&#x2F;code&gt;. &lt;code&gt;FILTER&lt;&#x2F;code&gt; just decides which rows each worker folds into its partial count. `&lt;&#x2F;p&gt;
&lt;h2 id=&quot;one-distinct-poisons-the-whole-statement&quot;&gt;One DISTINCT poisons the whole statement&lt;a class=&quot;zola-anchor&quot; href=&quot;#one-distinct-poisons-the-whole-statement&quot; aria-label=&quot;Anchor link for: one-distinct-poisons-the-whole-statement&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The cost is not scoped to the distinct aggregate. It is scoped to the aggregation node it shares a query block with. Put a perfectly parallelizable aggregate next to a distinct one in the same &lt;code&gt;SELECT&lt;&#x2F;code&gt; and &lt;em&gt;both&lt;&#x2F;em&gt; lose parallelism, thanks to the fact that one Aggregate node computes both and it can only run one way. An aggregate in a separate subquery or CTE is a different node and isn&#x27;t affected:&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;(amount), &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;DISTINCT&lt;&#x2F;span&gt;&lt;span&gt; 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;&#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; Aggregate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Sort&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Sort Key: user_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 events&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;sum(amount)&lt;&#x2F;code&gt; on its own would have run across four workers. Sharing a &lt;code&gt;SELECT&lt;&#x2F;code&gt; with one &lt;code&gt;count(DISTINCT)&lt;&#x2F;code&gt; drags it down to the same serial sort.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-rewrite-push-the-distinct-into-a-group-by&quot;&gt;The rewrite: push the DISTINCT into a GROUP BY&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-rewrite-push-the-distinct-into-a-group-by&quot; aria-label=&quot;Anchor link for: the-rewrite-push-the-distinct-into-a-group-by&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Do the deduplication with the one operation Postgres &lt;em&gt;can&lt;&#x2F;em&gt; parallelize, a &lt;code&gt;GROUP BY&lt;&#x2F;code&gt;, and count the groups afterward:&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: #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; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; 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;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; user_id) s;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;GROUP BY user_id&lt;&#x2F;code&gt; is exactly &quot;the distinct user_ids&quot;, and grouping has partial mode: each worker builds a partial hash of the groups it saw, and the leader merges those hashes. Counting how many groups came out is then trivial.&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, 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;&#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; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; 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;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; user_id) s;&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; Aggregate (actual rows=1.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Finalize HashAggregate (actual rows=50001.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Group Key: events.user_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Batches: 1  Memory Usage: 3097kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Gather (actual rows=250005.00 loops=1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Workers Planned: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Workers Launched: 4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               -&amp;gt;  Partial HashAggregate (actual rows=50001.00 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Group Key: events.user_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Batches: 1  Memory Usage: 3097kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Worker 0:  Batches: 1  Memory Usage: 3097kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Worker 1:  Batches: 1  Memory Usage: 3097kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Worker 2:  Batches: 1  Memory Usage: 3097kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Worker 3:  Batches: 1  Memory Usage: 3097kB&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 events (actual rows=2000000.00 loops=5)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The rewrite is parallel again: four workers each hash their slice down to the local set of users, and the leader merges those into the final 50,001 groups in memory, with no sort or disk spill.&lt;&#x2F;p&gt;
&lt;p&gt;The wall-clock difference on this 10M-row table, identical hardware and settings, median of three runs:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Query&lt;&#x2F;th&gt;&lt;th&gt;Plan&lt;&#x2F;th&gt;&lt;th&gt;Time&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;count(DISTINCT user_id)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;serial sort, 115MB to disk&lt;&#x2F;td&gt;&lt;td&gt;1211 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;count(*) FROM (… GROUP BY user_id)&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;parallel hash, in memory&lt;&#x2F;td&gt;&lt;td&gt;360 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;div class=&quot;sidenote&quot;&gt;If an approximate answer is acceptable, this is the problem HyperLogLog is meant to solve: its sketch is a small state that, in principle, has the combine function exact distinct counts lack, so partial sketches from workers should merge. The &lt;code&gt;postgresql-hll&lt;&#x2F;code&gt; extension builds on that idea, and it is often suggested for dashboard-style distinct counts at the price of a bounded error rate.&lt;&#x2F;div&gt;
Both return `50001`. The rewrite is about 3.4x faster here, and the gap should widen with the table: the serial sort&#x27;s cost grows with row count, while the parallel hash keeps adding throughput with each worker.
&lt;h2 id=&quot;order-by-aggregates-hit-the-same-wall&quot;&gt;ORDER BY aggregates hit the same wall&lt;a class=&quot;zola-anchor&quot; href=&quot;#order-by-aggregates-hit-the-same-wall&quot; aria-label=&quot;Anchor link for: order-by-aggregates-hit-the-same-wall&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The block is not specific to &lt;code&gt;DISTINCT&lt;&#x2F;code&gt;. Any aggregate that needs its input in a particular order, the ordered-set and ordered aggregates, fails to parallelize for the same reason, because a worker&#x27;s locally-ordered partial result cannot be merged without re-ordering across workers:&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; string_agg&lt;&#x2F;span&gt;&lt;span&gt;(country, &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; ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; country) &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;&#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; Aggregate&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Sort&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Sort Key: country&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Seq Scan on events&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;string_agg&lt;&#x2F;code&gt;, &lt;code&gt;array_agg&lt;&#x2F;code&gt;, &lt;code&gt;json_agg&lt;&#x2F;code&gt; with an inner &lt;code&gt;ORDER BY&lt;&#x2F;code&gt;, and &lt;code&gt;percentile_cont&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;percentile_disc&lt;&#x2F;code&gt; all land here. If you have an aggregate that insists on global order or global distinctness, assume it runs on one core until &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; tells you otherwise.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-harder-case-per-group-distinct-counts&quot;&gt;The harder case: per-group distinct counts&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-harder-case-per-group-distinct-counts&quot; aria-label=&quot;Anchor link for: the-harder-case-per-group-distinct-counts&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The clean rewrite above is for a single distinct count over the whole table. The per-group version of the same 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; country, &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;DISTINCT&lt;&#x2F;span&gt;&lt;span&gt; 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;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; country;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;is also serial (a &lt;code&gt;GroupAggregate&lt;&#x2F;code&gt; over a sort on &lt;code&gt;country, user_id&lt;&#x2F;code&gt;). The same idea applies, deduplicate first with a grouping the workers can split, then 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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; country, &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; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; country, 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;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; country, user_id) 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;GROUP BY&lt;&#x2F;span&gt;&lt;span&gt; country;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This &lt;em&gt;makes&lt;&#x2F;em&gt; the work parallelizable, but whether the planner actually picks the parallel path depends on cardinalities and cost. In my testing the overall-count rewrite parallelized reliably, while this stacked-grouping form sometimes stayed serial because the planner judged the two hash-aggregate layers cheap enough already. The rule is the same, push the distinctness into a &lt;code&gt;GROUP BY&lt;&#x2F;code&gt; first, but look at the &lt;code&gt;EXPLAIN&lt;&#x2F;code&gt; before you trust it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-to-actually-care&quot;&gt;When to actually care&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-to-actually-care&quot; aria-label=&quot;Anchor link for: when-to-actually-care&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;None of this actually matters on a small table. If the scan is a few thousand rows, serial is instant and the rewrite only adds noise. The distinct-aggregate penalty is a function of how many rows the single core has to sort, so it shows up exactly where it hurts Large fact tables or nightly rollups that runs on single core while rest sit idle.&lt;&#x2F;p&gt;
&lt;p&gt;The tell in &lt;code&gt;EXPLAIN (ANALYZE)&lt;&#x2F;code&gt; is unmistakable once you know it: a top-level &lt;code&gt;Aggregate&lt;&#x2F;code&gt; with no &lt;code&gt;Gather&lt;&#x2F;code&gt; beneath it, a big &lt;code&gt;Sort&lt;&#x2F;code&gt; with an &lt;code&gt;external merge ... Disk:&lt;&#x2F;code&gt; line, and a single &lt;code&gt;loops=1&lt;&#x2F;code&gt; scan of the whole table. If you see that shape above a distinct or ordered aggregate on a table that matters, it&#x27;s worth the rewrite.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <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 that important. 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 informs the report, but only the measured actuals (buffers, spills, cardinality error) can fail the build.&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 reports &quot;found something&quot; loses credibility over time, so RegreSQL will also report &quot;I can&#x27;t tell you that from this data&quot; when the evidence doesn&#x27;t support a verdict - that&#x27;s a valid result 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, with nothing else scanning the table, 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.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;&quot;Same order every time&quot; holds because the scan starts at block zero and walks forward. Postgres has one wrinkle that can break even that: &lt;code&gt;synchronize_seqscan&lt;&#x2F;code&gt; (on by default). When a scan of a table larger than &lt;code&gt;shared_buffers &#x2F; 4&lt;&#x2F;code&gt; is already in progress, a second scan starts at &lt;em&gt;its&lt;&#x2F;em&gt; current block to share cached pages, then wraps around at the end. So a concurrent reader can hand a serial scan a different starting point, and the float sum moves with it, no writes and no parallelism required. It is the same order-dependence, entering through a different door.&lt;&#x2F;div&gt;
 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;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #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 near enough 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 - the serial sum was already wrong. What parallelism removed was the serial sum&#x27;s one comforting property: getting &lt;em&gt;the same wrong answer every time&lt;&#x2F;em&gt;, which people often 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 it usually does help, but 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 for display, but compare against a tolerance rather than by rounding, since two totals a hair apart round to different neighbours as readily as to the same one, 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. The result is still wrong, just consistently wrong. 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;
&lt;p&gt;PS: Thanks to Peter J. Holzer for spotting that the cast to &lt;code&gt;numeric&lt;&#x2F;code&gt; rounds on the way in, and that rounding is the wrong way to compare floats.&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>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, trading flexibility for catching breakage early.&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>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>The hidden cost of PostgreSQL arrays</title>
        <published>2026-01-12T20:50:00+00:00</published>
        <updated>2026-01-12T20:50:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/good-bad-arrays/"/>
        <id>https://boringsql.com/posts/good-bad-arrays/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/good-bad-arrays/">&lt;p&gt;Starting with arrays in PostgreSQL is as simple as declaring a column as &lt;code&gt;integer[]&lt;&#x2F;code&gt;, inserting some values, and you are done.&lt;&#x2F;p&gt;
&lt;p&gt;Or building the array on the fly.&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;{1,2,3}&amp;#39;&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;SELECT array&lt;&#x2F;span&gt;&lt;span&gt;[1,2,3];&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;  int4&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,2,3}&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;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  array&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,2,3}&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;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;arrays.html&quot;&gt;official documentation&lt;&#x2F;a&gt; provides a good introduction. But beneath this straightforward interface lies a set of more complex properties than most of us realise. Arrays in PostgreSQL are not just &quot;lists&quot; in a field. They have their own memory management strategy, their own index logic, and a lot of edge-case scenarios.&lt;&#x2F;p&gt;
&lt;p&gt;As it goes with &lt;strong&gt;boringSQL&lt;&#x2F;strong&gt; deep-dives, this article will explore the corners of array functionality that might break your production.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-document-model-temptation&quot;&gt;The document model temptation&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-document-model-temptation&quot; aria-label=&quot;Anchor link for: the-document-model-temptation&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Wait? Are we going to talk about JSONB arrays? Not at all. The whole concept of arrays in RDBMSs is actually &lt;strong&gt;document storage in disguise&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;In database design, locality ensures faster retrieval times by keeping related data close on physical storage.&lt;&#x2F;span&gt;Whether you use a distinct &lt;code&gt;integer[]&lt;&#x2F;code&gt; type or a JSON list &lt;code&gt;[1, 2, 3]&lt;&#x2F;code&gt;, you are making the exact same architectural decision: you are &lt;strong&gt;prioritising locality over normalisation&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;When you store &lt;code&gt;tag_ids&lt;&#x2F;code&gt; in an array, you are embedding related data directly into a row - just like a NoSQL database might embed subdocuments. This is not inherently wrong. Document databases exist for good reasons: they eliminate joins, simplify reads, and map naturally to application objects.&lt;&#x2F;p&gt;
&lt;p&gt;But PostgreSQL is a relational database. It was designed around the relational model, where:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;foreign keys&lt;&#x2F;strong&gt; enforce &lt;a href=&quot;&#x2F;posts&#x2F;text-identifier-in-db-design&#x2F;&quot;&gt;referential integrity&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;joins&lt;&#x2F;strong&gt; connect normalised tables&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;updates&lt;&#x2F;strong&gt; modify individual rows, not entire lists&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Arrays give you document-model convenience, but you lose relational promises. There are no foreign keys and no &lt;code&gt;ON DELETE referential_action&lt;&#x2F;code&gt; (like CASCADE) for array elements. If you delete a &lt;code&gt;tags&lt;&#x2F;code&gt; entry, the orphaned ID will remain in your array forever.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The rule of thumb&lt;&#x2F;strong&gt; is that if you find yourself in need of referential integrity - you most likely want a link table, not an array. Arrays are for data that shares the same lifecycle as the parent row. Not for relationships spanning across different tables.&lt;&#x2F;p&gt;
&lt;p&gt;A practical example is the author of a blog post (one author might write multiple other posts), whereas a whitelist of IP addresses for a service account is only applicable to the given entity.&lt;&#x2F;p&gt;
&lt;p&gt;With JSONB being so flexible, you might wonder why we still bother with quirkier native types. The answer lies in the &#x27;boring&#x27; part of the database: predictability and efficiency. An &lt;code&gt;integer[]&lt;&#x2F;code&gt; column guarantees that every element is an integer — similar to how &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-enums&#x2F;&quot;&gt;enums&lt;&#x2F;a&gt; enforce a fixed set of allowed values.&lt;&#x2F;p&gt;
&lt;p&gt;Arrays are also more storage-efficient for primitives because they don&#x27;t carry the metadata overhead of JSON objects.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-syntax-gotchas&quot;&gt;The syntax gotchas&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-syntax-gotchas&quot; aria-label=&quot;Anchor link for: the-syntax-gotchas&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;This post assumes basic knowledge of arrays. We won&#x27;t cover the basics.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;arrays-don-t-have-to-start-at-1&quot;&gt;Arrays don&#x27;t have to start at 1&lt;a class=&quot;zola-anchor&quot; href=&quot;#arrays-don-t-have-to-start-at-1&quot; aria-label=&quot;Anchor link for: arrays-don-t-have-to-start-at-1&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;By default, SQL arrays start at 1. And seemingly, there is nothing wrong with iterating through them in a fashion similar 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;FOR&lt;&#x2F;span&gt;&lt;span&gt; i &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;IN&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 1&lt;&#x2F;span&gt;&lt;span&gt; .. array_length(fruits, &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;LOOP&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    RAISE NOTICE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Index % contains: %&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, i, fruits[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;END LOOP&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 until you find an array with the arbitrary bounds. Which PostgreSQL allows.&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;[-5:-3]={10,20,30}&amp;#39;&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;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;To make sure you iterate correctly through any given array, always use &lt;code&gt;array_lower()&lt;&#x2F;code&gt; and &lt;code&gt;array_upper()&lt;&#x2F;code&gt; in PL&#x2F;pgSQL&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; array_lower(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[-5:-3]={10,20,30}&amp;#39;&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;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; array_lower&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;          -5&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;or &lt;code&gt;generate_subscripts()&lt;&#x2F;code&gt; in SQL.&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; generate_subscripts(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[-5:-3]={10,20,30}&amp;#39;&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;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; generate_subscripts&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;                  -5&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                  -4&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;(3 rows)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;missing-dimensions&quot;&gt;Missing dimensions&lt;a class=&quot;zola-anchor&quot; href=&quot;#missing-dimensions&quot; aria-label=&quot;Anchor link for: missing-dimensions&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;When creating a table, you might expect strict typing. That&#x27;s true for everything — except the array dimensions. You might think &lt;code&gt;integer[][]&lt;&#x2F;code&gt; enforces a 2D matrix. Except it does not. The &lt;code&gt;[]&lt;&#x2F;code&gt; syntax is effectively syntactic sugar. PostgreSQL does not enforce the number of dimensions of sub-arrays at the schema level at all by default.&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; dimension_test&lt;&#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;    matrix &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;);&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; dimension_test &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;{{1,2}, {3,4}}&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;-- this is not going to fail&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; dimension_test &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;{1,2,3}&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;-- 3D matrix works too&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; dimension_test &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;{{{1,2},{3,4}}, {{5,6},{7,8}}}&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 enforce a specific array dimension, you cannot rely on the type definition. Instead, you must use a &lt;code&gt;CHECK&lt;&#x2F;code&gt; constraint.&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; strict_matrix&lt;&#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;    -- dims: ensure it&amp;#39;s 2-Dimensional&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;    -- array_length: make sure it&amp;#39;s exactly 3x3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    board &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;span style=&quot;color: #F97583;&quot;&gt; CHECK&lt;&#x2F;span&gt;&lt;span&gt; (array_ndims(board) &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; AND&lt;&#x2F;span&gt;&lt;span&gt; array_length(board, &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;=&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&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;INSERT INTO strict_matrix VALUES (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ARRAY[&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        [0, 1, 0],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        [1, 1, 0],&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        [0, 0, 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;&#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 only exception is that PostgreSQL enforces uniformity of arrays on every nesting level. This means it rejects sub-arrays with different sizes.&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; dimension_test &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;{{1,2}, {3}}&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;ERROR:  malformed &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;array&lt;&#x2F;span&gt;&lt;span&gt; literal: &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;quot;{{1,2}, {3}}&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;LINE&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;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; dimension_test &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;{{1,2}, {3}}&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;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DETAIL:  Multidimensional arrays must have sub&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;arrays &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;with&lt;&#x2F;span&gt;&lt;span&gt; matching dimensions.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;slicing-arrays&quot;&gt;Slicing arrays&lt;a class=&quot;zola-anchor&quot; href=&quot;#slicing-arrays&quot; aria-label=&quot;Anchor link for: slicing-arrays&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;When accessing array values, it&#x27;s important to consider that the syntax &lt;code&gt;[1]&lt;&#x2F;code&gt; and &lt;code&gt;[1:1]&lt;&#x2F;code&gt; are different. While the first one is an accessor, the second one acts like a constructor.&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; matrix[1][1]&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; from&lt;&#x2F;span&gt;&lt;span&gt; dimension_test ;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; matrix&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;      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: #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;When slicing an array, even if the slice is a single-element, it will be returned as a single-element array, not a scalar 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; matrix[1:1][1:1], matrix[1][1:1], matrix[1:1][1]&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; from&lt;&#x2F;span&gt;&lt;span&gt; dimension_test ;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; matrix | matrix | matrix&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&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;}}  | {{&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&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;h3 id=&quot;the-ugly&quot;&gt;The ugly&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-ugly&quot; aria-label=&quot;Anchor link for: the-ugly&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Accessing array values has a forgiving behaviour, making it more difficult to find underlying bugs:&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;-- out-of-bound access returns 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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[1,2,3])[10];&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; array&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&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;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;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #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;-- out of bounds slicing returns an empty array&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;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[1,2,3])[5:10];&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; array&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&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;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;But the single most confusing aspect that might trip you up coming from other programming languages is the fact that PostgreSQL treats multi-dimensional arrays as a single matrix, not an array of arrays.&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;-- wrong dimensionality &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;ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[[1,2],[3,4]])[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; array&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&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;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;While in other languages you expect &lt;code&gt;{1,2}&lt;&#x2F;code&gt; as a result, PostgreSQL can&#x27;t give it to you. It tries to return the first cell and fails (because the index is not complete).&lt;&#x2F;p&gt;
&lt;p&gt;And to paraphrase Fletcher&#x27;s &quot;Double Bubble&quot; analogy (which also went very wrong — extra points for getting the reference), you can&#x27;t fix this by using slice notation.&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; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{{1,2},{3,4}}&amp;#39;&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;[])[1:1];&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  int4&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&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;&#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 only way to solve this puzzle is to &lt;code&gt;unnest&lt;&#x2F;code&gt; the slice and re-aggregate the 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 style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; array_agg(val) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; unnest((&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{{1,2},{3,4}}&amp;#39;&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;[])[1:1]) val;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; array_agg&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&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;&#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;div class=&quot;callout&quot;&gt;
Just be aware that `array_agg` does not guarantee the order of aggregated elements unless you use an `ORDER BY` clause. While it usually works with simple `unnest` queries, relying on implicit ordering can be risky.
&lt;&#x2F;div&gt;
&lt;p&gt;The other alternative is to cast it to JSONB and back. Not pretty no matter how you look at it. If you need to work with complex multi-dimensional structures where each sub-array has independent meaning, just use JSONB. It will do exactly what you expect.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;indexing-arrays&quot;&gt;Indexing arrays&lt;a class=&quot;zola-anchor&quot; href=&quot;#indexing-arrays&quot; aria-label=&quot;Anchor link for: indexing-arrays&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While you can use a B-tree index on an array column, it won&#x27;t help you unless you are looking for whole-array equality and sorting an array by dictionary rules — and even on regular columns, &lt;a href=&quot;&#x2F;posts&#x2F;why-postgresql-indexes-are-ignored&#x2F;&quot;&gt;PostgreSQL can ignore your indexes&lt;&#x2F;a&gt; when it decides a sequential scan is cheaper.&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;-- which is bigger? B is correct&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- A: {1, 1000, 1000}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- B: {2, 0}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Rendering B-tree indexes useless for any real-world index operations.&lt;&#x2F;p&gt;
&lt;p&gt;When working with arrays, you actually need &lt;strong&gt;GIN&lt;&#x2F;strong&gt; (Generalized Inverted Index). If a B-tree index is a phone book, GIN is an index at the back of the book. To query one or more elements, you need to find all possible locations and then intersect them to find the locations that match.&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; posts_by_tags&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; posts &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; GIN (tags);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;GIN indexes are designed for set operations, making presence the key feature, while ignoring order. Here are operators that GIN provides for arrays:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Containment&lt;&#x2F;strong&gt; &lt;code&gt;@&amp;gt;&lt;&#x2F;code&gt; - matches rows that include ALL of the selected items.&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;-- match ALL tags&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;tags @&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;{urgent, bug}&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Overlap&lt;&#x2F;strong&gt; &lt;code&gt;&amp;amp;&amp;amp;&lt;&#x2F;code&gt; - does the row include ANY of the selected items.&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;-- match bug or feature&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;tags &amp;amp;&amp;amp; &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{bug, feature}&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;There&#x27;s also &lt;code&gt;&amp;lt;@&lt;&#x2F;code&gt; and &lt;code&gt;=&lt;&#x2F;code&gt; (equality), but they should be easy to understand.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;two-sides-of-any&quot;&gt;Two sides of &lt;code&gt;ANY&lt;&#x2F;code&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#two-sides-of-any&quot; aria-label=&quot;Anchor link for: two-sides-of-any&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;This is where it gets interesting. While you might often hear (myself included) that you shouldn&#x27;t use dynamic SQL for &lt;code&gt;IN&lt;&#x2F;code&gt; lists and should use &lt;code&gt;ANY&lt;&#x2F;code&gt; instead, there are some dangers you need to be aware of.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;ANY&lt;&#x2F;code&gt; operator behaves very differently depending on which side of the comparison the array sits.&lt;&#x2F;p&gt;
&lt;p&gt;The advice to use &lt;code&gt;ANY&lt;&#x2F;code&gt; holds true when you are &lt;strong&gt;passing lists into the database&lt;&#x2F;strong&gt;. Instead of generating a query with 100 distinct parameters (&lt;code&gt;WHERE id IN ($1, $2, ... $100)&lt;&#x2F;code&gt;), which bloats your query cache and forces hard-parses, you should pass a single array parameter.&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;-- good: one parameter, one query plan&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; users &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: #F97583;&quot;&gt;int&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 trap is assuming this syntax is equally efficient when querying array columns. It is not. If you use &lt;code&gt;ANY&lt;&#x2F;code&gt; to check if a value exists inside a table column, you are effectively asking the database to loop.&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;-- bad: GIN does not support ANY; turning this into a seq 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;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; tickets &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;feature&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; ANY(tags);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When you write &lt;code&gt;WHERE &#x27;feature&#x27; = ANY(tags)&lt;&#x2F;code&gt;, you are not actually using an array operator. What you wrote is a scalar integer equality operator &lt;code&gt;=&lt;&#x2F;code&gt; applied inside a loop construct. Since the scalar operator &lt;code&gt;=&lt;&#x2F;code&gt; is not part of &lt;code&gt;array_ops&lt;&#x2F;code&gt;, the planner assumes the index cannot help and falls back to a sequential scan.&lt;&#x2F;p&gt;
&lt;p&gt;The correct way to rewrite the query 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;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- good again&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; tickets &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; tags @&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt; ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[&amp;#39;feature&amp;#39;];&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;fast-updates-and-the-trade-off&quot;&gt;Fast updates and the trade-off&lt;a class=&quot;zola-anchor&quot; href=&quot;#fast-updates-and-the-trade-off&quot; aria-label=&quot;Anchor link for: fast-updates-and-the-trade-off&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Because a GIN index is built to work with sets, it is expensive to maintain. With a B-tree index, one row equals one index entry. In a GIN index, one row equals N index entries, where N is the number of elements in your array.&lt;&#x2F;p&gt;
&lt;p&gt;This leads to write multiplication. To prevent this, PostgreSQL defaults to using a &quot;fast update&quot; mechanism. This is a strategy where new entries are added to a pending list (an unsorted temporary buffer) and only merged into the main index structure later (during VACUUM).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;Given the unsorted nature of a GIN index, it is an exception to &lt;a href=&quot;&#x2F;posts&#x2F;vacuum-is-lie&#x2F;&quot;&gt;VACUUM is a Lie&lt;&#x2F;a&gt;, as VACUUM actually does perform structural maintenance here.&lt;&#x2F;span&gt;While this makes INSERT operations manageable, it can slow down SELECTs. Every time you query the index, PostgreSQL must scan the organised main index plus the entire messy pending list. If that list grows large, your query performance might degrade.&lt;&#x2F;p&gt;
&lt;p&gt;If you operate a read-heavy workflow with infrequent writes, you should disable this to guarantee consistent reading performance.&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; posts_by_tags&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; posts &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; GIN (tags) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WITH&lt;&#x2F;span&gt;&lt;span&gt; (fastupdate &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;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;storage-and-modification&quot;&gt;Storage and modification&lt;a class=&quot;zola-anchor&quot; href=&quot;#storage-and-modification&quot; aria-label=&quot;Anchor link for: storage-and-modification&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Now it&#x27;s time to return to &lt;a href=&quot;https:&#x2F;&#x2F;boringsql.com&#x2F;posts&#x2F;good-bad-arrays&#x2F;#the-document-model-temptation&quot;&gt;the document model&lt;&#x2F;a&gt;. In PostgreSQL, rows are immutable (MVCC); there is no such thing as an &quot;in-place update&quot;, and arrays are stored as atomic values. The consequence: &lt;strong&gt;to modify a single element of an array, PostgreSQL must copy and rewrite the entire row&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;UPDATE&lt;&#x2F;span&gt;&lt;span&gt; user_activity&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; event_ids &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; event_ids &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 10001&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; 50&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;Every single append rewrites the entire array, which in effect results in a rewrite of the entire row.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;toasted&quot;&gt;TOASTed&lt;a class=&quot;zola-anchor&quot; href=&quot;#toasted&quot; aria-label=&quot;Anchor link for: toasted&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;When any array grows large enough (&amp;gt; 2 KB — see below), PostgreSQL moves it automatically to a separate storage area using &lt;a href=&quot;&#x2F;posts&#x2F;postgresql-toast&#x2F;&quot;&gt;TOAST&lt;&#x2F;a&gt;. While this keeps the data row lean, it turns array updates into a severe performance bottleneck.&lt;&#x2F;p&gt;
&lt;p&gt;The difference this introduces is subtle but comes with a big impact. While a standard MVCC update simply copies the row version on the main heap, updating a TOASTed array forces PostgreSQL to fetch all external chunks, decompress the entire object into memory, apply the change, and then recompress and write the new full-size blob back to the TOAST table. This turns a simple modification into a CPU and I&#x2F;O-intensive operation that rewrites the entire dataset rather than just the delta.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;span class=&quot;sidenote&quot;&gt;The threshold is derived from &lt;code&gt;TOAST_TUPLES_PER_PAGE&lt;&#x2F;code&gt; (default: 4), ensuring 4 tuples fit on a page.&lt;&#x2F;br&gt;&lt;&#x2F;br&gt;Threshold: ~2 KB&lt;&#x2F;span&gt;Where does the 2 KB value come from? The TOAST threshold is calculated to ensure at least four tuples can fit on a single &lt;a href=&quot;&#x2F;posts&#x2F;inside-the-8kb-page&#x2F;&quot;&gt;8 KB heap page&lt;&#x2F;a&gt;. PostgreSQL uses this to balance efficiency against the overhead of TOAST indirection.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-compressions&quot;&gt;The compressions&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-compressions&quot; aria-label=&quot;Anchor link for: the-compressions&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Before version 14, PostgreSQL relied on &lt;code&gt;pglz&lt;&#x2F;code&gt; — an algorithm prioritising compression ratio over speed. This made the &quot;decompress-modify-compress&quot; cycle of TOAST painful.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 14 introduced LZ4 as an alternative:&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; articles &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; COLUMN tags &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET COMPRESSION&lt;&#x2F;span&gt;&lt;span&gt; lz4;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;LZ4 is significantly faster for both compression and decompression, with only slightly lower compression ratios. If you are working with large arrays, switching to LZ4 is one of the easiest ways to reduce the CPU penalty of TOAST.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;when-a-large-array-might-make-sense&quot;&gt;When a large array might make sense&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-a-large-array-might-make-sense&quot; aria-label=&quot;Anchor link for: when-a-large-array-might-make-sense&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;You might have gained the impression that arrays are bad. When evaluating the use of arrays, the real &lt;strong&gt;question isn&#x27;t how big the array is&lt;&#x2F;strong&gt; but rather &lt;strong&gt;how often do you modify it&lt;&#x2F;strong&gt;? An array of 10,000 elements that you write once and is read-only for the rest of its lifecycle is a completely valid use case. An array of 50 elements that you append to on every incoming request is the real villain here.&lt;&#x2F;p&gt;
&lt;p&gt;If you combine this with compression, you might get an interesting mix.&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;DROP TABLE IF EXISTS&lt;&#x2F;span&gt;&lt;span&gt; compression_test;&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; compression_test&lt;&#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;    compressed_floats float4[], &lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    raw_floats float4[]&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;-- do not compress raw_floats&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; compression_test &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ALTER&lt;&#x2F;span&gt;&lt;span&gt; COLUMN raw_floats &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; STORAGE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;EXTERNAL&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 semi-random data with low cardinality&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; compression_test (compressed_floats, raw_floats)&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; semi_random_arr, semi_random_arr&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 ARRAY&lt;&#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; 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; 50&lt;&#x2F;span&gt;&lt;span&gt;)::float4 &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;span&gt;    ) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; semi_random_arr&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; generator;&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; &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;Compressed (EXTENDED)&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; strategy,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_size_pretty(pg_column_size(compressed_floats)::&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; size_on_disk&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; compression_test&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&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;Raw (EXTERNAL)&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;    pg_size_pretty(pg_column_size(raw_floats)::&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;&#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; compression_test;&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;       strategy        | size_on_disk&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; Compressed (EXTENDED) | 15 kB&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Raw (EXTERNAL)        | 39 kB&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;h2 id=&quot;bulk-loading-with-arrays&quot;&gt;Bulk loading with arrays&lt;a class=&quot;zola-anchor&quot; href=&quot;#bulk-loading-with-arrays&quot; aria-label=&quot;Anchor link for: bulk-loading-with-arrays&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Up until now, it might seem that arrays don&#x27;t actually bring many benefits. While they can have rough edges around storage, they are incredibly useful for transport.&lt;&#x2F;p&gt;
&lt;p&gt;For inserting 5,000 rows, &lt;code&gt;unnest&lt;&#x2F;code&gt; beats both an application-side loop and a massive &lt;code&gt;VALUES (...), (...)&lt;&#x2F;code&gt; string.&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; measurements (sensor_id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;value&lt;&#x2F;span&gt;&lt;span&gt;, captured_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 * FROM&lt;&#x2F;span&gt;&lt;span&gt; unnest(&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: #F97583;&quot;&gt;int&lt;&#x2F;span&gt;&lt;span&gt;[],        &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- array of sensor IDs&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;2&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;float&lt;&#x2F;span&gt;&lt;span&gt;[],      &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- array of 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: #79B8FF;&quot;&gt;3&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;span style=&quot;color: #6A737D;&quot;&gt; -- array of timestamps&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;All that is needed is one network round trip, and one query to parse and plan. PostgreSQL handles the arrays row by row internally for you. This works both for UPSERTs and &lt;a href=&quot;&#x2F;posts&#x2F;beyond-upserts-with-merge&#x2F;&quot;&gt;MERGE&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-cases-for-special-arrays&quot;&gt;The cases for special arrays&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-cases-for-special-arrays&quot; aria-label=&quot;Anchor link for: the-cases-for-special-arrays&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Standard PostgreSQL arrays are polymorphic types (&lt;code&gt;anyarray&lt;&#x2F;code&gt;). This provides a powerful feature that allows a single function definition to operate on many different data types. They have to handle integers, strings, timestamps, and custom types equally well. But if you have specific data types, you can unlock significant performance gains by using specialised extensions.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-intarray-extension&quot;&gt;The &lt;code&gt;intarray&lt;&#x2F;code&gt; extension&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-intarray-extension&quot; aria-label=&quot;Anchor link for: the-intarray-extension&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;If you are dealing exclusively with 4-byte integers (&lt;code&gt;int4&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;integer&lt;&#x2F;code&gt;), the built-in array operations are leaving performance on the table. The &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;intarray.html&quot;&gt;intarray&lt;&#x2F;a&gt; extension provides specialised functions and index operators that are significantly faster than the generic implementation.&lt;&#x2F;p&gt;
&lt;p&gt;To use it, you must explicitly enable 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: #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; intarray;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The difference in developer ergonomics is immediate. To sort an array in standard SQL, you are forced to unnest, order, and re-aggregate. With intarray, you get native functions like sort() and uniq().&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;-- standard arrays&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; array_agg(val &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; val)&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; unnest(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{3, 1, 2}&amp;#39;&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;[]) val;&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;-- intarray&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; sort(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{3, 1, 2}&amp;#39;&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;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Beyond raw management functions, it introduces a specialised query syntax that simplifies complex boolean logic. Instead of chaining multiple overlap (&lt;code&gt;&amp;amp;&amp;amp;&lt;&#x2F;code&gt;) and containment (&lt;code&gt;@&amp;gt;&lt;&#x2F;code&gt;) checks, you can express your requirements in a single &quot;query string&quot; using the &lt;code&gt;@@&lt;&#x2F;code&gt; operator.&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;-- standard arrays&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; staff&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; available_days @&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;{1}&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;          -- must include Mon&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; available_days &amp;amp;&amp;amp; &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;{6, 7}&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;       -- must include Sat OR Sun&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&lt;&#x2F;span&gt;&lt;span&gt; (available_days @&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;{2}&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- must NOT include Tue&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;-- intarray&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; staff&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; available_days @@ &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 &amp;amp; (6 | 7) &amp;amp; !2&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 only catch is the type restriction. &lt;code&gt;intarray&lt;&#x2F;code&gt; is strictly limited to signed 32-bit integers. If your values exceed 2 billion, you are back where you started.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;ai-with-pgvector&quot;&gt;AI with &lt;code&gt;pgvector&lt;&#x2F;code&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#ai-with-pgvector&quot; aria-label=&quot;Anchor link for: ai-with-pgvector&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;You cannot talk about arrays in 2026 without mentioning &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;pgvector&#x2F;pgvector&quot;&gt;pgvector&lt;&#x2F;a&gt;. While it markets itself as a &quot;vector store&quot;, internally it is simply an array of floats with a different mathematical focus.&lt;&#x2F;p&gt;
&lt;p&gt;Standard arrays are &lt;strong&gt;binary&lt;&#x2F;strong&gt;: they care about Exact Matches (Overlap &lt;code&gt;&amp;amp;&amp;amp;&lt;&#x2F;code&gt;, Containment &lt;code&gt;@&amp;gt;&lt;&#x2F;code&gt;). Vectors are all about &lt;strong&gt;fuzzy distance&lt;&#x2F;strong&gt; (Cosine &lt;code&gt;&amp;lt;=&amp;gt;&lt;&#x2F;code&gt;, Euclidean &lt;code&gt;&amp;lt;-&amp;gt;&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;If you are building search or recommendation features, pgvector allows you to treat your array column not as a list of &quot;facts&quot; (tag A, tag B), but as coordinates in semantic space.&lt;&#x2F;p&gt;
&lt;p&gt;Nevertheless, the architectural decision is exactly the same as using a standard array: you are trading strict structure for convenience. Since there is no way to &quot;join&quot; two rows based on how similar they are, you store the vector directly on the row. You accept a larger table size in exchange for the ability to ask, &quot;What is close to this?&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;PS: Thanks to Matthias Feist for inspiring this article.&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 - the database now rejects the overlap outright instead of merely making it rare. 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, and it will save you a debugging session later.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Time to Better Know The Time in PostgreSQL</title>
        <published>2025-04-06T00:00:00+00:00</published>
        <updated>2025-04-06T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/know-the-time-in-postgresql/"/>
        <id>https://boringsql.com/posts/know-the-time-in-postgresql/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/know-the-time-in-postgresql/">&lt;p&gt;To honor the name of the site (boringSQL) let&#x27;s deep dive into a topic which might sound obvious, but it might be never ending source of surprises and misunderstanding.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;simple-things&quot;&gt;Simple things&lt;a class=&quot;zola-anchor&quot; href=&quot;#simple-things&quot; aria-label=&quot;Anchor link for: simple-things&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;We can start with the simple statement 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;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-03-30 00:30&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; t;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       t&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;2025-03-30 00:30&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;which gives you (and I do hope that&#x27;s not a big surprise) a simple text literal, rather than anything to do with date and time. As soon as you use the string literal in queries similar 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;SELECT * FROM&lt;&#x2F;span&gt;&lt;span&gt; events &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; start_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-03-30 00:30&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;PostgreSQL will implicitly convert the string into timestamp without time zone. The reason why it can happen is the fact &lt;code&gt;start_at&lt;&#x2F;code&gt; is most likely timestamp based field, allowing automatic cast to match the column&#x27;s data type. Which is unlike the 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 style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-03-30 01:00&amp;#39;&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;15 minutes&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;ERROR:  invalid input syntax for type interval: &amp;quot;2025-03-30 01:00&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;which will simply not work, as PostgreSQL can&#x27;t perform automatic cast (which as we will cover later might be surprising behavior, but that&#x27;s how things are). The correct way to get this example query to work is to use either one of two ways (both functionally equivalent but different notation).&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;-- PostgreSQL cast notation&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-03-30 01:00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp -&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;15 minutes&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;-- SQL standard explicit type notation&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 timestamp&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-03-03 01:00&amp;#39;&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;15 minutes&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;postgresql-timestamp-vs-timestamptz&quot;&gt;PostgreSQL timestamp vs timestamptz&lt;a class=&quot;zola-anchor&quot; href=&quot;#postgresql-timestamp-vs-timestamptz&quot; aria-label=&quot;Anchor link for: postgresql-timestamp-vs-timestamptz&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The next possible source of confusion when working with time in PostgreSQL is presence of two distinct data types:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;timestamp&lt;&#x2F;code&gt; (or &lt;code&gt;timestamp without time zone&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;timestamptz&lt;&#x2F;code&gt; (or &lt;code&gt;timestamp with time zone&lt;&#x2F;code&gt;)
Despite what the names suggest, &lt;strong&gt;the key difference isn&#x27;t whether they store timezone information&lt;&#x2F;strong&gt;, but rather how they handle it during storage and retrieval.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;blockquote&gt;
&lt;p&gt;IMPORTANT: before we cover the details, remember to always use &lt;code&gt;timestamptz&lt;&#x2F;code&gt;. As official &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;wiki.postgresql.org&#x2F;wiki&#x2F;Don%27t_Do_This&quot;&gt;Don&#x27;t Do This&lt;&#x2F;a&gt; page shows, using &lt;code&gt;timestamp&lt;&#x2F;code&gt; is like storing picture of the clock, instead of point in time. And while this article might use &lt;code&gt;timestamp&lt;&#x2F;code&gt; it&#x27;s purely for demonstration purposes.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;All you need to remember is the fact &lt;code&gt;timestamp&lt;&#x2F;code&gt; &lt;strong&gt;stores the exact datetime value as entered&lt;&#x2F;strong&gt; without any timezone context or adjustments. It&#x27;s essentially a snapshot of a calendar date and wall clock time, disconnected from any particular geographic location.&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 stores what you provided&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-03-30 01:30&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      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;span&gt;2025-03-30 01:30:00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On the other hand &lt;code&gt;timestamptz&lt;&#x2F;code&gt; &lt;strong&gt;normalizes all input to UTC internally&lt;&#x2F;strong&gt; and then converts values to the session&#x27;s timezone for display. This provides geographic context to your datetime 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;-- this converts your input to UTC based on your session timezone&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-03-30 01:30&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;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      timestamptz&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;2025-03-30 01:30:00+01&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And this is where potential confusion might start. Let&#x27;s take 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;-- With UTC timezone&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; timezone &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;UTC&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;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-03-30 01:30&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;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      timestamptz&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; 2025-03-30 01:30:00+00&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;-- With Tokyo timezone&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; timezone &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;Asia&#x2F;Tokyo&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;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-03-30 01:30&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;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      timestamptz&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; 2025-03-30 01:30:00+09&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;which gives you correct representation based on the session timezone. This comes in handy when we add the actual storage.&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 table and view with Berlin timezone&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; timezone &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;Europe&#x2F;Berlin&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;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; time_flies&lt;&#x2F;span&gt;&lt;span&gt;(moment &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp with time zone&lt;&#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; time_flies &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;2025-03-30 00:30&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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; moment &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; time_flies;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         moment&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; 2025-03-30 00:30:00+01&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;-- View the same data with New York timezone&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; timezone &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;America&#x2F;New_York&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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; moment &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; time_flies;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         moment&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; 2025-03-29 19:30:00-04&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This demonstrates the advantage of using automatic time zone conversion, allowing you to avoid not only time-zone bugs, but also DST related issues.
While most of us might be fast asleep during our local DST changes, it&#x27;s no fun to account for the time difference in a wrong. Using &lt;code&gt;timestamptz&lt;&#x2F;code&gt; is sure way how to avoid it. Let&#x27;s take an example of recent (at the time of writing the article) DST 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;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Set Berlin timezone (during DST change)&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; timezone &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;Europe&#x2F;Berlin&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;-- Using timestamp (without timezone awareness)&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-03-30 01:30&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp +&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;45 minutes&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; end_time;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      end_time&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; 2025-03-30 02:15:00&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;-- Using timestamptz (with timezone awareness)&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-03-30 01:30&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; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;45 minutes&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; end_time;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        end_time&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; 2025-03-30 03:15:00+02&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;at-time-zone&quot;&gt;AT TIME ZONE&lt;a class=&quot;zola-anchor&quot; href=&quot;#at-time-zone&quot; aria-label=&quot;Anchor link for: at-time-zone&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Another powerful but potentially confusing operator in PostgreSQL is &lt;code&gt;AT TIME ZONE&lt;&#x2F;code&gt; which allows you to convert timestamps between different time zones, but its behavior differs depending on the input data type.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;When applied to timestamp (without time zone)&lt;&#x2F;strong&gt;
When you apply AT TIME ZONE to a timestamp, PostgreSQL &lt;strong&gt;interprets the input as being in the specified time zone&lt;&#x2F;strong&gt; and converts it to a timestamptz:&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;-- First, set UTC timezone&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; timezone&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;UTC&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;-- Interpret &amp;#39;2025-03-30 01:30&amp;#39; as if it were in the &amp;#39;Europe&#x2F;Paris&amp;#39; time zone&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-03-30 01:30&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp AT TIME ZONE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Europe&#x2F;Paris&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        timezone&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; 2025-03-30 00:30:00+00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;What it does is effectively &quot;Take this wall clock time, consider it as being in the specified time zone, and give me the corresponding moment in time (as a timestamptz)&quot;. The representation here is based on session time zone settings.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;When applied to timestamptz (with time zone)&lt;&#x2F;strong&gt;
Conversely, when you apply AT TIME ZONE to a timestamptz, PostgreSQL &lt;strong&gt;converts the timestamp to the specified time zone and returns a timestamp without time zone&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: #6A737D;&quot;&gt;-- Set timezone&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; timezone&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;America&#x2F;Port_of_Spain&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;-- Convert the timestamptz to how it would appear on wall clocks in Tokyo&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-03-30 01:30+01:00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz AT TIME ZONE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Asia&#x2F;Tokyo&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        timezone&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; 2025-03-30 09:30:00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This operation says: &quot;Take this moment in time and show me what wall clock time it would be in the specified time zone.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Practical usage&lt;&#x2F;strong&gt;
Probably the only use when correctly using &lt;code&gt;timestamps&lt;&#x2F;code&gt; is to provide &quot;wall clock&quot; representation of the times at various time zones at once for (view) representational purposes.&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 time is the company-wide meeting in various offices?&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;2025-03-30 15:00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz AS&lt;&#x2F;span&gt;&lt;span&gt; meeting_time_utc,&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-03-30 15:00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz AT TIME ZONE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Europe&#x2F;London&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; london_office_time,&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-03-30 15:00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz AT TIME ZONE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Asia&#x2F;Tokyo&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; tokyo_office_time;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    meeting_time_utc    | london_office_time  |  tokyo_office_time&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; 2025-03-30 15:00:00+02 | 2025-03-30 14:00:00 | 2025-03-30 22:00:00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It&#x27;s important to note that in API based clients, it&#x27;s always better to represent full time notation and leave the clients with the representational layer. Similar to that, unless you need to work with multiple time zones, and you depend on the correct local time representation it&#x27;s always better to use local session &lt;code&gt;timezone&lt;&#x2F;code&gt; setting instead of using &lt;code&gt;AT TIME ZONE&lt;&#x2F;code&gt; operator. This applies for both time input and output.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Common mistake&lt;&#x2F;strong&gt;
Let&#x27;s consider the example where you might consider &quot;magically casting&quot; already stored time using &lt;code&gt;AT TIME ZONE&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;-- Setup example&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; timezone&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;Europe&#x2F;Berlin&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;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; different_moments&lt;&#x2F;span&gt;&lt;span&gt;(moment1 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz&lt;&#x2F;span&gt;&lt;span&gt;, moment2 &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;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; different_moments (moment1) &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;2025-03-30 01:30&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;-- Apply double time zone conversion&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; different_moments &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET&lt;&#x2F;span&gt;&lt;span&gt; moment2 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; moment1 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AT TIME ZONE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;Europe&#x2F;Berlin&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AT TIME ZONE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;America&#x2F;New_York&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;-- View the results&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; different_moments;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        moment1         |        moment2&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; 2025-03-30 01:30:00+01 | 2025-03-30 07:30:00+02&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Unless you are aware of the implicit conversion between &lt;code&gt;timestamps&lt;&#x2F;code&gt; and &lt;code&gt;timestamp&lt;&#x2F;code&gt; and vice-versa you might be set for a lot of surprises. In this particular case the first conversion performed redundant conversion and removed the time zone offset. While second &quot;forced&quot; it to NYC time, while the subsequent select rendered it in local session timezone.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;timestamps-and-the-storage&quot;&gt;Timestamps and the storage&lt;a class=&quot;zola-anchor&quot; href=&quot;#timestamps-and-the-storage&quot; aria-label=&quot;Anchor link for: timestamps-and-the-storage&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While you will use &lt;code&gt;timestamp with time zone&lt;&#x2F;code&gt; from now on, PostgreSQL still comes with several nuances related to the way it can store the data. First lesser known feature might be timestamp precision. Let&#x27;s consider following 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: #6A737D;&quot;&gt;-- Table with various timestamp precision specifications&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; precision&lt;&#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;	t1 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp with time zone&lt;&#x2F;span&gt;&lt;span&gt;,        &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- default precision (6)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	t2 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp&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; with time zone&lt;&#x2F;span&gt;&lt;span&gt;,     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- seconds precision&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	t3 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp&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; with time zone&lt;&#x2F;span&gt;&lt;span&gt;,     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- centiseconds precision&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	t4 &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamp&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; with time zone&lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;      -- 10 microseconds precision&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;Effectively the &lt;code&gt;timestamp&lt;&#x2F;code&gt; comes with default precision of 6 digits (microseconds), but you can specify anywhere from 0 to 6 for both &lt;code&gt;timestamp&lt;&#x2F;code&gt; and &lt;code&gt;timestamptz&lt;&#x2F;code&gt; types. At the same time it does not have any impact on the storage (8 bytes) regardless the precision specified.&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;-- Insert the same timestamp into all columns&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 precision VALUES&lt;&#x2F;span&gt;&lt;span&gt; (current_timestamp, current_timestamp, current_timestamp, 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;SELECT * FROM precision&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;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;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;t1 | 2025-04-03 21:19:20.952354+02&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;t2 | 2025-04-04 21:19:21+02&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;t3 | 2025-04-04 21:19:20.95+02&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;t4 | 2025-04-04 21:19:20.9524+02&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Other than precision there are some other interesting aspects of the timestamps storage in PostgreSQL:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;It has &lt;strong&gt;finite range&lt;&#x2F;strong&gt; of &lt;code&gt;timestamptz &#x27;4713-01-01 BC&#x27;&lt;&#x2F;code&gt; and &lt;code&gt;timestamptz &#x27;294276-12-31&#x27;&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;and at the same time &lt;strong&gt;supports positive and negative infinity&lt;&#x2F;strong&gt; with &lt;code&gt;&#x27;infinity&#x27;::timestamptz&lt;&#x2F;code&gt; and &lt;code&gt;&#x27;-infinity&#x27;::timestamptz&lt;&#x2F;code&gt; that might provide alternative to &lt;code&gt;NULL&lt;&#x2F;code&gt; for open ended intervals. If you choose to use infinity instead of &lt;code&gt;NULL&lt;&#x2F;code&gt; values, you can test whatever the provided date&#x2F;timestamp is finite value or not using &lt;code&gt;isfinite(timestamp)&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;getting-the-current-time&quot;&gt;Getting the current time&lt;a class=&quot;zola-anchor&quot; href=&quot;#getting-the-current-time&quot; aria-label=&quot;Anchor link for: getting-the-current-time&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Not many developers are aware that there are different ways to get the current datetime in SQL. These methods not only differ in syntax but also in their underlying logic.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;current-timestamp-vs-now&quot;&gt;CURRENT_TIMESTAMP vs NOW()&lt;a class=&quot;zola-anchor&quot; href=&quot;#current-timestamp-vs-now&quot; aria-label=&quot;Anchor link for: current-timestamp-vs-now&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;CURRENT_TIMESTAMP&lt;&#x2F;strong&gt; is part of the SQL Standard and interestingly, it&#x27;s defined as both a &quot;time-varying variable&quot; and a &quot;datetime value function&quot;. In PostgreSQL, you can use it with or without parentheses, making both these forms valid:&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; 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;SELECT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; CURRENT_TIMESTAMP&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;-- (2) specifies precision&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Along with CURRENT_TIMESTAMP, the SQL Standard also defines &lt;code&gt;CURRENT_TIME&lt;&#x2F;code&gt; and &lt;code&gt;CURRENT_DATE&lt;&#x2F;code&gt; for working with individual time and date components respectively.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;NOW()&lt;&#x2F;code&gt;, while not part of the SQL Standard, is a widely used alternative across many database systems. Unlike CURRENT_TIMESTAMP, it&#x27;s strictly a function and therefore always requires parentheses when called. Its straightforward syntax - &lt;code&gt;NOW()&lt;&#x2F;code&gt; - makes it a popular choice among developers.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;transaction-behavior&quot;&gt;Transaction Behavior&lt;a class=&quot;zola-anchor&quot; href=&quot;#transaction-behavior&quot; aria-label=&quot;Anchor link for: transaction-behavior&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Both &lt;code&gt;NOW()&lt;&#x2F;code&gt; and &lt;code&gt;CURRENT_TIMESTAMP&lt;&#x2F;code&gt; share the same transaction behavior: they return the timestamp from the start of the current transaction, not the moment of execution:&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;SELECT NOW&lt;&#x2F;span&gt;&lt;span&gt;();                     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Returns transaction start 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&gt; pg_sleep(&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: #6A737D;&quot;&gt;-- Wait 5 seconds&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 NOW&lt;&#x2F;span&gt;&lt;span&gt;();                     &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Still returns the same 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;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;This behavior ensures data consistency within transactions but might be unexpected when measuring elapsed time.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;alternative-time-functions&quot;&gt;Alternative Time Functions&lt;a class=&quot;zola-anchor&quot; href=&quot;#alternative-time-functions&quot; aria-label=&quot;Anchor link for: alternative-time-functions&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;When you need the actual current time regardless of transaction status, &lt;code&gt;CLOCK_TIMESTAMP()&lt;&#x2F;code&gt; is your best choice. It returns the precise moment of execution, making it particularly useful for measuring real elapsed time. Here&#x27;s how it differs from &lt;code&gt;NOW()&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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_sleep(&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;SELECT NOW&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; transaction_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       CLOCK_TIMESTAMP()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; actual_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;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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       transaction_time        |          actual_time&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; 2025-04-03 19:28:25.310254+00 | 2025-04-03 19:28:26.311917+00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Another useful function is &lt;code&gt;STATEMENT_TIMESTAMP()&lt;&#x2F;code&gt;, which captures the time when the current statement began executing. This differs subtly from &lt;code&gt;CLOCK_TIMESTAMP()&lt;&#x2F;code&gt;, which gives you the exact moment of function execution. The difference becomes clear in 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: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    pg_sleep(&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;    STATEMENT_TIMESTAMP()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS statement&lt;&#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;    CLOCK_TIMESTAMP()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; wall_time;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; pg_sleep |           statement           |          wall_time&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;          | 2025-04-03 19:32:15.984459+00 | 2025-04-03 19:32:17.98661+00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;These timestamp functions serve different purposes and are invaluable when you need to measure transaction timing or analyze statement-level performance. Each provides a different perspective on time within your database operations.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;intervals&quot;&gt;Intervals&lt;a class=&quot;zola-anchor&quot; href=&quot;#intervals&quot; aria-label=&quot;Anchor link for: intervals&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Guess if you have worked with time in PostgreSQL you have come across with intervals. You probably just entered the it using something like &lt;code&gt;interval &#x27;1 year 2 months 3 days 4 hours&#x27;&lt;&#x2F;code&gt;. Technical that&#x27;s representation driven by setting &lt;code&gt;intervalstyle&lt;&#x2F;code&gt; which (as in this case) is set to &lt;code&gt;postgres_verbose&lt;&#x2F;code&gt;. But you have much more 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;-- PostgreSQL default style&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; intervalstyle &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;postgres&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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 year 2 months 3 days 4 hours&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&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&gt; 1 year 2 mons 3 days 04:00:00&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;-- SQL standard style&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; intervalstyle &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;sql_standard&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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 year 2 months 3 days 4 hours&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&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&gt; +1-2 +3 +4:00:00&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;-- ISO 8601 style&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; intervalstyle &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;iso_8601&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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 year 2 months 3 days 4 hours&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&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&gt; P1Y2M3DT4H&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With the session &lt;code&gt;intervalstyle&lt;&#x2F;code&gt; you only change the interval format representation, not the actual value.
Nevertheless the interval representation you might run into an interval definition that might not longer make it obvious (both in input or output) to interpret. That&#x27;s where function &lt;code&gt;justify_interval&lt;&#x2F;code&gt; comes in play - helping you to normalize time expression into conventional formats.&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;-- Normalize complex 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;SELECT&lt;&#x2F;span&gt;&lt;span&gt; justify_interval(interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;15 months 40 days 30 hours&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        justify_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&gt; @ 1 year 4 mons 11 days 6 hours&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;-- Practical example: normalize project durations&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;    project_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    total_hours &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; hours&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; raw_duration,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    justify_interval(interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 hour&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span&gt; total_hours) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; normalized_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;FROM&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;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Database Migration&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1850&lt;&#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;API Development&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;720&lt;&#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;UI Redesign&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;340&lt;&#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;    AS&lt;&#x2F;span&gt;&lt;span&gt; projects(project_name, total_hours);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    project_name    | raw_duration |   normalized_duration&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; Database Migration | 1850 hours   | @ 2 mons 17 days 2 hours&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; API Development    | 720 hours    | @ 1 mon&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; UI Redesign        | 340 hours    | @ 14 days 4 hours&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One of the important specific with internal normalization is the fact it uses 30-days time periods as a month definition.&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;-- Month definition in normalize intervals&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; justify_interval(interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;30 days&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; justify_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&gt; @ 1 mon&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Which might be understandable, but you need to beware of the edge cases when using days and months intervals.&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;-- Edge case comparison: month vs 30 days&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;	date&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-31&amp;#39;&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;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; example1,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	date&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;2025-01-31&amp;#39;&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;30 days&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; example2;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;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;example1 | 2025-02-28 00:00:00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;example2 | 2025-03-02 00:00:00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And one last thing, before wrapping up the interval - you can (same as with dates&#x2F;timestamp) get specific parts using &lt;code&gt;extract&lt;&#x2F;code&gt; function.&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;-- Extract specific parts from intervals&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;    extract(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;days from&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;1 year 3 months 21 days&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS days&lt;&#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;    extract(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;hours from&lt;&#x2F;span&gt;&lt;span&gt; interval &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;25:30:45&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS hours&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;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; days | hours&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; 21   | 25&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;time-ranges&quot;&gt;Time ranges&lt;a class=&quot;zola-anchor&quot; href=&quot;#time-ranges&quot; aria-label=&quot;Anchor link for: time-ranges&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While working with timestamp comes natural, it&#x27;s time ranges which often feel like ugly ducklings. And quite unfairly so - timestamp ranges in PostgreSQL are powerful yet underutilized features that elegantly solve common time-based challenges. PostgreSQL offers dedicated range types that are perfect for modeling time periods, reservations, schedules, and any situation where you need to track intervals with defined start and end points:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tsrange&lt;&#x2F;code&gt; - range of timestamp without time zone&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;tstzrange&lt;&#x2F;code&gt; range of timestamp with time zone
And to follow the earlier advice - just as you should default to &lt;code&gt;timestamps&lt;&#x2F;code&gt;, always use &lt;code&gt;tstzrange&lt;&#x2F;code&gt; for time ranges unless you have VERY specific reason for it.
Timestamp ranges aren&#x27;t merely convenient syntax - they provide a complete set of operations for determining relationships between time periods and enable powerful constraints that handle complex business rules without reinventing the wheel.
Timestamp ranges can be created using the range constructor syntax or string syntax.&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;-- Range constructor syntax&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; 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-04-01 09:00:00+02&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;span style=&quot;color: #9ECBFF;&quot;&gt;    &amp;#39;2025-04-01 17:30:00+02&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;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; workday;&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;-- String syntax&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-04-01 09:00:00+02, 2025-04-01 17:30:00+02)&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; workday;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                       workday&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[&amp;quot;2025-04-01 07:00:00+00&amp;quot;,&amp;quot;2025-04-01 15:30:00+00&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The default boundary notation for timestamp ranges is &lt;code&gt;[)&lt;&#x2F;code&gt; - lower bound inclusive (includes the start time) and upper bound exclusive (excludes the end time).
For time-based applications, this convention is particularly valuable. Consider scheduling consecutive one-hour meetings from 9:00 to 10:00 and 10:00 to 11:00. With &lt;code&gt;[)&lt;&#x2F;code&gt; notation, these are represented as &lt;code&gt;[09:00, 10:00)&lt;&#x2F;code&gt; and &lt;code&gt;[10:00, 11:00)&lt;&#x2F;code&gt;, creating perfect adjacency without overlap or gaps. If you used inclusive bounds &lt;code&gt;[09:00, 10:00]&lt;&#x2F;code&gt; and &lt;code&gt;[10:00, 11:00]&lt;&#x2F;code&gt;, the moment 10:00 would technically belong to both ranges - a logical impossibility for scheduling. This natural alignment with how we conceptualize time slots makes &lt;code&gt;[)&lt;&#x2F;code&gt; notation the default and recommended choice for most of the application use cases.
Timestamp ranges support numerous operations for checking relationships:&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. Check if a timestamp is within a 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&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-04-01 09:00, 2025-04-01 17:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange @&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-04-01 12:30&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;timestamptz AS&lt;&#x2F;span&gt;&lt;span&gt; is_during_workday;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; is_during_workday&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; t&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;-- 2. Check if two ranges overlap&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;[2025-04-01 09:00, 2025-04-01 12:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange &amp;amp;&amp;amp;&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-04-01 11:00, 2025-04-01 14:00)&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; meetings_overlap;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; meetings_overlap&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; t&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;-- 3. Extract time range bounds&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;    lower&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-04-01 09:00, 2025-04-01 17:00)&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; start_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    upper&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-04-01 09:00, 2025-04-01 17:00)&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; end_time;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        start_time        |         end_time&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; 2025-04-01 09:00:00+00   | 2025-04-01 17:00:00+00&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;As well as obvious manipulations:&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. Merge adjacent ranges&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;[2025-04-01 09:00, 2025-04-01 12:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange &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: #9ECBFF;&quot;&gt;    &amp;#39;[2025-04-01 12:00, 2025-04-01 17:00)&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; full_day;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                           full_day&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; [&amp;quot;2025-04-01 09:00:00+00&amp;quot;,&amp;quot;2025-04-01 17:00:00+00&amp;quot;)&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;-- 2. Find intersection between overlapping ranges&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;[2025-04-01 09:00, 2025-04-01 14:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange &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: #9ECBFF;&quot;&gt;    &amp;#39;[2025-04-01 12:00, 2025-04-01 17:00)&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; overlap_period;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                        overlap_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;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; [&amp;quot;2025-04-01 12:00:00+00&amp;quot;,&amp;quot;2025-04-01 14:00:00+00&amp;quot;)&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;-- 3. Find gap between non-overlapping ranges&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;[2025-04-01 09:00, 2025-04-01 11:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange &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: #9ECBFF;&quot;&gt;    &amp;#39;[2025-04-01 14:00, 2025-04-01 17:00)&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; gap;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                             gap&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; [&amp;quot;2025-04-01 11:00:00+00&amp;quot;,&amp;quot;2025-04-01 14:00:00+00&amp;quot;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And if you followed the previously mentioned logic, you wouldn&#x27;t double guess &lt;code&gt;tstzrange&lt;&#x2F;code&gt; handling of the DST transitions.&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;-- Testing DST transition handling&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; timezone &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;Europe&#x2F;Berlin&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;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;[2025-03-30 01:00:00, 2025-03-30 03:00:00)&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; dst_transition_range,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    upper&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-03-30 01:00:00, 2025-03-30 03:00:00)&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;::tstzrange) &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: #79B8FF;&quot;&gt;    lower&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;[2025-03-30 01:00:00, 2025-03-30 03:00:00)&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; actual_duration;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                dst_transition_range                 | actual_duration&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; [&amp;quot;2025-03-30 01:00:00+01&amp;quot;,&amp;quot;2025-03-30 03:00:00+02&amp;quot;) | @ 1 hour&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The area where time ranges win in almost all scenarios is indexing. If you consider a manual implementation of the range using &lt;code&gt;start_at&lt;&#x2F;code&gt; and &lt;code&gt;end_at&lt;&#x2F;code&gt; columns you typically rely on B-tree indexes. While they might work for simple queries on just one of the values, they fall short for time ranges. 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;-- Traditional approach with two columns and B-tree indexes&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; events_columns&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; start_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;2025-04-01 17:00&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; end_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-04-01 09:00&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;For this query, PostgreSQL might use one of the indexes if it&#x27;s selective enough, but the fundamental problem remains: each B-tree index can only efficiently filter on its own column. The planner might use the start_at index to find events starting before 17:00, or the end_at index to find events ending after 09:00, but it can&#x27;t use both indexes simultaneously to find the intersection.
With range types, we can use &lt;strong&gt;GiST&lt;&#x2F;strong&gt; (Generalized Search Tree) indexes that are specifically designed for range operations:&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 specialized GiST index for time range 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 INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; idx_events_range&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; events_range &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;USING&lt;&#x2F;span&gt;&lt;span&gt; GIST(time_period);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This allows you to transform the sample query into:&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-based approach using the &amp;amp;&amp;amp; (overlap) operator&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; events_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; time_period &amp;amp;&amp;amp; tstzrange(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2025-04-01 09:00&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2025-04-01 17:00&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 performance gap between these approaches is substantial – range queries with GiST indexes typically &lt;strong&gt;outperform&lt;&#x2F;strong&gt; the traditional approach by 2-10x on large datasets. This difference becomes even more pronounced as your data grows.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;time-to-wrap-up&quot;&gt;Time to wrap up&lt;a class=&quot;zola-anchor&quot; href=&quot;#time-to-wrap-up&quot; aria-label=&quot;Anchor link for: time-to-wrap-up&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Working with time in PostgreSQL has more edge cases than it first appears. To summarize:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Always use timestamptz instead of timestamp&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Be intentional about time zone handling&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Leverage PostgreSQL&#x27;s specialized time tools&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Mind the edge cases&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Time handling has more edge cases than most software engineering problems, but PostgreSQL&#x27;s time types cover nearly all of them. Understanding these &quot;boring&quot; concepts will save you from the pitfalls covered above.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>VIEW inlining in PostgreSQL</title>
        <published>2025-02-08T00:00:00+00:00</published>
        <updated>2025-02-08T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/view-inlining/"/>
        <id>https://boringsql.com/posts/view-inlining/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/view-inlining/">&lt;p&gt;Database VIEWs are powerful tools that often don&#x27;t get the attention they deserve when building database-driven applications. They make our database work easier in several ways:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;They let us reuse common query patterns instead of writing them over and over&lt;&#x2F;li&gt;
&lt;li&gt;They give us a place to define business rules once and use them everywhere&lt;&#x2F;li&gt;
&lt;li&gt;They help us write cleaner, more organized queries&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Let&#x27;s see how this works with a practical example. Imagine we want to work with &lt;em&gt;active users&lt;&#x2F;em&gt; - users who have used the application within the last 7 days. Instead of writing this condition in every query, we can define a 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: #F97583;&quot;&gt;CREATE VIEW&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; active_users&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;	*&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; users;&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;	last_login &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; current_date &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;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now we can easily use this view whenever we need active users. For example, if we want to find active users from Germany:&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;	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; active_users&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;	country &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;Germany&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;While this simple example shows how views can make developers&#x27; lives easier by organizing and reusing common logic, there&#x27;s more to the story. In this article, we&#x27;ll explore something even more interesting: how PostgreSQL can optimize these views through a process called &quot;inlining&quot; - making them not just convenient, but fast too.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-is-view-inlining&quot;&gt;What is VIEW inlining&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-is-view-inlining&quot; aria-label=&quot;Anchor link for: what-is-view-inlining&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When you use a view, it acts like a building block in SQL queries. Taking our previous example, PostgreSQL effectively transforms the query by replacing the view with its underlying 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 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: #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;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&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; users&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;		last_login &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; current_date &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;) active users&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;	country &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;Germany&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;While we write the query using &lt;code&gt;active_users&lt;&#x2F;code&gt; VIEW, PostgreSQL query planner will see it as an opportunity to optimize it further. Instead of treating the sub-query as separate step (and retrieving large subset of the users), it effectively transforms the query and &lt;strong&gt;inlines&lt;&#x2F;strong&gt; the view into the query itself. Behind the scenes, PostgreSQL will execute the query similar 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;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: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; users&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;	last_login &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span&gt; current_date &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 style=&quot;color: #F97583;&quot;&gt;	AND&lt;&#x2F;span&gt;&lt;span&gt; country &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;Germany&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;strong&gt;inlining process&lt;&#x2F;strong&gt; allows the PostgreSQL query planner to optimize the entire query as a single unit. This allows it to select the best indexes, possible join strategies and other aspects together, rather than having to execute the individual parts separately and then filtering the data that won&#x27;t be otherwise used.  This is exactly what VIEWs are for - we get the best of both worlds - clean, modular code for developers and optimal performance for the database.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;inlining-in-action&quot;&gt;Inlining in action&lt;a class=&quot;zola-anchor&quot; href=&quot;#inlining-in-action&quot; aria-label=&quot;Anchor link for: inlining-in-action&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The easiest way how to preview the VIEW inlining is to run EXPLAIN&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; Seq Scan on users  (cost=0.00..19.20 rows=1 width=4)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: ((country = &amp;#39;Germany&amp;#39;::text) AND (last_login &amp;gt; (CURRENT_DATE - &amp;#39;7 days&amp;#39;::interval)))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The Filter part here is the one showing how the filtering conditions from both the query and the view got merged together by the query planner.&lt;&#x2F;p&gt;
&lt;p&gt;This works especially well with complex queries involving joins, aggregations and filters. PostgreSQL can optimize entire query chain as one unit instead of executing individual pieces 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; order_analytics&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;    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;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;country&lt;&#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;    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;, &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&gt;) &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: #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; orders,&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;(&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;total_amount&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&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;JOIN&lt;&#x2F;span&gt;&lt;span&gt; customers c &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;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;&#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;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;When you use this VIEW in the 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; customer_id, revenue&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; order_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;WHERE&lt;&#x2F;span&gt;&lt;span&gt; country &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;Germany&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 month &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&lt;&#x2F;span&gt;&lt;span&gt; revenue &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; 1000&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 will see how PostgreSQL effectively inlines the filter conditions where they belong.&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;HashAggregate  (cost=205.97..207.97 rows=200 width=16)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Group Key: o.customer_id, c.country, (date_trunc(&amp;#39;month&amp;#39;::text, o.created_at))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (sum(o.total_amount) &amp;gt; 1000)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Hash Join  (cost=16.12..185.25 rows=1235 width=20)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Hash Cond: (o.customer_id = c.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;             Filter: (created_at &amp;gt;= &amp;#39;2024-01-01&amp;#39;::date)&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 customers c&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Filter: (country = &amp;#39;Germany&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This works especially well with increasing query complexity. Particulary involving joins and further filters. PostgreSQL can optimize entire query chain as one unit instead of executing individual pieces 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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;CREATE VIEW completed_orders AS&lt;&#x2F;span&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;   o.id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   o.customer_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   o.total_amount,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   o.created_at,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   o.status,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   p.name as product_name,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   c.email,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   c.country&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FROM orders o&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;JOIN customers c ON c.id = o.customer_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;JOIN order_items oi ON oi.order_id = o.id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;JOIN products p ON p.id = oi.product_id&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WHERE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   o.status = &amp;#39;completed&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;SELECT&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;FROM completed_orders&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WHERE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	country = &amp;#39;Germany&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;CREATE INDEX idx_orders_customer_id ON orders(customer_id); CREATE INDEX idx_order_items_order_id ON order_items(order_id); CREATE INDEX idx_order_items_product_id ON order_items(product_id);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;as demonstrated in the query 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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Nested Loop  (cost=0.30..19.70 rows=1 width=176)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Nested Loop  (cost=0.15..13.52 rows=1 width=148)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Join Filter: (o.id = oi.order_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Nested Loop  (cost=0.15..12.43 rows=1 width=144)&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  (cost=0.00..1.05 rows=1 width=80)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Filter: (status = &amp;#39;completed&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               -&amp;gt;  Index Scan using customers_pkey on customers c  (cost=0.15..8.17 rows=1 width=68)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Index Cond: (id = o.customer_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Filter: (country = &amp;#39;Germany&amp;#39;::text)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Seq Scan on order_items oi  (cost=0.00..1.04 rows=4 width=8)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Index Scan using products_pkey on products p  (cost=0.15..6.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: (id = oi.product_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;planner-barriers&quot;&gt;Planner barriers&lt;a class=&quot;zola-anchor&quot; href=&quot;#planner-barriers&quot; aria-label=&quot;Anchor link for: planner-barriers&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While PostgreSQL is very good at inlining views, certain operations create &quot;planner barrier&quot; that prevent the optimization. In case of views some of the conditions that will cause it are&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;DISTINCT ON operations&lt;&#x2F;li&gt;
&lt;li&gt;Window functions (OVER clauses)&lt;&#x2F;li&gt;
&lt;li&gt;Set operations (UNION&#x2F;INTERSECT&#x2F;EXCEPT)&lt;&#x2F;li&gt;
&lt;li&gt;More complex aggregations&lt;&#x2F;li&gt;
&lt;li&gt;Common Table Expressions that are materialized (either with MATERIALIZED hint or by the &lt;a href=&quot;&#x2F;posts&#x2F;good-cte-bad-cte&#x2F;&quot;&gt;decision of the query planner&lt;&#x2F;a&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Use of VOLATILE functions&lt;&#x2F;li&gt;
&lt;li&gt;And in some cases complex subqueries&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;When planner is not able to inline VIEWs it might lead to the unnecessary performance and resource impact. I.e. each sub query might materialize results separately, leading to the already retrieved rows to be discarded by another part of the query as one of the most common examples.&lt;&#x2F;p&gt;
&lt;p&gt;When using EXPLAIN this is demostated by one of the following options:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Subquery scan on...&lt;&#x2F;code&gt; nodes&lt;&#x2F;li&gt;
&lt;li&gt;Materialization nodes&lt;&#x2F;li&gt;
&lt;li&gt;Separate aggregation&#x2F;sorting steps before the main execution&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;As mentioned above the window function acts as such a planner barrier.&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 VIEW order_insights AS&lt;&#x2F;span&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;   o.customer_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   o.total_amount,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   o.created_at,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.created_at) as order_sequence,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   SUM(o.total_amount) OVER (PARTITION BY o.customer_id ORDER BY o.created_at) as running_total&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;FROM orders o;&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&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;SELECT * FROM order_insights&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WHERE customer_id = 1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;AND total_amount &amp;gt; 500;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Giving us easy to spot &lt;code&gt;Subquery scan&lt;&#x2F;code&gt; 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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; Subquery Scan on order_insights  (cost=1.06..1.10 rows=1 width=84)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Filter: (order_insights.total_amount &amp;gt; &amp;#39;500&amp;#39;::numeric)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  WindowAgg  (cost=1.06..1.08 rows=1 width=84)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Sort  (cost=1.06..1.06 rows=1 width=44)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               Sort Key: o.created_at&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  (cost=0.00..1.05 rows=1 width=44)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Filter: (customer_id = 1)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;runtime-materialization-not-materialized-views&quot;&gt;Runtime Materialization (Not MATERIALIZED VIEWs)&lt;a class=&quot;zola-anchor&quot; href=&quot;#runtime-materialization-not-materialized-views&quot; aria-label=&quot;Anchor link for: runtime-materialization-not-materialized-views&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Let&#x27;s clear up a possible confusion - we&#x27;re not talking about CREATE MATERIALIZED VIEW here. This is about PostgreSQL&#x27;s runtime decision to cache view results in memory during query execution. Query planner might use explicit materialization when it needs to reference view results multiple times or prevent repeated expensive computations. The query planner shows this through a Materialize 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;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&gt;    customer_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;    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; orders,&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;(total_amount) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;as&lt;&#x2F;span&gt;&lt;span&gt; total_spent&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;&#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 &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 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&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;    a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total_spent&lt;&#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;    b&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total_spent&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; as&lt;&#x2F;span&gt;&lt;span&gt; other_customer_spent&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_summary 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; customer_summary 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; b&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total_spent&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; a&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;total_spent&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 the perfect example of materialization.&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; Nested Loop  (cost=46.00..200.75 rows=3333 width=68)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   Join Filter: (b.total_spent &amp;gt; (sum(orders.total_amount)))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  HashAggregate  (cost=23.00..24.25 rows=100 width=44)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         Group Key: orders.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..18.00 rows=1000 width=15)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;   -&amp;gt;  Materialize  (cost=23.00..25.75 rows=100 width=32)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         -&amp;gt;  Subquery Scan on b  (cost=23.00..25.25 rows=100 width=32)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;               -&amp;gt;  HashAggregate  (cost=23.00..24.25 rows=100 width=44)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;                     Group Key: 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 orders_1  (cost=0.00..18.00 rows=1000 width=15)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Next to the materialization - &lt;strong&gt;the materialized VIEWs&lt;&#x2F;strong&gt; (&lt;code&gt;CREATE MATERIALIZED VIEW&lt;&#x2F;code&gt;) are for practical purposes &quot;just another table&quot; they present an ultimate planner barrier. PostgreSQL must scan the materialized data directly, trading query flexibility for performance&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tips-for-writing-inlining-friendly-views&quot;&gt;Tips for writing inlining friendly VIEWs&lt;a class=&quot;zola-anchor&quot; href=&quot;#tips-for-writing-inlining-friendly-views&quot; aria-label=&quot;Anchor link for: tips-for-writing-inlining-friendly-views&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Well, there&#x27;s not a single &quot;right&quot; way how to write views. And don&#x27;t forget - VIEWs should make your database easier to work with, not harder. When designing views, focus on single responsibility - each view should handle one aspect of business logic rather than trying to optimize everything at once. Simple views are more likely to get inlined by PostgreSQL&#x27;s query planner.&lt;&#x2F;p&gt;
&lt;p&gt;There are several common patterns that prevent inlining&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Avoid window functions, distinct and set operations completely&lt;&#x2F;li&gt;
&lt;li&gt;Split complex logic into multiple views if possible&lt;&#x2F;li&gt;
&lt;li&gt;Minimize subquery usage&lt;&#x2F;li&gt;
&lt;li&gt;Consider materialized views for aggregations&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;View dependencies are a critical consideration. Even minor schema changes can trigger cascading view modifications across your database. Instead of creating deep view hierarchies, split complex logic into smaller, composable views. For critical views that many applications depend on, consider versioning (v1, v2) rather than modifying existing ones.&lt;&#x2F;p&gt;
&lt;p&gt;When in doubt, write the plain SQL first, then extract common patterns into views. This helps avoid overengineering and keeps your database schema maintainable.&lt;&#x2F;p&gt;
&lt;p&gt;Inlining is only half the story. The other half is what views are made of in the catalog, and why every column change to the underlying table turns into a drop-and-recreate exercise. That trade-off, and the workarounds, is covered in &lt;a href=&quot;&#x2F;posts&#x2F;strong-views&#x2F;&quot;&gt;Strong views on PostgreSQL VIEWs&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>DELETEs are difficult</title>
        <published>2024-11-23T00:00:00+00:00</published>
        <updated>2024-11-23T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/deletes-are-difficult/"/>
        <id>https://boringsql.com/posts/deletes-are-difficult/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/deletes-are-difficult/">&lt;p&gt;Your database is ticking along nicely - until a simple DELETE brings it to its
knees. What went wrong? While we tend to focus on optimizing SELECT and INSERT
operations, we often overlook the hidden complexities of DELETE. Yet, removing
unnecessary data is just as critical. Outdated or irrelevant data can bloat your
database, degrade performance, and make maintenance a nightmare. Worse,
retaining some types of data without valid justification might even lead to
compliance issues.&lt;&#x2F;p&gt;
&lt;p&gt;At first glance, the DELETE command seems straightforward. Even the &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;sql-delete.html&quot;&gt;PostgreSQL
documentation&lt;&#x2F;a&gt; provides
simple examples 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;DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; films &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; kind &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;Musical&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;DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; films;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;These queries might work effortlessly on your development machine, where only a
few hundred records exist. But what happens when you try running a similar
DELETE in production, where datasets are orders of magnitude larger?&lt;&#x2F;p&gt;
&lt;p&gt;In this article, we’ll uncover why DELETE operations
demand careful consideration and explore how to handle them effectively.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-really-happens-when-you-delete-the-data&quot;&gt;What really happens when you DELETE the data?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-really-happens-when-you-delete-the-data&quot; aria-label=&quot;Anchor link for: what-really-happens-when-you-delete-the-data&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;At first glance, a DELETE query might seem straightforward. However, once the query is executed, a series of intricate steps occur:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Row Identification&lt;&#x2F;strong&gt;: Similar to a SELECT operation, the query identifies rows visible to the current transaction (considering MVCC) and checks for locks.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Lock Acquisition&lt;&#x2F;strong&gt;: The database acquires row-level exclusive locks to prevent other operations on the targeted rows.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;BEFORE DELETE trigger&lt;&#x2F;strong&gt;: If a BEFORE DELETE trigger is defined, it is executed at this point.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Marking Rows as Deleted&lt;&#x2F;strong&gt;: Instead of being physically removed, the rows are marked as deleted in the current transaction, rendering them invisible to future queries (depending on transaction isolation). If table has large data objects, TOAST table will have to be involved too.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Index Updates&lt;&#x2F;strong&gt;: The corresponding index entries are also marked for deletion (if applicable).&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Cascaded Actions&lt;&#x2F;strong&gt;: Cascading operations, such as ON DELETE CASCADE, are performed on related tables.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;AFTER DELETE Trigger&lt;&#x2F;strong&gt;: If an AFTER DELETE trigger is defined, it is executed.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Write-Ahead Log (WAL)&lt;&#x2F;strong&gt;: Changes are recorded in the WAL-first at the row level, followed by index-level updates.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Only when the transaction is committed do these changes become permanent and visible to transactions starting afterward. However, even at this point, the data is &lt;strong&gt;not physically removed&lt;&#x2F;strong&gt;. This is how &lt;strong&gt;bloat&lt;&#x2F;strong&gt; is created.&lt;&#x2F;p&gt;
&lt;p&gt;Until the &lt;strong&gt;autovacuum&lt;&#x2F;strong&gt; process or a manual VACUUM operation reclaims the space, the “deleted” data remains. This leftover data contributes to bloat, which can degrade query performance over time.&lt;&#x2F;p&gt;
&lt;p&gt;The key question now is whether DELETEs are truly the hardest operation we can subject our database to. The answer? Quite possibly. While UPDATEs come close in complexity, they’re typically designed in ways that make them less challenging:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;UPDATEs usually modify only a limited number of columns, reducing the potential number of index updates lower.&lt;&#x2F;li&gt;
&lt;li&gt;Not all UPDATEs trigger a full row (COLD) update, where the old row is marked as dead and a new row is created. With careful table and query design, you can minimise these cases. &lt;a href=&quot;&#x2F;posts&#x2F;hot-updates&#x2F;&quot;&gt;HOT (Heap-Only Tuple) updates&lt;&#x2F;a&gt;, for instance, are easier to achieve with fixed-length columns.&lt;&#x2F;li&gt;
&lt;li&gt;Unlike DELETEs, UPDATEs don’t trigger cascaded actions - they only involve triggers that are explicitly defined.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;and-then-comes-autovacuum&quot;&gt;And then comes AUTOVACUUM&lt;a class=&quot;zola-anchor&quot; href=&quot;#and-then-comes-autovacuum&quot; aria-label=&quot;Anchor link for: and-then-comes-autovacuum&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When autovacuum kicks in (and you really want it to kick in) - typically triggered by thresholds for the number of dead tuples or changes to the table - a significant amount of work is required to clean them up. Let’s break it down step by step:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The process begins by scanning the table. While it’s not always a full table scan, the autovacuum checks the visibility map and pages where dead tuples might exist. This can happen incrementally, allowing even large tables to be processed - provided your autovacuum settings allow it to run frequently enough.&lt;&#x2F;li&gt;
&lt;li&gt;Each tuple is checked to ensure it’s no longer visible to any active or pending transactions.&lt;&#x2F;li&gt;
&lt;li&gt;Dead tuples that pass the visibility check are physically removed from the table.&lt;&#x2F;li&gt;
&lt;li&gt;Corresponding index entries for the removed tuples are updated.&lt;&#x2F;li&gt;
&lt;li&gt;The now-empty space is marked for reuse in future INSERT or UPDATE operations.&lt;&#x2F;li&gt;
&lt;li&gt;Table statistics are updated to reflect the current state, helping the query planner make better decisions.&lt;&#x2F;li&gt;
&lt;li&gt;The changes, including tuple removals and index updates, are logged in the Write-Ahead Log (WAL) for durability and replication.&lt;&#x2F;li&gt;
&lt;li&gt;If the table has TOASTed data (large objects), the associated TOAST tables are processed.&lt;&#x2F;li&gt;
&lt;li&gt;The visibility map is updated to mark cleaned pages as fully visible again.&lt;&#x2F;li&gt;
&lt;li&gt;Autovacuum resets thresholds to determine when the next vacuum operation should occur.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This process continues until it reaches the configured vacuum cost limit (in the case of autovacuum), at which point it pauses or stops. While autovacuum helps keep your database in check, it’s clear that reclaiming dead tuples is no small task - and it underscores why DELETE operations can have lasting effects on database performance.&lt;&#x2F;p&gt;
&lt;p&gt;While the &lt;code&gt;AUTOVACUUM&lt;&#x2F;code&gt; might sounds as a bad news, without it, your database would quickly become bloated with dead tuples, leading to degraded performance, slower queries, increased storage usage, and even the potential for out-of-disk errors as unused space cannot be reclaimed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;further-considerations&quot;&gt;Further considerations&lt;a class=&quot;zola-anchor&quot; href=&quot;#further-considerations&quot; aria-label=&quot;Anchor link for: further-considerations&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;For what seems like a simple DELETE, a surprising amount of work has already taken place, but the complexity doesn’t stop there. DELETE operations can introduce additional challenges, particularly when replication, resource contention, or the size of the operation comes into play.&lt;&#x2F;p&gt;
&lt;p&gt;In environments with replication to hot standby or replicas, DELETEs become more time-sensitive. The transaction cannot complete until the corresponding WAL (Write-Ahead Log) records have been written to disk on the standby. This is a fundamental requirement for maintaining data consistency in high-availability setups, where at least one standby server is typically involved. Additionally, if the standby is actively serving read operations, it must account for DELETEs before confirming the changes, potentially introducing further delays.&lt;&#x2F;p&gt;
&lt;p&gt;The size of the DELETE operation also plays a critical role. Small DELETEs, such as removing a single row, tend to have minimal impact. However, as the size of the operation grows, so does the volume of WAL records generated. Large DELETEs can overwhelm the system, slowing down transactions and straining the replication process. Standby servers must work harder to process the incoming WAL stream, which can bottleneck performance if their throughput is insufficient.&lt;&#x2F;p&gt;
&lt;p&gt;Resource contention adds yet another layer of complexity, particularly for large DELETEs. Generating WAL records, handling regular transactional workloads, and running background processes can collectively push the system towards I&#x2F;O saturation. This creates competition for CPU and memory resources, leading to slower operations across the board.&lt;&#x2F;p&gt;
&lt;p&gt;Finally, once the data is marked for deletion, the autovacuum process must eventually step in to physically remove it. This introduces its own set of challenges, as autovacuum must deal with the same resource contention and I&#x2F;O demands, compounding the overall impact of the initial DELETE operation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;soft-deletes-are-not-the-solution&quot;&gt;Soft-deletes are not the solution&lt;a class=&quot;zola-anchor&quot; href=&quot;#soft-deletes-are-not-the-solution&quot; aria-label=&quot;Anchor link for: soft-deletes-are-not-the-solution&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Soft-deletes might seem like an easy way to sidestep the complexities of
traditional DELETE operations. After all, updating a &lt;code&gt;deleted_at&lt;&#x2F;code&gt; field is
straightforward and unlikely to trigger a COLD update. However, soft-deletes are
not a true mechanism for data removal, and they come with their own set of
complications.&lt;&#x2F;p&gt;
&lt;p&gt;While soft-deletes can provide a simple way to implement “undo” functionality,
they raise serious questions about data consistency. For instance, do you only
mark the main entity as deleted, or do you also cascade the status to all
related records in referenced tables? Failing to cascade properly can leave your
database in an inconsistent state, making it difficult to maintain data
integrity.&lt;&#x2F;p&gt;
&lt;p&gt;Soft-deletes also require consideration in your application logic. Every query
must include appropriate filters to exclude “deleted” rows, which can complicate
query design and increase the risk of oversights. One missed filter could expose
data that should no longer be visible, leading to potential security or business
logic issues.&lt;&#x2F;p&gt;
&lt;p&gt;Finally, soft-deletes don’t solve the problem - they do merely postpone it. The
data is still in your database, consuming storage and potentially contributing
to performance degradation over time. Sooner or later, you’ll need to deal with
the actual removal of this data, bringing you back to the same challenges that
DELETEs pose in the first place.&lt;&#x2F;p&gt;
&lt;p&gt;At the time of writing this article we can only speculate how much will support
of temporal PRIMARY KEY and UNIQUE constriants in PostgreSQL 18 will transform
the balances in future. But given the complexity of the feature I wouldn&#x27;t bet
on it just yet.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;batching-is-the-answer&quot;&gt;Batching is the answer&lt;a class=&quot;zola-anchor&quot; href=&quot;#batching-is-the-answer&quot; aria-label=&quot;Anchor link for: batching-is-the-answer&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Giving PostgreSQL time to process and catch up with large-scale changes is critical when dealing with operations like DELETEs. The core issue here is the duration and magnitude of the transaction. &lt;strong&gt;The shorter the transaction and the fewer changes made, the better PostgreSQL can manage and reconcile those changes.&lt;&#x2F;strong&gt; This principle is universal across all database operations and underscores the importance of minimizing the impact of individual transactions.&lt;&#x2F;p&gt;
&lt;p&gt;While you can optimize certain aspects, like row identification (using indexes, clustering, or similar techniques), larger datasets demand a more strategic approach - batching. For example, deleting 1 million rows in a single transaction is a textbook case of what not to do. Instead, splitting the operation into smaller batches, such as deleting 10,000 rows across 100 iterations, is far more effective.&lt;&#x2F;p&gt;
&lt;p&gt;Will this method be faster than performing one massive DELETE? Likely not, especially if you include a wait time between batches to allow PostgreSQL to handle other workloads. However, the trade-off is worthwhile. By batching, you give PostgreSQL more breathing room to manage changes without overwhelming regular transactional workloads - unless, of course, you’ve scheduled dedicated maintenance time for the operation.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;how-to-batch-deletes&quot;&gt;How to Batch DELETEs&lt;a class=&quot;zola-anchor&quot; href=&quot;#how-to-batch-deletes&quot; aria-label=&quot;Anchor link for: how-to-batch-deletes&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The easiest way to batch DELETEs is to use a subquery or a &lt;a href=&quot;&#x2F;posts&#x2F;good-cte-bad-cte&#x2F;&quot;&gt;Common Table
Expression (CTE)&lt;&#x2F;a&gt; to limit the number of rows affected in each iteration. For
example, instead of executing a bulk DELETE 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;DELETE FROM&lt;&#x2F;span&gt;&lt;span&gt; films &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; kind &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;Musical&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;You can break the operation into smaller chunks. Using a query like the
following, you can repeatedly delete rows in manageable batches (for instance,
using \watch in psql to automate iterations):&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; films&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; ctid &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;&#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; ctid &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; films&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; kind &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;Musical&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;    LIMIT&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 250&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 use of &lt;code&gt;ctid&lt;&#x2F;code&gt; in this example is PostgreSQL system column which provides a
unique identifier for each row. By selecting &lt;code&gt;ctid&lt;&#x2F;code&gt; values in the subquery, you
can limit the number of rows affected in each iteration. This approach is more
efficient than using &lt;code&gt;LIMIT&lt;&#x2F;code&gt; directly in the main query, as it avoids the need
to re-scan the table for each batch.&lt;&#x2F;p&gt;
&lt;p&gt;If you don&#x27;t feel comfortable with &lt;code&gt;ctid&lt;&#x2F;code&gt; (which might deserve article on its
own) you can use regular lookup by primary key and &lt;code&gt;LIMIT&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;planning-for-autovacuum&quot;&gt;Planning for Autovacuum&lt;a class=&quot;zola-anchor&quot; href=&quot;#planning-for-autovacuum&quot; aria-label=&quot;Anchor link for: planning-for-autovacuum&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Batching alone doesn’t directly solve the issue of autovacuum catching up with the changes. You’ll need to plan for that separately. Adjusting autovacuum settings or triggering manual &lt;code&gt;VACUUM&lt;&#x2F;code&gt; and &lt;code&gt;VACUUM ANALYZE&lt;&#x2F;code&gt; runs can help manage bloat created during the DELETE process. However, disabling autovacuum is rarely advisable unless you’ve carefully planned for manual maintenance throughout the batch operations. Skipping this step risks leaving behind performance-impacting bloat that will require even more effort to address later.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;drop-whole-range-of-data-with-partitioning&quot;&gt;Drop whole range of data with partitioning&lt;a class=&quot;zola-anchor&quot; href=&quot;#drop-whole-range-of-data-with-partitioning&quot; aria-label=&quot;Anchor link for: drop-whole-range-of-data-with-partitioning&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Data that is naturally segmented - for example by time of the creation - makes it an excellent candidate for removal through partitioning. Partitioning allows you to bypass DELETE operations altogether by simply dropping or truncating the relevant partitions. This approach is far more efficient and avoids the overhead of scanning, locking, and marking rows as deleted, effectively eliminating the problem with the bloat.&lt;&#x2F;p&gt;
&lt;p&gt;While partitioning adds some complexity to schema design and query planning, it can provide significant performance benefits for DELETE-heavy workloads, especially when combined with automated partition management.&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;DELETE operations are often a source of unpleasant surprises - not
just by affecting performance and creating bloat, but by striking back at the
times we least expect. To handle them effectively, focus on strategies such as
batching, monitoring autovacuum, or leveraging partitioning for large datasets.
By considering DELETE operations during schema design, you can maintain an
efficient database, reduce maintenance headaches, and ensure it continues to run
smoothly as your data grows.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Text identifiers in PostgreSQL database design</title>
        <published>2024-11-09T00:00:00+00:00</published>
        <updated>2024-11-09T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/text-identifier-in-db-design/"/>
        <id>https://boringsql.com/posts/text-identifier-in-db-design/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/text-identifier-in-db-design/">&lt;p&gt;Whether you are designing a standalone application or a microservice, you will inevitably encounter the topic of sharing identifiers. Whether it’s URLs of web pages, RESTful API resources, JSON documents, CSV exports, or something else, the identifier of specific resources will be exposed.&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;&#x2F;orders&#x2F;123&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;products&#x2F;345&#x2F;variants&#x2F;1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While an identifier is just a number and does not carry any negative connotations, there are valid reasons why you might want to avoid exposing them. These reasons include:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Security and Data Exposure&lt;&#x2F;strong&gt;: Numerical identifiers are sequential and predictable, which can expose information about the underlying data source (e.g., the volume of the data) and provide a basis for ID enumeration.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Privacy and Confidentiality&lt;&#x2F;strong&gt;: Concerns may arise about concealing the volume of referenced data. For example, the number of customers, clients, or orders might be information a business prefers to keep private.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Non-descriptive Nature&lt;&#x2F;strong&gt;: Integers as identifiers can lead to confusion. An ID like 123 does not convey any additional information, making debugging edge cases more challenging.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;These and other reasons (like SEO optimisation) have led to the increased use of text-based identifiers. Their readability and versatility make them ideal for external data sharing.&lt;&#x2F;p&gt;
&lt;p&gt;However, in database (or data model) design, the advantages of text identifiers are often overshadowed by the problems they introduce. While text identifiers improve interoperability, they frequently come with performance and storage trade-offs. In contrast, integers are naturally faster and more efficient to process, resulting in lower storage requirements and faster indexing, sorting, and searching-tasks that computers are optimised for.&lt;&#x2F;p&gt;
&lt;p&gt;In this article, we will explore scenarios where using text identifiers directly in the database design might seem natural and discuss strategies for using them effectively.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-makes-text-identifiers-appealing&quot;&gt;What Makes Text Identifiers Appealing?&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-makes-text-identifiers-appealing&quot; aria-label=&quot;Anchor link for: what-makes-text-identifiers-appealing&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Let’s be honest-text identifiers are popular for a reason. For humans, they are much more readable and, in some cases, add extra context. (Raise your hand if you don’t appreciate Heroku’s quirky and memorable names!) If chosen carefully, they can also be easier to recall.&lt;&#x2F;p&gt;
&lt;p&gt;Text identifiers can embed additional context. Consider the order number APAC-20241103-8237, which encodes both the region and the order date. Another popular reason for using text identifiers is their uniqueness in distributed environments.&lt;&#x2F;p&gt;
&lt;p&gt;They’re especially handy when people need to interact with them directly. For example, customers copying an order number from an email or support teams discussing an issue benefit from a readable, meaningful identifier. It’s simpler, more intuitive, and less likely to cause headaches when someone tries to recall or share it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;when-an-identifier-isn-t-just-an-identifier&quot;&gt;When an Identifier Isn’t Just an Identifier&lt;a class=&quot;zola-anchor&quot; href=&quot;#when-an-identifier-isn-t-just-an-identifier&quot; aria-label=&quot;Anchor link for: when-an-identifier-isn-t-just-an-identifier&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Problems with text identifiers arise when they are used as natural keys in your data or database model. Despite their benefits, text identifiers often make poor primary keys for several reasons:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Context Changes&lt;&#x2F;strong&gt;: The additional context provided by text identifiers will likely change, necessitating updates. Despite assurances to the contrary, changes are inevitable.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Sorting Issues&lt;&#x2F;strong&gt;: Sorting text identifiers can be tricky, particularly with locale-based sorting or when numbers are embedded within the identifier (e.g., order1434 vs. order349).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The ultimate issue is efficiency:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Text identifiers typically require &lt;strong&gt;more storage space&lt;&#x2F;strong&gt; than numeric ones. Each character in a text field occupies more bytes than a simple integer, leading to larger database sizes and slower performance, especially during indexing or handling large datasets.&lt;&#x2F;li&gt;
&lt;li&gt;Databases are optimised for numeric operations, making &lt;strong&gt;searches, joins, and indexing on text fields inherently slower&lt;&#x2F;strong&gt;. This performance gap can significantly affect large datasets, impacting application efficiency.&lt;&#x2F;li&gt;
&lt;li&gt;Text identifiers complicate &lt;strong&gt;managing relationships between tables&lt;&#x2F;strong&gt;. The additional storage requirements affect not only the source table but also all referencing entities. Multiply this increased storage by the number of referencing tables, and you’ll get a sense of the overall impact.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;As databases grow, these issues become more pronounced. The combination of increased storage needs and slower operations contributes to &lt;strong&gt;database bloat&lt;&#x2F;strong&gt;, which can degrade system performance. Additionally, bloated indexes can mislead the query planner into making suboptimal choices, such as favouring sequential scans over index scans, complicating regular operations further.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;aren-t-uuids-the-solution-to-all-this&quot;&gt;Aren&#x27;t UUIDs the Solution to All This?&lt;a class=&quot;zola-anchor&quot; href=&quot;#aren-t-uuids-the-solution-to-all-this&quot; aria-label=&quot;Anchor link for: aren-t-uuids-the-solution-to-all-this&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;It depends. While UUIDs offer numerical representation and are excellent for unique key generation across distributed systems, they are not always the best choice:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;In comparison to BIGINT, there are few real-world scenarios that necessitate the full range of UUIDs. Premature optimisation most often isn’t worthwhile for most solutions.&lt;&#x2F;li&gt;
&lt;li&gt;UUIDs’ 16-byte (128-bit) storage size can be less efficient than many text identifiers. Even BIGINT, at 8 bytes (64 bits), is more storage-efficient. This inefficiency extends to indexes, joins, and other operations.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;ul&gt;
&lt;li&gt;A matter of opinion: UUIDs are plain ugly.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Moreover, different versions of UUIDs offer varying benefits. For example, UUIDv1 includes a timestamp component, making it somewhat sortable, while UUIDv4 is entirely random. Even sortable UUIDs may not offer significant advantages over more streamlined options like BIGINT or carefully structured text identifiers.&lt;&#x2F;p&gt;
&lt;p&gt;In most cases, the trade-offs in performance and readability make UUIDs less appealing unless their globally unique nature is essential across distributed systems.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;real-life-examples&quot;&gt;Real-Life Examples&lt;a class=&quot;zola-anchor&quot; href=&quot;#real-life-examples&quot; aria-label=&quot;Anchor link for: real-life-examples&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Let’s move beyond theory and explore practical examples:&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; sessions&lt;&#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;    token &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;    user_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; users(user_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;);&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;    sku &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;    label &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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;    ...&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; documents&lt;&#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;    document_id &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;    ...&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;In these cases, using text identifiers as primary keys might seem logical because:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;They serve as natural keys for the entity.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;ul&gt;
&lt;li&gt;They are likely propagated outside the service scope, hence needing storage anyway.&lt;&#x2F;li&gt;
&lt;li&gt;They are not expected to change.&lt;&#x2F;li&gt;
&lt;li&gt;The storage impact for a single table seems negligible.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;However, this logic falters under scrutiny. Text identifiers often propagate beyond the service scope, leading to pressure to update them. Updating primary keys is no trivial matter. Consider:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;While SKUs ideally never change, real-world scenarios like rebranding, product consolidation, or supplier changes can necessitate updates. Despite their appeal, SKUs are poor primary key candidates.&lt;&#x2F;li&gt;
&lt;li&gt;Randomly generated text identifiers (like session tokens) will… TBD.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The real issue arises when referencing these identifiers across tables:&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; session_logs&lt;&#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;    log_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BIGINT GENERATED ALWAYS AS 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;span&gt;    token &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT NOT NULL REFERENCES sessions&lt;&#x2F;span&gt;&lt;span&gt;(token),&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;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; product_reviews&lt;&#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;    review_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;    product_sku &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; products(sku),&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;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; customer_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;    order_id &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;    customer_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; customers(customer_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;);&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; document_revisions&lt;&#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;    revision_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;    document_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; documents(document_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;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In such scenarios, text identifiers can become problematic. Firstly, you &lt;strong&gt;lose the ability to modify them&lt;&#x2F;strong&gt;. Secondly, the &lt;strong&gt;increased storage requirements&lt;&#x2F;strong&gt; become evident. For instance, an additional 100 bytes per reference across a million records results in an extra 100 MB of storage for just one reference.&lt;&#x2F;p&gt;
&lt;p&gt;However, storage isn’t the biggest concern; &lt;strong&gt;indexing&lt;&#x2F;strong&gt; is. In PostgreSQL, indexing text fields - whether as primary or foreign keys - requires significantly more space than indexing numeric fields. This leads to bloated indexes, slower lookups, and more fragmented operations, particularly in queries relying on foreign key relationships. As a result, the query planner might resort to full table scans instead of index scans, further degrading performance.&lt;&#x2F;p&gt;
&lt;p&gt;These performance issues often remain undetected in development or testing environments but can cause significant disruptions in production.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;reintroducing-text-identifiers-effectively&quot;&gt;Reintroducing Text Identifiers Effectively&lt;a class=&quot;zola-anchor&quot; href=&quot;#reintroducing-text-identifiers-effectively&quot; aria-label=&quot;Anchor link for: reintroducing-text-identifiers-effectively&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The goal of this post is not to discourage the use of text identifiers entirely but to highlight why they are unsuitable as primary keys. Here are some strategies to handle them effectively:&lt;&#x2F;p&gt;
&lt;h4 id=&quot;1-introduce-a-surrogate-key&quot;&gt;1. Introduce a Surrogate Key&lt;a class=&quot;zola-anchor&quot; href=&quot;#1-introduce-a-surrogate-key&quot; aria-label=&quot;Anchor link for: 1-introduce-a-surrogate-key&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h4&gt;
&lt;p&gt;One straightforward solution is to introduce a surrogate primary key, replacing references to text identifiers:&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;    product_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED BY DEFAULT AS 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;span&gt;    sku &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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;    label &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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;    ...&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; products_by_sku&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; products(sku);&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; product_reviews&lt;&#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;    review_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;    product_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; products(product_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;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This approach maintains efficient retrieval by SKU via an index.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;2-use-mapping-tables-for-greater-flexibility&quot;&gt;2. Use Mapping Tables for Greater Flexibility&lt;a class=&quot;zola-anchor&quot; href=&quot;#2-use-mapping-tables-for-greater-flexibility&quot; aria-label=&quot;Anchor link for: 2-use-mapping-tables-for-greater-flexibility&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h4&gt;
&lt;p&gt;Mapping tables allow you 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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;* 	Update identifiers without affecting the parent entity.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;* 	Maintain a history of text identifiers linked to a specific entity.&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;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;    product_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED BY DEFAULT AS 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;span&gt;    label &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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;    ...&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; product_skus&lt;&#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_sku_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED BY DEFAULT AS 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;span&gt;    product_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; products(product_id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    sku &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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;    created_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMPTZ 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;    deleted_at &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TIMESTAMPTZ&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 UNIQUE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; unique_product_skus&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; product_skus (product_id, sku) &lt;&#x2F;span&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;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This approach accommodates changing SKUs without compromising data integrity. A related use case could be linking a mapping table to a product variant:&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; product_variants&lt;&#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;    variant_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED BY DEFAULT 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;INT NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; products(product_id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    sku &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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 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;    ...&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;h4 id=&quot;3-decompose-the-text-identifier-s-meaning&quot;&gt;3. Decompose the Text Identifier’s Meaning&lt;a class=&quot;zola-anchor&quot; href=&quot;#3-decompose-the-text-identifier-s-meaning&quot; aria-label=&quot;Anchor link for: 3-decompose-the-text-identifier-s-meaning&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h4&gt;
&lt;p&gt;Text identifiers often embed meaningful information, such as regions, dates, or categories. By decomposing the identifier, you can store this contextual data separately, improving both flexibility and performance.&lt;&#x2F;p&gt;
&lt;p&gt;For example, instead of storing an order identifier like ORD-EMEA-00789 directly, you can design a more robust 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;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; 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;    order_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED BY DEFAULT 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;    region_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT NOT NULL REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; regions(region_id),&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    order_date &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DATE 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;    ...&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; regions&lt;&#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;    region_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED BY DEFAULT 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;    code &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;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;    ...&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; regions (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;name&lt;&#x2F;span&gt;&lt;span&gt;, code) &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;Europe, the Middle East, and Africa&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;EMEA&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;Asia Pacific&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;APAC&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;To&lt;&#x2F;span&gt;&lt;span&gt; generate a user&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;-&lt;&#x2F;span&gt;&lt;span&gt;friendly order &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;number&lt;&#x2F;span&gt;&lt;span&gt;, you can &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;create&lt;&#x2F;span&gt;&lt;span&gt; a &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;function&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 OR REPLACE FUNCTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; get_order_number&lt;&#x2F;span&gt;&lt;span&gt;(p_order_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;RETURNS TEXT 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;DECLARE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    v_region_code &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;    v_formatted_order_id &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;    v_order_number &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;BEGIN&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; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;code&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; INTO&lt;&#x2F;span&gt;&lt;span&gt; v_region_code&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;    JOIN&lt;&#x2F;span&gt;&lt;span&gt; regions 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; o&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;region_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; r&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;region_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;order_id&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; p_order_id;&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;    IF NOT&lt;&#x2F;span&gt;&lt;span&gt; FOUND &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;THEN&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 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;    END IF&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;    v_formatted_order_id :&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; TO_CHAR(p_order_id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;FM00000&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;    v_order_number :&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;ORD-&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; v_region_code &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&gt; v_formatted_order_id;&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;    RETURN&lt;&#x2F;span&gt;&lt;span&gt; v_order_number;&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;$$ &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LANGUAGE&lt;&#x2F;span&gt;&lt;span&gt; plpgsql;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This method enables you to store and retrieve structured data efficiently while still providing a readable and meaningful identifier for external use.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;4-reversible-text-ids&quot;&gt;4. Reversible Text IDs&lt;a class=&quot;zola-anchor&quot; href=&quot;#4-reversible-text-ids&quot; aria-label=&quot;Anchor link for: 4-reversible-text-ids&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h4&gt;
&lt;p&gt;For scenarios requiring enhanced security or privacy, where identifiers should not be easily enumerable or predictable, you can use reversible text-based IDs. These allow you to present users with seemingly random text representations while maintaining efficient numerical storage internally.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;sqids.org&#x2F;&quot;&gt;Sqids&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt; is a project that can help achieve this. It generates URL-friendly unique identifiers from numbers and can encode multiple numerical identifiers into a single string. Here’s an example using the Sqids 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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[42] -&amp;gt; JgaEBgznCpUZo3Kk&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;[42, 430004] -&amp;gt; lTiYlvsGkh59m1PQ&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The generated identifiers are reversible with knowledge of the shared alphabet, allowing you to decode requests without hitting the database, which can be beneficial in high-throughput environments. This technique is particularly useful for user, account, or session identifiers, balancing the need for security with operational efficiency.&lt;&#x2F;p&gt;
&lt;p&gt;However, it’s crucial to remember that this is not a substitute for robust security practices. Proper authentication and authorisation mechanisms are still necessary to secure your application.&lt;&#x2F;p&gt;
&lt;p&gt;Use text identifiers where they genuinely help, and stick to numeric primary keys where they don&#x27;t - that keeps the schema both efficient and maintainable.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;5-leverage-generate-columns&quot;&gt;5. Leverage Generate columns&lt;a class=&quot;zola-anchor&quot; href=&quot;#5-leverage-generate-columns&quot; aria-label=&quot;Anchor link for: 5-leverage-generate-columns&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h4&gt;
&lt;p&gt;In edge case scenarios, where keeping text identifiers with embedded context is necessary, &lt;strong&gt;generated columns&lt;&#x2F;strong&gt; in PostgreSQL can be a valuable feature. Since version 12, PostgreSQL allows defining columns whose values are automatically computed from other columns in the table. This ensures consistency without manual intervention.&lt;&#x2F;p&gt;
&lt;p&gt;For example, you can define a function to handle the formatting logic:&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 OR REPLACE FUNCTION&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; get_formatted_order_id&lt;&#x2F;span&gt;&lt;span&gt;(p_region_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT&lt;&#x2F;span&gt;&lt;span&gt;, p_order_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;RETURNS TEXT 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;DECLARE&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    v_region_code &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;BEGIN&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    v_region_code :&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;= CASE&lt;&#x2F;span&gt;&lt;span&gt; p_region_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;        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;EMEA&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;APAC&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;OTHER&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;&#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: #9ECBFF;&quot;&gt; &amp;#39;ORD-&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span&gt; v_region_code &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&gt; LPAD(p_order_id::&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: #79B8FF;&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;0&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;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;$$ &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;LANGUAGE&lt;&#x2F;span&gt;&lt;span&gt; plpgsql IMMUTABLE;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The function is marked IMMUTABLE because PostgreSQL requires generated columns to use only immutable functions-those guaranteed to return the same result for the same input every 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: #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;    order_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT GENERATED BY DEFAULT 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;    region_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT 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;    order_date &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DATE 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;    formatted_order_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT GENERATED ALWAYS 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&gt;        get_formatted_order_id(region_id, order_id)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ) STORED&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 setup ensures that &lt;code&gt;formatted_order_id&lt;&#x2F;code&gt; is automatically updated whenever &lt;code&gt;region_id&lt;&#x2F;code&gt; or &lt;code&gt;order_id&lt;&#x2F;code&gt; changes. The column is physically stored, enhancing read performance for frequently queried data.&lt;&#x2F;p&gt;
&lt;p&gt;Using generated columns can simplify maintaining consistency in derived values like formatted text identifiers. However, be mindful of their characteristics, such as:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automated Updates&lt;&#x2F;strong&gt;: The system automatically recomputes the column’s value when the referenced columns change.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Immutability Requirements&lt;&#x2F;strong&gt;: Only immutable functions can be used, ensuring reliable and consistent computation.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;a class=&quot;zola-anchor&quot; href=&quot;#wrapping-up&quot; aria-label=&quot;Anchor link for: wrapping-up&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Text identifiers will stay with us, and that&#x27;s great. They’re human-readable, memorable, and can pack a lot of meaningful context into a simple string. They make external interactions smoother, whether it’s a customer referencing an order number or a support team tracing an issue. They even add a bit of charm and personality to otherwise sterile identifiers.&lt;&#x2F;p&gt;
&lt;p&gt;However it&#x27;s important to keep their usage in control. The nice rule I&#x27;ve heard is&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Use text identifiers when &lt;strong&gt;communicating externally&lt;&#x2F;strong&gt; - for example, in URLs or API responses&lt;&#x2F;li&gt;
&lt;li&gt;Internally, always rely on &lt;strong&gt;numerical IDs&lt;&#x2F;strong&gt; - surrogate keys like INT or BIGINT (even UUID if you go for planet dominanance) where necessary, to maintain database efficiency and integrity.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This approach allows you to leverage the strengths of text identifiers for external communication while keeping your database optimised for performance and scalability. By storing text identifiers in dedicated fields and using numeric primary keys for internal operations, you achieve a the right balance and do best to maintain the system performance.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>We need to talk about ENUMs</title>
        <published>2024-09-04T00:00:00+00:00</published>
        <updated>2024-09-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/postgresql-enums/"/>
        <id>https://boringsql.com/posts/postgresql-enums/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/postgresql-enums/">&lt;p&gt;Designing a database schema, whether for a new application or a new feature, always raises a lot of questions. The choices you make can have a big impact on how well your database performs and how easy it is to maintain and scale. Whether you’re just getting started with PostgreSQL or consider yourself a seasoned pro, it’s easy to rely on old habits or outdated advice. In this article, I want to take a fresh look at one of those topics that often sparks debate: the use of ENUMs in PostgreSQL.&lt;&#x2F;p&gt;
&lt;p&gt;I have to admit, not so long ago, I would advise &quot;don&#x27;t use ENUMs&quot; without thinking about it too much. Relying only on random articles and some personal but outdated experience, this had been my go-to answer for some years. And while there were several limitations of ENUMs in PostgreSQL in the (distant) past, the support has improved a long time ago.&lt;&#x2F;p&gt;
&lt;p&gt;The improvements are so long-standing that:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL 9.1 (released in 2011) introduced the option to add new values to ENUMs &lt;strong&gt;without a table rewrite&lt;&#x2F;strong&gt;. From what I can say this fact alone remained the source of biggest misconception when thinking about ENUMs.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Renaming of values&lt;&#x2F;strong&gt; was added in version 10 (2017).&lt;&#x2F;li&gt;
&lt;li&gt;The ability to &lt;code&gt;ALTER TYPE ... ADD VALUE&lt;&#x2F;code&gt; in a transaction block was introduced in PostgreSQL 12 (2019).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And new features coming (at the time of writing this article) with &lt;strong&gt;PostgreSQL 17&lt;&#x2F;strong&gt; allow the use of newly added values within the same transaction block (previously not possible without an explicit commit).&lt;&#x2F;p&gt;
&lt;p&gt;Therefore, I want to correct even myself and say—let&#x27;s give ENUMs another chance. This article will go into detail and help us correctly decide when it makes sense to use them or not.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-are-enums-implemented&quot;&gt;How are ENUMs Implemented?&lt;a class=&quot;zola-anchor&quot; href=&quot;#how-are-enums-implemented&quot; aria-label=&quot;Anchor link for: how-are-enums-implemented&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Every stored ENUM value occupies 4 bytes on disk. This is the size of the OID, representing the actual ENUM value, stored as a row within the &lt;code&gt;pg_enum&lt;&#x2F;code&gt; table. You can see this yourself by querying the table directly, but it will come by default with no data defined.&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;# select * from pg_catalog.pg_enum;&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; oid | enumtypid | enumsortorder | enumlabel&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;But once you define a new ENUM data type, 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;CREATE TYPE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; order_status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; AS&lt;&#x2F;span&gt;&lt;span&gt; ENUM (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;new&amp;#39;&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;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;processing&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;shipped&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &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;p&gt;You will get the actual OIDs.&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;# select * from pg_catalog.pg_enum;&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;  oid   | enumtypid | enumsortorder | enumlabel&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; 211018 |    211016 |             1 | new&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211020 |    211016 |             2 | pending&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211022 |    211016 |             3 | processing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211024 |    211016 |             4 | shipped&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211026 |    211016 |             5 | delivered&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The table straightforwardly identifies both the type and individual values, but also the &lt;code&gt;enumsortorder&lt;&#x2F;code&gt;, which is the foundation for &lt;code&gt;BEFORE&#x2F;AFTER&lt;&#x2F;code&gt; new value(s) definition.&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 TYPE&lt;&#x2F;span&gt;&lt;span&gt; order_status &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD VALUE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;cancelled&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; BEFORE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;shipped&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;Resulting in:&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;  oid   | enumtypid | enumsortorder | enumlabel&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; 211018 |    211016 |             1 | new&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211020 |    211016 |             2 | pending&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211022 |    211016 |             3 | processing&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211024 |    211016 |             4 | shipped&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211026 |    211016 |             5 | delivered&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 211027 |    211016 |           3.5 | cancelled&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;To confirm the actual storage size of the ENUM value, you can use a sample table, the previously defined type &lt;code&gt;order_status&lt;&#x2F;code&gt;, a single data row, and the &lt;code&gt;pg_column_size&lt;&#x2F;code&gt; function.&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; 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;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 style=&quot;color: #F97583;&quot;&gt;    status&lt;&#x2F;span&gt;&lt;span&gt; order_status &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;    order_date &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; orders (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, order_date) &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;new&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2024-09-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;This gives you confirmation of the actual 4-byte storage.&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;# SELECT id, status, pg_column_size(status) AS status_size&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; id | status | status_size&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 | new    |           4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Returning to the &lt;code&gt;pg_enum&lt;&#x2F;code&gt; table, the structure of the table provides other important clues:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;enumlabel&lt;&#x2F;code&gt; is of type &lt;code&gt;name&lt;&#x2F;code&gt;, which is effectively a 63-byte varchar for storing system identifiers.&lt;&#x2F;li&gt;
&lt;li&gt;The &lt;code&gt;UNIQUE CONSTRAINT pg_enum_typid_label_index&lt;&#x2F;code&gt; prevents us from creating two values with the same name.&lt;&#x2F;li&gt;
&lt;li&gt;However, given the nature of &lt;code&gt;VARCHAR&lt;&#x2F;code&gt;, it is case-sensitive, allowing you to define &lt;code&gt;new&lt;&#x2F;code&gt; and &lt;code&gt;NEW&lt;&#x2F;code&gt; as two distinct values.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;life-with-enums&quot;&gt;Life with ENUMs&lt;a class=&quot;zola-anchor&quot; href=&quot;#life-with-enums&quot; aria-label=&quot;Anchor link for: life-with-enums&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Now that we understand how ENUMs are implemented, let&#x27;s review the lifecycle of such a data type in a typical database.&lt;&#x2F;p&gt;
&lt;p&gt;As demonstrated above, &lt;strong&gt;creation of new ENUMs&lt;&#x2F;strong&gt; is simple, and since version 12 (and expanded in 17), it&#x27;s possible even in transaction blocks, making it easier to work with database migration tools without needing to explicitly worry about transaction management.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Adding new values&lt;&#x2F;strong&gt; does not require a table rewrite and can be done without further hesitation. You can also safely &lt;strong&gt;rename existing values&lt;&#x2F;strong&gt;. Similarly, you can use ENUMs in arrays, indexes, or compare them as needed (based on their 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;ALTER TYPE name ADD VALUE&lt;&#x2F;span&gt;&lt;span&gt; [ IF NOT EXISTS ] new_enum_value [ { BEFORE | AFTER } neighbor_enum_value ]&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 TYPE name&lt;&#x2F;span&gt;&lt;span&gt; RENAME &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUE&lt;&#x2F;span&gt;&lt;span&gt; existing_enum_value &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TO&lt;&#x2F;span&gt;&lt;span&gt; new_enum_value&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It&#x27;s also important to mention that ENUMs allow you to use a default value, 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;status order_status NOT NULL DEFAULT &amp;#39;new&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Unfortunately, this is where the flexibility ends at the moment. There&#x27;s no easy way to remove or re-order existing values (please, don&#x27;t modify &lt;code&gt;pg_enum&lt;&#x2F;code&gt;—or any system tables, for that matter). The topic of removing existing ENUM value(s) can easily open up a whole discussion about the best way to &#x27;deprecate&#x27; and drop a value over time. The only suitable way to perform these operations is by creating a new type and altering the column. Please be aware of the ways described in &lt;strong&gt;&lt;a href=&quot;&#x2F;posts&#x2F;how-not-to-change-postgresql-column-type&#x2F;&quot;&gt;How not to change PostgreSQL column type&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt; will apply in this scenario.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;using-check-constraints-as-an-alternative-to-enums&quot;&gt;Using CHECK Constraints as an Alternative to ENUMs&lt;a class=&quot;zola-anchor&quot; href=&quot;#using-check-constraints-as-an-alternative-to-enums&quot; aria-label=&quot;Anchor link for: using-check-constraints-as-an-alternative-to-enums&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;While ENUMs are a powerful tool in PostgreSQL for enforcing a fixed set of values in a column, they come with the limitations mentioned above. An alternative approach to achieving similar functionality is to use a CHECK constraint with a TEXT column. This method offers more flexibility when managing the allowed values, especially in scenarios where the set of valid values might change frequently.&lt;&#x2F;p&gt;
&lt;p&gt;A CHECK constraint can be applied to a column to enforce that its value must be one of a predefined set of values. Here’s how you can define a table using a CHECK constraint as an alternative to an ENUM:&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; 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;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 style=&quot;color: #F97583;&quot;&gt;    status 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;    order_date &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 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;status IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;new&amp;#39;&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;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;processing&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;shipped&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &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;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;Modification of the constraints is possible, but you will always have to &lt;code&gt;DROP CONSTRAINT&lt;&#x2F;code&gt; and then &lt;code&gt;ADD CONSTRAINT&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; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;DROP CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; orders_status_check;&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; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; orders_status_check &lt;&#x2F;span&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;status IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;new&amp;#39;&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;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;processing&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;shipped&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &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;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;returned&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;While this operation is not as &#x27;heavyweight&#x27; as changing the data type, please be aware that an &lt;strong&gt;ACCESS EXCLUSIVE&lt;&#x2F;strong&gt; lock will be acquired for the entire table—meaning no other operations can be performed on the table. With a growing table size and increasing concurrency, this might still lead to application-level downtime.&lt;&#x2F;p&gt;
&lt;p&gt;Another limitation is the lack of direct support for sorting, meaning you will have to implement it manually in every query or abstract the sorting detail using a 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: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; id, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status&lt;&#x2F;span&gt;&lt;span&gt;, order_date&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;ORDER BY&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ARRAY_POSITION(&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        ARRAY&lt;&#x2F;span&gt;&lt;span&gt;[&amp;#39;new&amp;#39;, &amp;#39;pending&amp;#39;, &amp;#39;processing&amp;#39;, &amp;#39;shipped&amp;#39;, &amp;#39;delivered&amp;#39;, &amp;#39;returned&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;        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;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;reference-tables-as-the-real-alternative&quot;&gt;Reference Tables as the Real Alternative&lt;a class=&quot;zola-anchor&quot; href=&quot;#reference-tables-as-the-real-alternative&quot; aria-label=&quot;Anchor link for: reference-tables-as-the-real-alternative&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;It might be surprising, but one of the motivations for using either ENUMs or CHECK constraints is to avoid additional JOINs. It&#x27;s no surprise that JOINs are a source of pain for many people who struggle with SQL, and therefore they try to limit their use. While there might be negligible impact on performing such an operation, there are no other reasons not to use reference tables—it’s not storage (unless you over-optimise with &lt;code&gt;smallint&lt;&#x2F;code&gt; and extremely large datasets) and definitely not operational considerations.&lt;&#x2F;p&gt;
&lt;p&gt;In fact, reference tables are often better in most cases. They not only address all the use cases supported by both ENUMs and CHECK constraints, but they can also resolve the limitations of these methods.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;creating-and-using-a-reference-table&quot;&gt;Creating and Using a Reference Table&lt;a class=&quot;zola-anchor&quot; href=&quot;#creating-and-using-a-reference-table&quot; aria-label=&quot;Anchor link for: creating-and-using-a-reference-table&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Let’s create a reference table similar to the ENUM data type we’ve discussed 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;CREATE TABLE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; order_statuses&lt;&#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;    status_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&gt;    status_name &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;CREATE UNIQUE INDEX&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; unique_status_name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ON&lt;&#x2F;span&gt;&lt;span&gt; order_statuses (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;LOWER&lt;&#x2F;span&gt;&lt;span&gt;(status_name));&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; order_statuses (status_name) &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;new&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;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;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;processing&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;shipped&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;delivered&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;cancelled&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;h3 id=&quot;updating-the-orders-table-to-use-the-reference-table&quot;&gt;Updating the Orders Table to Use the Reference Table&lt;a class=&quot;zola-anchor&quot; href=&quot;#updating-the-orders-table-to-use-the-reference-table&quot; aria-label=&quot;Anchor link for: updating-the-orders-table-to-use-the-reference-table&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;To use the reference table, you’ll need to alter your existing &lt;code&gt;orders&lt;&#x2F;code&gt; table. This involves dropping the current &lt;code&gt;status&lt;&#x2F;code&gt; column if it’s using an ENUM or CHECK constraint, and replacing it with a foreign key that references the &lt;code&gt;order_statuses&lt;&#x2F;code&gt; 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; orders &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;IF EXISTS 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;&#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; orders &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ADD&lt;&#x2F;span&gt;&lt;span&gt; COLUMN status_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;ADD CONSTRAINT&lt;&#x2F;span&gt;&lt;span&gt; fk_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;FOREIGN KEY&lt;&#x2F;span&gt;&lt;span&gt; (status_id) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;REFERENCES&lt;&#x2F;span&gt;&lt;span&gt; order_statuses(status_id);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;impact-of-using-reference-tables&quot;&gt;Impact of Using Reference Tables&lt;a class=&quot;zola-anchor&quot; href=&quot;#impact-of-using-reference-tables&quot; aria-label=&quot;Anchor link for: impact-of-using-reference-tables&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;While reference tables do not provide anything for free, such as ENUM ordering or simple CHECK constraints, they offer far greater flexibility. Here are some examples of what you can achieve with reference tables:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Custom Sorting Logic:&lt;&#x2F;strong&gt; You can implement specific sorting by adding an additional &lt;code&gt;sort_order&lt;&#x2F;code&gt; column to the &lt;code&gt;order_statuses&lt;&#x2F;code&gt; table. This allows you to easily change the order of statuses without altering the schema.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Deprecating Values:&lt;&#x2F;strong&gt; By adding a &lt;code&gt;deleted_at&lt;&#x2F;code&gt; column (or a similar field), you can deprecate certain status values. Combined with a trigger, you can prevent these deprecated values from being used in new data while maintaining historical records.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Renaming Values:&lt;&#x2F;strong&gt; Unlike ENUMs, renaming a status is straightforward - simply update the value in the &lt;code&gt;order_statuses&lt;&#x2F;code&gt; table.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Internationalisation:&lt;&#x2F;strong&gt; If your application needs to support multiple languages, you can extend the &lt;code&gt;order_statuses&lt;&#x2F;code&gt; table with additional columns for different languages or even create a separate lookup table that stores translations.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Additional Metadata:&lt;&#x2F;strong&gt; Reference tables can store extra information about each status, such as descriptions, colours (for UI purposes), or associated icons, which can be particularly useful in applications with rich user interfaces.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Having mentioned all advantages of using Reference Tables, it&#x27;s also important to mention the fact it&#x27;s not straightforward to set the default values for such a references. Definitely not in obvious way which ENUMs provide. The real alternative is to create table&#x2F;column without default value and alter it specifically for given database using &lt;code&gt;ALTER COLUMN ... SET DEFAULT&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; 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;ALTER&lt;&#x2F;span&gt;&lt;span&gt; COLUMN status_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SET DEFAULT&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; status_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; order_statuses &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WHERE&lt;&#x2F;span&gt;&lt;span&gt; status_name &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&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;performance-considerations-enums-check-constraints-and-reference-tables&quot;&gt;Performance Considerations: ENUMs, CHECK Constraints, and Reference Tables&lt;a class=&quot;zola-anchor&quot; href=&quot;#performance-considerations-enums-check-constraints-and-reference-tables&quot; aria-label=&quot;Anchor link for: performance-considerations-enums-check-constraints-and-reference-tables&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Thanks to their internal representation as OIDs, ENUMs offer predictable performance and indexing strategies, often outperforming text-based CHECK constraints. Since ENUM comparisons rely on integers, they tend to be faster than string comparisons required for CHECK constraints. Reference tables, on the other hand, inherently require a JOIN, which introduces the overhead of accessing two tables and potentially working with multiple indexes.&lt;&#x2F;p&gt;
&lt;p&gt;While I initially planned to publish benchmarks comparing the performance of all three approaches, I decided not to include specific numbers. It’s true that ENUMs consistently performed the fastest, and reference tables were somewhat slower due to the necessary JOIN. However, as with most benchmarks, the scenarios were artificial. The actual performance difference, though measurable, is unlikely to be significant in practical applications. In any reasonably complex query, other factors are far more likely to impact performance than whether you use ENUMs or not.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion-use-enums-where-they-make-sense&quot;&gt;Conclusion: Use ENUMs Where They Make Sense&lt;a class=&quot;zola-anchor&quot; href=&quot;#conclusion-use-enums-where-they-make-sense&quot; aria-label=&quot;Anchor link for: conclusion-use-enums-where-they-make-sense&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;As with many decisions in software development, the use of ENUMs comes down to &quot;it depends.&quot; This article has provided clarity on how ENUMs work, where their limitations lie, and how to make an informed decision about when to use them.&lt;&#x2F;p&gt;
&lt;p&gt;To recap:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ENUMs&lt;&#x2F;strong&gt; are ideal for small, static sets of values that require strict type enforcement and minimal changes, preferably data types that are not directly tied to the business logic.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;CHECK constraints&lt;&#x2F;strong&gt; offer more flexibility than ENUMs, making them a good choice when the allowed values might change, but the data type remains simple, and there&#x27;s no&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Reference tables&lt;&#x2F;strong&gt; provide the most flexibility and scalability, making them the go-to choice for dynamic or complex value sets and when you want to avoid the limitations of ENUMs and CHECK constraints.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;But in the end, no plan ever survives first contact with reality. It’s essential to revisit and reconsider your decisions as your application evolves, ensuring that your database schema continues to serve the needs of your project as effectively as possible.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;And while it&#x27;s not meant as advice - personally I&#x27;m still going to gravitate towards the advice &quot;don&#x27;t use ENUMs&quot; though. I&#x27;m yet to see value-based field that does not change over time.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Beyond Simple Upserts with MERGE in PostgreSQL</title>
        <published>2024-08-25T00:00:00+00:00</published>
        <updated>2024-08-25T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/beyond-upserts-with-merge/"/>
        <id>https://boringsql.com/posts/beyond-upserts-with-merge/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/beyond-upserts-with-merge/">&lt;p&gt;Understanding how comfortable someone is with databases and SQL often comes down to the features they use. In PostgreSQL, one such feature that distinguishes more advanced users is the &lt;code&gt;MERGE&lt;&#x2F;code&gt; command, introduced in version 15 and expanded in version 17 (in beta at the time of writing this article). Before &lt;code&gt;MERGE&lt;&#x2F;code&gt;, developers typically relied on &lt;code&gt;INSERT ... ON CONFLICT DO UPDATE&lt;&#x2F;code&gt; for upserts—a method introduced in PostgreSQL 9.5 that has since become a staple in many developers&#x27; toolkits.&lt;&#x2F;p&gt;
&lt;p&gt;While &lt;code&gt;ON CONFLICT&lt;&#x2F;code&gt; offers a straightforward solution for simple upsert scenarios, it can quickly become limiting as business logic grows in complexity. This is where the &lt;code&gt;MERGE&lt;&#x2F;code&gt; command excels. Introduced in the SQL:2003 standard, &lt;code&gt;MERGE&lt;&#x2F;code&gt; allows for more sophisticated data synchronisation tasks by combining multiple operations—such as conditional inserts, updates, and deletes—into a single, atomic statement.&lt;&#x2F;p&gt;
&lt;p&gt;In this article, we’ll explore the capabilities of the &lt;code&gt;MERGE&lt;&#x2F;code&gt; command, comparing it with traditional upsert methods and examining how it can streamline database operations. Through practical examples, we&#x27;ll illustrate how &lt;code&gt;MERGE&lt;&#x2F;code&gt; can simplify even the most complex workflows, making it an indispensable tool for developers working with PostgreSQL.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;hands-on-example-managing-a-score-points-system-for-a-mobile-game&quot;&gt;Hands-on Example: Managing a Score Points System for a Mobile Game&lt;a class=&quot;zola-anchor&quot; href=&quot;#hands-on-example-managing-a-score-points-system-for-a-mobile-game&quot; aria-label=&quot;Anchor link for: hands-on-example-managing-a-score-points-system-for-a-mobile-game&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Imagine you’re managing a tournament score system for an online game, where players earn special status by participating in weekly tournaments. The simple business logic would be:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Players can earn score points by participating in the tournament.&lt;&#x2F;li&gt;
&lt;li&gt;Players must participate in all tournaments to maintain their score (i.e., maintain the streak).&lt;&#x2F;li&gt;
&lt;li&gt;Players who participate in a second consecutive tournament can achieve the status of &lt;code&gt;veteran&lt;&#x2F;code&gt; (if they reach 1,000 score points) or gain the status of &lt;code&gt;star&lt;&#x2F;code&gt; (if they reach 100 or more score points).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The score points are updated weekly, based on the outcome of the previous week&#x27;s participation.&lt;&#x2F;p&gt;
&lt;p&gt;We will start with a simple database schema consisting of a table &lt;code&gt;tournament_scores&lt;&#x2F;code&gt; that tracks (as the name suggests) only active users.&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; tournament_scores&lt;&#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;    player_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&gt;    player_name &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;TEXT UNIQUE 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;    score &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INT NOT NULL 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 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;newbie&amp;#39;&lt;&#x2F;span&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;status IN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;newbie&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;veteran&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;star&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;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And populate it with some sample 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;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; tournament_scores (player_name, score, &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;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;PlayerOne&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;900&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;newbie&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;),   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Regular player, close to Veteran promotion&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;PlayerTwo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;1200&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;veteran&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;), &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Already a Veteran player&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;PlayerThree&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;300&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;newbie&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;); &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Regular player with a lower score&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;upsert-using-on-conflict&quot;&gt;Upsert using &lt;code&gt;ON CONFLICT&lt;&#x2F;code&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#upsert-using-on-conflict&quot; aria-label=&quot;Anchor link for: upsert-using-on-conflict&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;When activity data is received, there are multiple ways to process it. &lt;code&gt;ON CONFLICT&lt;&#x2F;code&gt; is used here for demonstration purposes.&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; tournament_scores (player_name, score)&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;&#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;PlayerOne&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50&lt;&#x2F;span&gt;&lt;span&gt;),    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerOne&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;PlayerTwo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;120&lt;&#x2F;span&gt;&lt;span&gt;),   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerTwo&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;PlayerFour&amp;#39;&lt;&#x2F;span&gt;&lt;span&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;span style=&quot;color: #6A737D;&quot;&gt;-- New player without an account, PlayerFour&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; CONFLICT (player_name)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;DO &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;UPDATE SET&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    score &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; tournament_scores&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; EXCLUDED&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#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 = CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;tournament_scores&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; EXCLUDED&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&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; 1000&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;veteran&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;newbie&amp;#39;&lt;&#x2F;span&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;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This performs the basic requirements, updating the scores and possibly evaluating the status for existing tournament users. To remove users who no longer participated (and hence broke the streak) and apply other conditional logic, you would need to use separate statements.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;handling-upserts-with-merge&quot;&gt;Handling Upserts with &lt;code&gt;MERGE&lt;&#x2F;code&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#handling-upserts-with-merge&quot; aria-label=&quot;Anchor link for: handling-upserts-with-merge&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Now, let’s introduce the &lt;code&gt;MERGE&lt;&#x2F;code&gt; command and implement the same logic as 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;MERGE INTO&lt;&#x2F;span&gt;&lt;span&gt; tournament_scores ts&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; (&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;&#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;PlayerOne&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50&lt;&#x2F;span&gt;&lt;span&gt;),    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerOne&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;PlayerTwo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;120&lt;&#x2F;span&gt;&lt;span&gt;),   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerTwo&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;PlayerFour&amp;#39;&lt;&#x2F;span&gt;&lt;span&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;span style=&quot;color: #6A737D;&quot;&gt;-- New player without an account, PlayerFour&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; v(player_name, score_added)&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; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;player_name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&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;player_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;WHEN MATCHED THEN&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 SET&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      score &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&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;score_added&lt;&#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 = CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&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;score_added&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; 1000&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;veteran&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;newbie&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END&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 NOT MATCHED THEN&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&lt;&#x2F;span&gt;&lt;span&gt; (player_name, score)&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;v&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;player_name&lt;&#x2F;span&gt;&lt;span&gt;, &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;score_added&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;Here, we define the table &lt;code&gt;tournament_scores&lt;&#x2F;code&gt; as the &lt;strong&gt;target&lt;&#x2F;strong&gt; and the list of VALUES as the &lt;strong&gt;source&lt;&#x2F;strong&gt;. Using a conditional clause, we define the logic for both matched and unmatched entries, giving us both INSERT and UPDATE paths.&lt;&#x2F;p&gt;
&lt;p&gt;The evaluation paths in this case are called &lt;code&gt;when_clauses&lt;&#x2F;code&gt;. The &lt;code&gt;MERGE&lt;&#x2F;code&gt; statement allows you to specify multiple clauses with different conditions, for example, allowing you to award users &lt;code&gt;star&lt;&#x2F;code&gt; status if they gain more than 100 score points within a given tournament.&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;MERGE INTO&lt;&#x2F;span&gt;&lt;span&gt; tournament_scores ts&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; (&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;&#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;PlayerOne&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50&lt;&#x2F;span&gt;&lt;span&gt;),    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerOne&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;PlayerTwo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;120&lt;&#x2F;span&gt;&lt;span&gt;),   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerTwo&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;PlayerFour&amp;#39;&lt;&#x2F;span&gt;&lt;span&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;span style=&quot;color: #6A737D;&quot;&gt;-- New player without an account, PlayerFour&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; v(player_name, score_added)&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; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;player_name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&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;player_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;WHEN MATCHED AND&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;score_added&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 style=&quot;color: #F97583;&quot;&gt; THEN&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 SET&lt;&#x2F;span&gt;&lt;span&gt; score &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&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;score_added&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;star&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 MATCHED THEN&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 SET&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      score &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&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;score_added&lt;&#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 = CASE WHEN&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&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;score_added&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; 1000&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;veteran&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ELSE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;newbie&amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; END&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 NOT MATCHED THEN&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&lt;&#x2F;span&gt;&lt;span&gt; (player_name, score)&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;v&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;player_name&lt;&#x2F;span&gt;&lt;span&gt;, &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;score_added&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;Here’s a summary of how it works:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;MERGE&lt;&#x2F;code&gt; command evaluates the &lt;code&gt;when_clauses&lt;&#x2F;code&gt; in the order they are written.&lt;&#x2F;li&gt;
&lt;li&gt;If a row matches the condition specified in a clause, the action defined in that clause is performed, and the row is no longer eligible to be matched against subsequent &lt;code&gt;when_clauses&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;While we introduced multiple evaluation paths, the &lt;code&gt;MERGE&lt;&#x2F;code&gt; command in PostgreSQL goes beyond that and expands the &lt;code&gt;WHEN NOT MATCHED&lt;&#x2F;code&gt; clause, which effectively becomes &lt;code&gt;WHEN NOT MATCHED [BY TARGET]&lt;&#x2F;code&gt;. PostgreSQL allows you to specify &lt;code&gt;WHEN NOT MATCHED BY SOURCE&lt;&#x2F;code&gt; to perform the necessary merge statement for the data not present in the source table.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;handling-deletes-with-merge&quot;&gt;Handling DELETEs with &lt;code&gt;MERGE&lt;&#x2F;code&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#handling-deletes-with-merge&quot; aria-label=&quot;Anchor link for: handling-deletes-with-merge&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The functionality completely missing from regular upserts with &lt;code&gt;ON CONFLICT&lt;&#x2F;code&gt; is the ability to delete missing entries. In our sample scenario, we want to penalise players who broke their streak and did not participate in last week&#x27;s tournament by effectively deleting their entries from the target 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;MERGE INTO&lt;&#x2F;span&gt;&lt;span&gt; tournament_scores ts&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; (&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;&#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;PlayerOne&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;50&lt;&#x2F;span&gt;&lt;span&gt;),    &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerOne&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;PlayerTwo&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;120&lt;&#x2F;span&gt;&lt;span&gt;),   &lt;&#x2F;span&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- Add points for PlayerTwo&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;PlayerFour&amp;#39;&lt;&#x2F;span&gt;&lt;span&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;span style=&quot;color: #6A737D;&quot;&gt;-- New player without an account, PlayerFour&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; v(player_name, score_added)&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; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;player_name&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&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;player_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;WHEN MATCHED AND&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;score_added&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 style=&quot;color: #F97583;&quot;&gt; THEN&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 SET&lt;&#x2F;span&gt;&lt;span&gt; score &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&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;score_added&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;status =&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;#39;star&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 MATCHED THEN&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 SET&lt;&#x2F;span&gt;&lt;span&gt; score &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; ts&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;score&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; +&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;score_added&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 NOT MATCHED THEN&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&lt;&#x2F;span&gt;&lt;span&gt; (player_name, score)&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;v&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;player_name&lt;&#x2F;span&gt;&lt;span&gt;, &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;score_added&lt;&#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 NOT MATCHED BY&lt;&#x2F;span&gt;&lt;span&gt; SOURCE &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;THEN&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&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 introduction of the &lt;code&gt;DELETE&lt;&#x2F;code&gt; merge operation complements all possible &lt;code&gt;MERGE&lt;&#x2F;code&gt; outcomes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The first is &lt;code&gt;merge_update&lt;&#x2F;code&gt;, applicable to &lt;code&gt;WHEN MATCHED&lt;&#x2F;code&gt; and &lt;code&gt;WHEN NOT MATCHED&lt;&#x2F;code&gt; clauses, consisting of regular &lt;code&gt;UPDATE&lt;&#x2F;code&gt; operations.&lt;&#x2F;li&gt;
&lt;li&gt;The second is &lt;code&gt;merge_insert&lt;&#x2F;code&gt; for the &lt;code&gt;WHEN NOT MATCHED&lt;&#x2F;code&gt; clause, allowing (as the name implies) data to be inserted.&lt;&#x2F;li&gt;
&lt;li&gt;The &lt;code&gt;DELETE&lt;&#x2F;code&gt; we introduce is part of &lt;code&gt;merge_delete&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Technically, there&#x27;s also the ability to specify &lt;code&gt;DO NOTHING&lt;&#x2F;code&gt; for all available &lt;code&gt;when_clauses&lt;&#x2F;code&gt;, similar to upserts using &lt;code&gt;ON CONFLICT&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;using-merge-output&quot;&gt;Using &lt;code&gt;MERGE&lt;&#x2F;code&gt; Output&lt;a class=&quot;zola-anchor&quot; href=&quot;#using-merge-output&quot; aria-label=&quot;Anchor link for: using-merge-output&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Starting from PostgreSQL 17, the &lt;code&gt;MERGE&lt;&#x2F;code&gt; command has been extended to support &lt;code&gt;RETURNING&lt;&#x2F;code&gt; clauses to process merged data further. When an &lt;code&gt;INSERT&lt;&#x2F;code&gt; or &lt;code&gt;UPDATE&lt;&#x2F;code&gt; action is performed, the new values of the target table&#x27;s columns are used. When a &lt;code&gt;DELETE&lt;&#x2F;code&gt; is performed, the old values of the target table&#x27;s columns are used.&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;The &lt;code&gt;MERGE&lt;&#x2F;code&gt; command significantly enhances the ability to handle complex &lt;code&gt;INSERT&lt;&#x2F;code&gt; and &lt;code&gt;UPDATE&lt;&#x2F;code&gt; logic by allowing multiple operations within a single query while also capturing even the most intricate scenarios. In this article, we have explored the syntax and common use cases of &lt;code&gt;MERGE&lt;&#x2F;code&gt;. For further exploration, note that the &lt;code&gt;source&lt;&#x2F;code&gt; dataset is where you can perform any data transformations required, making &lt;code&gt;MERGE&lt;&#x2F;code&gt; particularly well-suited for ETL operations, data archival, cleanup tasks, and more.&lt;&#x2F;p&gt;
&lt;p&gt;Incorporating &lt;code&gt;MERGE&lt;&#x2F;code&gt; into your PostgreSQL toolkit can simplify database operations, reduce the risk of data inconsistencies, and streamline your&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Gentle Introduction to Window Functions in PostgreSQL</title>
        <published>2024-07-07T00:00:00+00:00</published>
        <updated>2024-07-07T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/window-functions-introduction/"/>
        <id>https://boringsql.com/posts/window-functions-introduction/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/window-functions-introduction/">&lt;p&gt;Understanding the relationship between data points is crucial. For instance, you might need to identify the most recent orders for each customer or track changes in sensor readings over time. Unlike aggregate functions, which summarise data into a single row, it is window functions that allow you to analyse data while preserving each row’s details. This is the core of the logic, but don’t worry if you struggle to imagine the difference, as we will cover all of it in this article.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL supports SQL window functions, facilitating complex calculations across related rows within a table. These functions are particularly useful for tasks such as ranking entries, calculating running totals, finding moving averages, and comparing individual entries. Mastering window functions can significantly enhance your data analysis capabilities.&lt;&#x2F;p&gt;
&lt;p&gt;You can easily use similar window functions as you would with aggregation. Let’s take a simple example of sensor readings:&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; sensor_readings&lt;&#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;    sensor_id &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;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value &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;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_time &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;);&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; sensor_readings (sensor_id, reading_value, reading_time) &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: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;32&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;7&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2024-07-01 11:24:34&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: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;33&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;2024-07-02 11:29:01&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: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;33&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;2024-07-02 12:03:59&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: #79B8FF;&quot;&gt;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;33&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: #9ECBFF;&quot;&gt;&amp;#39;2024-07-03 10:12:15&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: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;32&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;8&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2024-07-01 13:17:01&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: #79B8FF;&quot;&gt;2&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;35&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;8&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2024-07-02 09:18:11&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: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&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;1&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;2024-07-01 13:54:03&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: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;30&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;2024-07-01 14:12:09&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: #79B8FF;&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;31&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: #9ECBFF;&quot;&gt;&amp;#39;2024-07-02 16:07:43&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;When you start with the aggregation functions, it is straightforward for anybody familiar with SQL:&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;    sensor_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;    AVG&lt;&#x2F;span&gt;&lt;span&gt;(reading_value)&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; sensor_readings&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; sensor_id;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;with the output&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; sensor_id |         avg&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 | 30.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         2 | 34.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 | 32.9750000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While similar, using the window function AVG we can get almost same 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 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;    sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    AVG&lt;&#x2F;span&gt;&lt;span&gt;(reading_value) &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;PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; avg_reading_value&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; sensor_readings;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;such 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; sensor_id | reading_value |  avg_reading_value&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 |          32.7 | 32.9750000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 |          33.1 | 32.9750000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 |          33.1 | 32.9750000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 |          33.0 | 32.9750000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         2 |          32.8 | 34.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         2 |          35.8 | 34.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         3 |          29.1 | 30.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         3 |          30.3 | 30.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         3 |          31.5 | 30.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This reiterates the fundamental difference between the two sets of functions. As mentioned earlier, while the results for &lt;code&gt;sensor_id&lt;&#x2F;code&gt; are the same in both cases, the &lt;strong&gt;aggregate function&lt;&#x2F;strong&gt; summarised it into a single row (grouped by &lt;code&gt;sensor_id&lt;&#x2F;code&gt;), whereas the &lt;strong&gt;window function&lt;&#x2F;strong&gt; provides the value for the set of rows in the partition defined by &lt;code&gt;sensor_id&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;After showing the average in a window function, it’s important to note that traditional aggregation functions might not be the most helpful in the context of window function logic. Despite functions like &lt;code&gt;AVG&lt;&#x2F;code&gt;, &lt;code&gt;COUNT&lt;&#x2F;code&gt;, and &lt;code&gt;SUM&lt;&#x2F;code&gt;—which are the most used aggregation functions—being available as window functions, we used the above only to demonstrate the difference.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;over-clause&quot;&gt;OVER clause&lt;a class=&quot;zola-anchor&quot; href=&quot;#over-clause&quot; aria-label=&quot;Anchor link for: over-clause&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Before diving into the individual functions, let’s cover the syntax first. From the sample query above, you already get the basic syntax of the window functions, with the &lt;code&gt;OVER&lt;&#x2F;code&gt; clause being a primary identification of windowing functionality.&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;window_function ([expression...]) OVER window_definition&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In our example, the basic window functions can be similar to the aggregate ones— like &lt;code&gt;AVG&lt;&#x2F;code&gt;, &lt;code&gt;SUM&lt;&#x2F;code&gt; and &lt;code&gt;COUNT&lt;&#x2F;code&gt; - and a number of the functions we will cover shortly.&lt;&#x2F;p&gt;
&lt;p&gt;The window definition part specifies how the window function will see the data it works over. It can include:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Partitioning&lt;&#x2F;strong&gt; to divide the result sets into separate partitions (think groups in aggregate functions).&lt;&#x2F;li&gt;
&lt;li&gt;Definition of the &lt;strong&gt;ordering&lt;&#x2F;strong&gt; of the rows within each partition.&lt;&#x2F;li&gt;
&lt;li&gt;Specification of how to apply &lt;strong&gt;framing&lt;&#x2F;strong&gt; of the subset of rows for each row’s calculation.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;From all the components of the window syntax, only partitioning is mandatory.&lt;&#x2F;p&gt;
&lt;p&gt;If we revisit our first window function example above, you can identify the &lt;strong&gt;partitioning&lt;&#x2F;strong&gt; part (&lt;code&gt;PARTITION BY sensor_id&lt;&#x2F;code&gt;). As already mentioned, you can easily compare the partitioning logic to the grouping used in aggregate functions, with the same properties—like partitioning data segments by multiple fields, using functions and other logic.&lt;&#x2F;p&gt;
&lt;p&gt;With partitioning comes hand in hand &lt;strong&gt;ordering&lt;&#x2F;strong&gt; of the data. When you start thinking of row properties, instead of grouping data into a single row, order starts to make a difference. Let’s take 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&gt;window_function ([expression...]) &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;PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_time)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This will provide different data compared to unsorted ones. If you struggle to find the application for this, think of the moving average or row numbering.&lt;&#x2F;p&gt;
&lt;p&gt;The last component of the window definition is &lt;strong&gt;framing&lt;&#x2F;strong&gt;, allowing you to further limit the data over which the window function is calculated. There are two framing expressions: &lt;code&gt;ROWS BETWEEN&lt;&#x2F;code&gt; and &lt;code&gt;RANGE BETWEEN&lt;&#x2F;code&gt;. Using the framing, we can turn AVG from the above example to provide our first real use case when you might want to use window functions.&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;    reading_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    AVG&lt;&#x2F;span&gt;&lt;span&gt;(reading_value) &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;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;        PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_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;        ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_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;        ROWS BETWEEN&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; PRECEDING 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;&#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; moving_avg_reading_value&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; sensor_readings;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Implementing the moving average of the individual readings, taking into account a maximum of 3 rows including the current 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;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      reading_time      | sensor_id | reading_value | moving_avg_reading_value&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; 2024-07-01 11:24:34+02 |         1 |          32.7 |      32.9000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-02 11:29:01+02 |         1 |          33.1 |      32.9666666666666667&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-02 12:03:59+02 |         1 |          33.1 |      33.0666666666666667&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-03 10:12:15+02 |         1 |          33.0 |      33.0500000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-01 13:17:01+02 |         2 |          32.8 |      34.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-02 09:18:11+02 |         2 |          35.8 |      34.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-01 13:54:03+02 |         3 |          29.1 |      29.7000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-01 14:12:09+02 |         3 |          30.3 |      30.3000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; 2024-07-02 16:07:43+02 |         3 |          31.5 |      30.9000000000000000&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h2 id=&quot;exploring-window-functions&quot;&gt;Exploring Window functions&lt;a class=&quot;zola-anchor&quot; href=&quot;#exploring-window-functions&quot; aria-label=&quot;Anchor link for: exploring-window-functions&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Now that we have a foundational understanding of window functions and their components, let’s dive into specific window functions. We’ll cover some of the most commonly used functions, such as &lt;code&gt;ROW_NUMBER()&lt;&#x2F;code&gt;, &lt;code&gt;RANK()&lt;&#x2F;code&gt;, &lt;code&gt;DENSE_RANK()&lt;&#x2F;code&gt;, &lt;code&gt;LAG()&lt;&#x2F;code&gt;, &lt;code&gt;LEAD()&lt;&#x2F;code&gt;, &lt;code&gt;FIRST_VALUE()&lt;&#x2F;code&gt;, &lt;code&gt;LAST_VALUE()&lt;&#x2F;code&gt; and more. For each function, we’ll provide examples to illustrate their practical applications.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;row-number&quot;&gt;ROW_NUMBER&lt;a class=&quot;zola-anchor&quot; href=&quot;#row-number&quot; aria-label=&quot;Anchor link for: row-number&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The &lt;code&gt;ROW_NUMBER()&lt;&#x2F;code&gt; function assigns a unique sequential integer to rows within a partition of a result set, starting with one for the first row in each partition.&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;    sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&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;PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_time) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; row_num&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; sensor_readings;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This makes it easy to find the first&#x2F;last entries for a specified window. As an example, you can experiment with getting only the last reading for each hour.&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;    sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value&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;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        reading_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        reading_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&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;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;            PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id, date_trunc(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;hour&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, reading_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;            ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_time &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&gt;        ) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; row_num&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; sensor_readings&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; ranked_readings&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; row_num &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;h3 id=&quot;rank-and-dense-rank&quot;&gt;RANK and DENSE_RANK&lt;a class=&quot;zola-anchor&quot; href=&quot;#rank-and-dense-rank&quot; aria-label=&quot;Anchor link for: rank-and-dense-rank&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The &lt;code&gt;RANK()&lt;&#x2F;code&gt; and &lt;code&gt;DENSE_RANK()&lt;&#x2F;code&gt; functions are used to assign a rank to each row within a partition, based on the order of one or more values. These functions are useful when scoring the values and handling ties. The main difference between &lt;code&gt;RANK&lt;&#x2F;code&gt; and &lt;code&gt;DENSE_RANK&lt;&#x2F;code&gt; is how they deal with gaps.&lt;&#x2F;p&gt;
&lt;p&gt;Example use to find the highest reading_value per sensor:&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;    sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    RANK&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;PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_value &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;AS&lt;&#x2F;span&gt;&lt;span&gt; rank&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; sensor_readings;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If you consider the sorting for the &lt;code&gt;sensor_id&lt;&#x2F;code&gt; 1 in our sample seed the difference between &lt;code&gt;RANK&lt;&#x2F;code&gt; and &lt;code&gt;DENSE_RANK&lt;&#x2F;code&gt; is easy to demonstrate.&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; sensor_id |      reading_time      | reading_value | rank&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 | 2024-07-02 11:29:01+02 |          33.1 |    1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 | 2024-07-02 12:03:59+02 |          33.1 |    1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 | 2024-07-03 10:12:15+02 |          33.0 |    3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 | 2024-07-01 11:24:34+02 |          32.7 |    4&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Giving a natural ranking with tie on the reading value 33.1 and leaving a gap on 2nd rank, whereas &lt;code&gt;DENSE_RANK&lt;&#x2F;code&gt; wouldn&#x27;t include the gap.&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; sensor_id |      reading_time      | reading_value | rank&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 | 2024-07-02 11:29:01+02 |          33.1 |    1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 | 2024-07-02 12:03:59+02 |          33.1 |    1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 | 2024-07-03 10:12:15+02 |          33.0 |    2&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;         1 | 2024-07-01 11:24:34+02 |          32.7 |    3&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;&lt;h3 id=&quot;lag-and-lead&quot;&gt;&lt;code&gt;LAG&lt;&#x2F;code&gt; and &lt;code&gt;LEAD&lt;&#x2F;code&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#lag-and-lead&quot; aria-label=&quot;Anchor link for: lag-and-lead&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;To evaluate previous or subsequent rows without the need to self-join the dataset, you can utilise the power of the window functions &lt;code&gt;LAG&lt;&#x2F;code&gt; and &lt;code&gt;LEAD&lt;&#x2F;code&gt;. These functions are particularly useful for calculating differences between rows, comparing current and previous values, or fetching future values for comparison.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;LAG&lt;&#x2F;code&gt; function provides access to a value in a previous row within the partition, while &lt;code&gt;LEAD&lt;&#x2F;code&gt; provides access to a subsequent row.&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;    sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    LAG&lt;&#x2F;span&gt;&lt;span&gt;(reading_value, &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;OVER&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_time) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; previous_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    LEAD&lt;&#x2F;span&gt;&lt;span&gt;(reading_value, &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;OVER&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_time) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; next_value&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; sensor_readings;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In this query, we are using a value of 1, but you can choose any position necessary. By using &lt;code&gt;LAG()&lt;&#x2F;code&gt; and &lt;code&gt;LEAD()&lt;&#x2F;code&gt;, you can perform advanced analyses that require looking backward or forward within your dataset, making it easier to derive meaningful insights and trends.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;first-value-and-last-values&quot;&gt;&lt;code&gt;FIRST_value&lt;&#x2F;code&gt; and &lt;code&gt;LAST_VALUES&lt;&#x2F;code&gt;&lt;a class=&quot;zola-anchor&quot; href=&quot;#first-value-and-last-values&quot; aria-label=&quot;Anchor link for: first-value-and-last-values&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The functions &lt;code&gt;FIRST_value&lt;&#x2F;code&gt; and &lt;code&gt;LAST_VALUES&lt;&#x2F;code&gt; are similar, except they (as the name says) give the first&#x2F;last value of the partition.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;FIRST_VALUE&lt;&#x2F;code&gt; is useful when comparing the partition values to the first value (for example opening price for a day), and &lt;code&gt;LAST_VALUE&lt;&#x2F;code&gt; to identity the closing values.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;other-window-functions&quot;&gt;Other Window functions&lt;a class=&quot;zola-anchor&quot; href=&quot;#other-window-functions&quot; aria-label=&quot;Anchor link for: other-window-functions&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;The complete list of the window functions is available in &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;current&#x2F;functions-window.html&quot;&gt;the documentation&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;re-using-the-window-definition&quot;&gt;Re-using the window definition&lt;a class=&quot;zola-anchor&quot; href=&quot;#re-using-the-window-definition&quot; aria-label=&quot;Anchor link for: re-using-the-window-definition&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;As you might have noticed, the query we used to demonstrate &lt;code&gt;LAG&lt;&#x2F;code&gt; and &lt;code&gt;LEAD&lt;&#x2F;code&gt; was rather verbose. This was due to the repeated definition of the window for both columns. Luckily, the syntax of the window functions allows you to define and re-use the window definition.&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;    sensor_id,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_time,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    reading_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    LAG&lt;&#x2F;span&gt;&lt;span&gt;(reading_value, &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;OVER&lt;&#x2F;span&gt;&lt;span&gt; readings_window &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; previous_value,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;    LEAD&lt;&#x2F;span&gt;&lt;span&gt;(reading_value, &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;OVER&lt;&#x2F;span&gt;&lt;span&gt; readings_window &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; next_value&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; sensor_readings&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;WINDOW&lt;&#x2F;span&gt;&lt;span&gt; readings_window &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;span style=&quot;color: #F97583;&quot;&gt;PARTITION BY&lt;&#x2F;span&gt;&lt;span&gt; sensor_id &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;ORDER BY&lt;&#x2F;span&gt;&lt;span&gt; reading_time);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This way you can ensure the consistent window definition and consistency in complex queries.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
