<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Bala Nagaraju — Depth First]]></title><description><![CDATA[Bala Nagaraju — Depth First]]></description><link>https://depthfirst.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 08 Nov 2026 09:00:18 GMT</lastBuildDate><atom:link href="https://depthfirst.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Save the moment in UTC, save the place by name: a beginner’s guide to dates and timezones]]></title><description><![CDATA[Store the moment in UTC and the place by name. Learn who keeps timezone rules, why Vancouver shows MST, and when UTC alone breaks]]></description><link>https://depthfirst.hashnode.dev/dates-timezones-beginners-guide</link><guid isPermaLink="true">https://depthfirst.hashnode.dev/dates-timezones-beginners-guide</guid><category><![CDATA[timezone]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Beginner Developers]]></category><dc:creator><![CDATA[Payyavula Balanagaraju]]></dc:creator><pubDate>Sat, 07 Nov 2026 19:25:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae7af10abc3cbc6d6923b7b/4231f9a4-0723-4d15-aae4-7c191a9f9b23.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If your app shows a date or a time to people in more than one place, this guide is for you. You don't need to
know anything about timezones to read it. By the end you'll know the three rules most teams follow, why they
work, and the one case where they are not enough.</p>
<p>I learned all of this the hard way, so let me start there.</p>
<h2>How I learned this</h2>
<p>When we started, we saved times the simple way: the local time someone typed, with no timezone attached.
For example <code>2026-12-05 10:00</code>.</p>
<p>It looked fine. Then it broke quietly:</p>
<ul>
<li>Courses closed an hour early, or an hour late.</li>
<li>Early-bird prices ended at the wrong moment.</li>
<li>A page showed a different time from the one that was saved.</li>
</ul>
<p>The cause was the same every time. <code>2026-12-05 10:00</code> is not a moment. It's 10:00 <em>somewhere</em>. Without the
"where", every server, browser and job read it in its own timezone, and they didn't agree.</p>
<p>So I read the official sources, asked a lot of questions, and we changed how we store time. Here is what I
wish someone had told me on day one.</p>
<h2>Step 1: a moment and a wall clock are two different things</h2>
<ul>
<li>A <strong>moment</strong> is one point in time, the same everywhere on Earth. "When the rocket launched."</li>
<li>A <strong>wall-clock time</strong> is what a clock on the wall shows in one place. "10:00 in Vancouver."</li>
</ul>
<p>The same moment shows different wall-clock times in different places. 10:00 in Vancouver is 22:30 in
Bengaluru on the same moment.</p>
<p>Bugs happen when you save a wall-clock time and treat it like a moment.</p>
<h2>Step 2: UTC is the reference clock</h2>
<p><strong>UTC</strong> (Coordinated Universal Time) is the world's reference clock. It never changes for daylight saving.
Every place on Earth is described as "UTC plus or minus some hours" at a given moment:</p>
<table>
<thead>
<tr>
<th>Place</th>
<th>Offset today (Nov 2026)</th>
</tr>
</thead>
<tbody><tr>
<td>Bengaluru</td>
<td>UTC+5:30</td>
</tr>
<tr>
<td>Dublin</td>
<td>UTC+0 in winter, UTC+1 in summer</td>
</tr>
<tr>
<td>Vancouver</td>
<td>UTC−7, all year (new in 2026, see below)</td>
</tr>
</tbody></table>
<p>If you save every moment in UTC, every server, browser and background job reads the same value. That fixes the
"which timezone did this mean?" problem at the root.</p>
<h2>Step 3: save the place by name, never by offset</h2>
<p>An <strong>offset</strong> like <code>−07:00</code> is a number. A <strong>timezone name</strong> like <code>America/Vancouver</code> is a place with a
history of rules.</p>
<p>Offsets change. Dublin is <code>+00:00</code> in January and <code>+01:00</code> in July. Vancouver was <code>−08:00</code> every winter until
2026. If you save <code>−08:00</code>, you've saved a fact that was true for one date only.</p>
<p>If you save <code>America/Vancouver</code>, the software works out the right offset for any date: past, present or
future, daylight saving included. You write no daylight-saving code at all.</p>
<p>Names look like <code>Continent/City</code>: <code>America/Vancouver</code>, <code>America/Edmonton</code>, <code>Europe/Dublin</code>, <code>Asia/Kolkata</code>.
(Note: it's <code>Asia/Kolkata</code> and <code>Europe/Dublin</code>, not "India/Calcutta" or "Ireland/Dublin". Use the official
list.)</p>
<h2>Step 4: who keeps these rules? The IANA timezone database</h2>
<p>Somebody has to record that "Vancouver stopped changing clocks in 2026". That somebody is the <strong>IANA Time Zone
Database</strong> (also called tzdata or the Olson database), a small group of volunteers who publish the rules for
every place on Earth, going back decades.</p>
<p>When a government changes its clocks, the database ships a new release. Operating systems, Java, Postgres,
Node.js and libraries like moment-timezone copy that data into themselves.</p>
<p>That last part matters: <strong>the timezone database is a dependency, like any library.</strong> If you don't update it,
your app uses old rules.</p>
<p>And you probably have more than one copy:</p>
<ul>
<li>the server's operating system,</li>
<li>your runtime (Node.js, Java),</li>
<li>your date library (moment-timezone bundles its own),</li>
<li>your database (Postgres ships its own copy unless it was built to use the system's),</li>
<li>every user's browser.</li>
</ul>
<p>They can disagree for weeks after a change.</p>
<h2>Step 5: what changed in Canada in 2026 (a real example)</h2>
<p>This year four parts of Canada stopped changing their clocks. The database needed four releases to keep up:</p>
<table>
<thead>
<tr>
<th>Release</th>
<th>Date</th>
<th>Change</th>
</tr>
</thead>
<tbody><tr>
<td>2026b</td>
<td>22 Apr 2026</td>
<td>British Columbia: UTC−7 all year</td>
</tr>
<tr>
<td>2026c</td>
<td>8 Jul 2026</td>
<td>Alberta: UTC−6 all year</td>
</tr>
<tr>
<td>2026d</td>
<td>11 Sep 2026</td>
<td>Northwest Territories: UTC−6 all year</td>
</tr>
<tr>
<td>2026e</td>
<td>29 Sep 2026</td>
<td>Manitoba: UTC−5 all year</td>
</tr>
</tbody></table>
<p>British Columbia moved its clocks forward for the last time on 8 March 2026. On 1 November, when clocks would
normally go back, they stayed put.</p>
<h3>Why Vancouver now shows "MST"</h3>
<p>Short labels like PST and MST are not decoration. Each one names an offset:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th>Means</th>
</tr>
</thead>
<tbody><tr>
<td>PST</td>
<td>Pacific Standard Time, UTC−8</td>
</tr>
<tr>
<td>PDT</td>
<td>Pacific Daylight Time, UTC−7 (daylight saving)</td>
</tr>
<tr>
<td>MST</td>
<td>Mountain Standard Time, UTC−7</td>
</tr>
</tbody></table>
<p>Vancouver is now at UTC−7 all year, with no daylight saving. PST would be an hour wrong. PDT would say daylight
saving is on, and it no longer exists there. So the database labels it <strong>MST</strong>, the long-standing name for
UTC−7 standard time in North America. For the same reason, Edmonton (now UTC−6 all year) is labelled <strong>CST</strong>.</p>
<p>Two honest notes:</p>
<ul>
<li>The provinces use their own names: British Columbia calls it "Pacific Time" (PCT), Alberta calls it "Alberta
Time". The database's 2026b notes say a future release may change the label.</li>
<li>Windows and browsers don't always show the database's label. Browsers use another project (Unicode CLDR)
for display names, so you may see "GMT-7" or a long name instead.</li>
</ul>
<p>Our choice was to show what the database says. If the database changes the label later, our screens change
with the next update, with no code. If you override labels by hand, you own that list forever. And never
search-and-replace "MST" with something else: Arizona really is MST all year, and so is Denver in winter.</p>
<h2>Step 6: what this looks like in code</h2>
<p><strong>In the database (Postgres):</strong> use <code>timestamptz</code> for moments, and keep the zone name next to it.</p>
<pre><code class="language-sql">CREATE TABLE course (
  id         bigint PRIMARY KEY,
  starts_at  timestamptz NOT NULL,  -- a moment; Postgres stores it as UTC
  time_zone  text        NOT NULL   -- IANA name, e.g. 'America/Vancouver'
);

-- Show it as the local wall-clock time of the course's place
SELECT starts_at AT TIME ZONE time_zone AS local_start
FROM course;
</code></pre>
<p>Despite its name, <code>timestamptz</code> doesn't store a timezone. It stores the moment in UTC and converts it for
display using your session's <code>TimeZone</code> setting. The plain <code>timestamp</code> type (without time zone) is the
"local time with no zone" trap from the start of this guide.</p>
<p><strong>In JavaScript:</strong> a <code>Date</code> is already a moment (milliseconds since 1970 in UTC). Convert only when you display it.</p>
<pre><code class="language-js">const startsAt = new Date('2026-12-05T17:00:00Z'); // the moment, from the API, in UTC

startsAt.toLocaleString('en-CA', {
  timeZone: 'America/Vancouver', // the place, by name
  dateStyle: 'medium',
  timeStyle: 'short',
});
// 10:00 a.m. on 5 Dec 2026 in Vancouver (with up-to-date timezone data)
</code></pre>
<p><strong>In background jobs:</strong> schedule in UTC. "Close this course at 2026-12-05T17:00:00Z" fires at the right moment
whether the course is in Vancouver, Dublin or Bengaluru. Our job queue only ever deals in UTC, and that
removed a whole class of bugs.</p>
<h2>Step 7: the one case where UTC alone breaks: future events</h2>
<p>Here's the trap that almost nobody mentions in beginner guides.</p>
<p>Say in February 2026 someone creates an event: <strong>10:00 on 5 December 2026, in Vancouver.</strong> At that time the
rules said Vancouver would be at UTC−8 in December. So the app saved <code>2026-12-05T18:00:00Z</code>.</p>
<p>Then British Columbia changed its law. Vancouver will be at UTC−7 in December instead. Now that saved moment,
<code>18:00Z</code>, shows as <strong>11:00</strong> in Vancouver. The event is an hour off, and nobody touched it.</p>
<p>The person meant "10:00 on the wall clock in Vancouver". We saved a moment computed with rules that later
changed.</p>
<p>What to do about it:</p>
<ul>
<li><strong>For things that already happened</strong> (payments, logins, check-ins): UTC is exactly right. The past doesn't
change.</li>
<li><strong>For future events people plan by the wall clock</strong> (a class at 10:00 local): also keep the local time and
the zone name the person chose, and recompute the UTC moment from them when the rules change. Or at least
check future events after every timezone-data update.</li>
</ul>
<p>Jon Skeet wrote the classic piece on this: <em>Storing UTC is not a silver bullet</em> (link at the end). Read it
once you're comfortable with the basics.</p>
<h2>A checklist to keep</h2>
<ol>
<li>Save moments in UTC (<code>timestamptz</code> in Postgres, ISO strings ending in <code>Z</code> in APIs).</li>
<li>Save the place as an IANA name (<code>America/Vancouver</code>), never as an offset.</li>
<li>Convert to local time only at the last step, when a person reads it.</li>
<li>Schedule jobs in UTC.</li>
<li>Treat the timezone database as a dependency: update it, and know every copy you run.</li>
<li>For future wall-clock events, keep the local time + zone name too, and re-check after rule changes.</li>
<li>Show the label the database gives you. Don't hand-edit labels.</li>
</ol>
<h2>What I'd tell a new developer</h2>
<p>UTC tells you <em>when</em>. The zone name tells you <em>where</em>. You need both, and the local time is something you work
out at the end, not something you store.</p>
<p>What's the one timezone rule you wish someone had told you earlier?</p>
<hr />
<p><strong>Sources</strong></p>
<ul>
<li>IANA Time Zone Database: <a href="https://www.iana.org/time-zones">https://www.iana.org/time-zones</a></li>
<li>2026b release notes (British Columbia, the "MST" label): <a href="https://www.iana.org/time-zones/releases/2026b">https://www.iana.org/time-zones/releases/2026b</a></li>
<li>2026c release notes (Alberta, "CST"): <a href="https://www.iana.org/time-zones/releases/2026c">https://www.iana.org/time-zones/releases/2026c</a></li>
<li>2026e release notes (Manitoba): <a href="https://www.iana.org/time-zones/releases/2026e">https://www.iana.org/time-zones/releases/2026e</a></li>
<li>Time in British Columbia (PCT name, dates): <a href="https://en.wikipedia.org/wiki/Time_in_British_Columbia">https://en.wikipedia.org/wiki/Time_in_British_Columbia</a></li>
<li>moment-timezone issue #1163 (2026 Canadian changes): <a href="https://github.com/moment/moment-timezone/issues/1163">https://github.com/moment/moment-timezone/issues/1163</a></li>
<li>PostgreSQL date/time types: <a href="https://www.postgresql.org/docs/current/datatype-datetime.html">https://www.postgresql.org/docs/current/datatype-datetime.html</a></li>
<li>Jon Skeet, <em>Storing UTC is not a silver bullet</em> (2019): <a href="https://codeblog.jonskeet.uk/2019/03/27/storing-utc-is-not-a-silver-bullet/">https://codeblog.jonskeet.uk/2019/03/27/storing-utc-is-not-a-silver-bullet/</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Changing one number from 32 to 34 made a Postgres query 6,000× faster. Here's exactly why.]]></title><description><![CDATA[Postgres splits joins before removing useless ones. Here is why join_collapse_limit has holes, what to ship, and what Postgres 16+ changes.]]></description><link>https://depthfirst.hashnode.dev/changing-one-number-from-32-to-34-made-a-postgres-query-6-000-faster-here-s-exactly-why</link><guid isPermaLink="true">https://depthfirst.hashnode.dev/changing-one-number-from-32-to-34-made-a-postgres-query-6-000-faster-here-s-exactly-why</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[database]]></category><category><![CDATA[performance]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Payyavula Balanagaraju]]></dc:creator><pubDate>Sun, 01 Nov 2026 21:48:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae7af10abc3cbc6d6923b7b/dd57b3a5-ebd9-4fc1-bf65-480be79449d8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>Who this is for:</strong> anyone who has seen a Postgres query that should be fast but isn't, and EXPLAIN shows a bad plan without saying why. You don't need to know planner internals. Everything is explained from zero, with the real code lines.</p>
<p><strong>The short version:</strong> in Postgres 15 and every version released so far, the planner splits your joins into groups <em>before</em> it removes the joins it doesn't need. If the two tables that need an index lookup land in different groups, the fast plan can't be built at all. <code>join_collapse_limit</code> sets the group size, and the "safe" values are not a range: they have holes. Postgres 16+ gets a change that helps, but doesn't remove the problem completely.</p>
</blockquote>
<hr />
<h2>1. The problem</h2>
<p>A report on the platform I work on started timing out. One query, run with a few filters, took <strong>137,779 ms</strong>: more than two minutes.</p>
<p>The query reads from a view. That view is built on other views, which are built on more views (we call them layered views). It looks like one simple <code>SELECT ... FROM report_view WHERE ...</code>. Underneath, it is not simple at all.</p>
<p>EXPLAIN ANALYZE showed the slow part clearly:</p>
<pre><code>Nested Loop  (rows=1) (actual time=1112..139689 rows=1005)
  Join Filter: (enrollment.course_id = course.id)
  Rows Removed by Join Filter: 8,944,739
  -&gt;  Bitmap Heap Scan on course             (estimated rows=1, actual rows=181)
  -&gt;  Hash Left Join ...                     (loops=181)
        -&gt;  Hash (rows=895194) (actual time=538.801) loops=181     ← 538.8 ms × 181 = 97.5 s
</code></pre>
<p>Two things were wrong at once:</p>
<ol>
<li><strong>The plan shape.</strong> Postgres built <em>both</em> sides of the join completely, then compared them row by row (<code>Join Filter</code>) and threw away 8.9 million rows. The fast way would have been: take each of the 181 courses and <em>look up</em> its enrollments through an index. In a plan that looks like <code>Index Cond: (course_id = course.id)</code>.</li>
<li><strong>The row estimate.</strong> Postgres expected <strong>1</strong> course and got <strong>181</strong>. It planned an expensive inner step thinking it would run once. It ran 181 times.</li>
</ol>
<p>Fixing the plan shape alone was enough: the inner step drops to ~0.06 ms, so running it 181 times stops mattering. The rest of this article is about the plan shape, because that's the part EXPLAIN can't explain.</p>
<hr />
<h2>2. The first fix worked, and that was the problem</h2>
<p>The first suggestion I got (from an AI assistant) was to raise one planner setting, <code>join_collapse_limit</code>. It worked. The report became fast.</p>
<p>But I didn't know <em>why</em> it worked, so I tested every value from 8 to 140, three times each, on a copy of real data:</p>
<pre><code> 8 10 12 14 16 18 20 22 24 26 28 30 32 34 ............ 140
 ✗  ✗  ✓  ✗  ✓  ✓  ✓  ✓  ✓  ✗  ✗  ✓  ✗  ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>Values</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Slow</strong> (bad plan)</td>
<td>8, 10, 14, 26, 28, 32</td>
</tr>
<tr>
<td><strong>Fast</strong> (index lookup)</td>
<td>12, 16–24, 30, 34 and up</td>
</tr>
</tbody></table>
<p>At <strong>32</strong> the query took <strong>140,611 ms</strong>. At <strong>34</strong> it took <strong>23.9 ms</strong>. That's almost 6,000× from changing one number by two.</p>
<p><strong>This is not a threshold. It's a range with holes in it.</strong> If the setting were simply "bigger is better", 14 couldn't be slow when 12 is fast. Nobody could tell me why, so I went to the Postgres source code.</p>
<hr />
<h2>3. Background: what the planner does with your joins (two minutes)</h2>
<h3>3.1 Views disappear</h3>
<p>A view is saved SQL. When you query a view, Postgres pastes the view's SQL into your query before planning. Views built on views get pasted recursively. Our one-line query became <strong>about 435 table references</strong>: one inner view alone contributed 121, and it appeared three times.</p>
<h3>3.2 Postgres can throw away useless LEFT JOINs ("join removal")</h3>
<p>Most of those 435 tables aren't needed for this report. Postgres can remove a LEFT JOIN if <strong>all</strong> of these are true (<code>join_is_removable</code>, <code>src/backend/optimizer/plan/analyzejoins.c:159</code> in 15.8):</p>
<ol>
<li>It's a <strong>LEFT</strong> join.</li>
<li>The right side is <strong>one single table</strong>.</li>
<li>That table can match <strong>at most one row</strong>, proven by a primary key or unique index on the join columns.</li>
<li><strong>No column</strong> of that table is used anywhere above the join: SELECT, WHERE, ORDER BY, or another join's ON.</li>
</ol>
<p>If all four hold, removing the table can't change the result, so Postgres drops it. In our query, removal cut ~435 references down to <strong>about 11</strong>.</p>
<p>Rule 4 is why the columns you select matter. With 2 columns selected, only <strong>3</strong> tables survived and the query was fast at the default settings. With the report's <strong>27</strong> columns, <strong>10–11</strong> survived and it was slow. Each extra column can bring back a table that would otherwise be removed.</p>
<h3>3.3 Postgres limits how hard it searches</h3>
<p>To join N tables, the planner tries different orders. The number of possible orders grows like N!:</p>
<table>
<thead>
<tr>
<th align="right">Tables</th>
<th align="right">Possible orders (≈ N!)</th>
</tr>
</thead>
<tbody><tr>
<td align="right">4</td>
<td align="right">24</td>
</tr>
<tr>
<td align="right">8</td>
<td align="right">40,320</td>
</tr>
<tr>
<td align="right">11</td>
<td align="right">~40 million</td>
</tr>
</tbody></table>
<p>So Postgres protects itself with two settings:</p>
<ul>
<li><strong><code>join_collapse_limit</code></strong> (default <strong>8</strong>): tables written with explicit <code>JOIN</code> syntax are grouped into lists of at most this many items. The planner searches join orders <strong>inside each list</strong>, and treats each finished list as one fixed piece.</li>
<li><strong><code>geqo_threshold</code></strong> (default <strong>12</strong>): if a single list has 12 or more items, Postgres stops searching exhaustively and uses <strong>GEQO</strong>, a genetic (randomised) optimiser. Plans can then change from run to run.</li>
</ul>
<hr />
<h2>4. The real cause: the order of two steps</h2>
<p>Here is the order inside the planner's main function, <code>query_planner()</code>, in Postgres 15.8 (<code>src/backend/optimizer/plan/planmain.c</code>):</p>
<table>
<thead>
<tr>
<th>Line</th>
<th>Call</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td>184</td>
<td><code>joinlist = deconstruct_jointree(root);</code></td>
<td>Walks the whole join tree <strong>once</strong> and does two jobs in that walk: (A) collects the facts the planner needs (which columns are used where, what each join condition looks like), and (B) <strong>builds the join list, applying <code>join_collapse_limit</code></strong></td>
</tr>
<tr>
<td>221</td>
<td><code>joinlist = remove_useless_joins(root, joinlist);</code></td>
<td><strong>Then</strong> removes the useless LEFT JOINs</td>
</tr>
</tbody></table>
<p><strong>The join list is built at line 184, counting all ~435 tables. The useless ones are removed at line 221, and the join list is never rebuilt.</strong> That's the whole problem.</p>
<h3>4.1 How the join list is built</h3>
<p><code>deconstruct_recurse</code> visits the join tree bottom-up. At every <code>JOIN</code> it has the list from the left side and the list from the right side, and applies one rule (<code>src/backend/optimizer/plan/initsplan.c:1037-1058</code>):</p>
<pre><code class="language-c">else if (list_length(leftjoinlist) + list_length(rightjoinlist) &lt;=
         join_collapse_limit)
{
    /* OK to combine subproblems */
    joinlist = list_concat(leftjoinlist, rightjoinlist);
}
else
{
    /* can't combine, but needn't force join order above here */
    ...
    joinlist = list_make2(leftpart, rightpart);
}
</code></pre>
<p>In plain words:</p>
<ul>
<li><strong>left items + right items ≤ limit</strong> → merge them into one flat list (all orders will be tried across them);</li>
<li><strong>otherwise</strong> → don't merge; return exactly two items, the left side as a <strong>sub-list</strong> and the right side.</li>
</ul>
<p>A tiny example. <code>A JOIN B JOIN C</code> with a limit of 2 (Postgres reads it as <code>(A JOIN B) JOIN C</code>):</p>
<table>
<thead>
<tr>
<th>JOIN node</th>
<th>Left list</th>
<th>Right list</th>
<th>Total</th>
<th>≤ 2?</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><code>A JOIN B</code></td>
<td><code>[A]</code></td>
<td><code>[B]</code></td>
<td>2</td>
<td>yes</td>
<td><code>[A, B]</code></td>
</tr>
<tr>
<td><code>(…) JOIN C</code></td>
<td><code>[A, B]</code></td>
<td><code>[C]</code></td>
<td>3</td>
<td>no</td>
<td><code>[[A, B], C]</code></td>
</tr>
</tbody></table>
<p>The result <code>[[A, B], C]</code> means: plan A+B together first, on their own; then join that finished piece with C. The order "A with C first" is never even considered.</p>
<h3>4.2 Why a sub-list blocks the fast plan</h3>
<p>When the planner meets a sub-list, it plans it <strong>completely and alone</strong> first (<code>make_rel_from_joinlist</code>, <code>src/backend/optimizer/path/allpaths.c:3325</code>). The level above gets one finished result and can't look inside it.</p>
<p>The fast plan we wanted is "for each course, use an index to find its enrollments". Postgres calls this a <em>parameterized nested loop</em>. It only works when both tables are in the <strong>same</strong> list. If <code>enrollment</code> sits inside a sub-list and <code>course</code> sits outside it, that lookup can't be built. Postgres isn't choosing the slow plan because it thinks it's cheaper. The fast plan <strong>doesn't exist</strong> as an option.</p>
<h3>4.3 Removal doesn't fix the shape</h3>
<p>When <code>remove_useless_joins</code> removes a table, it deletes that table's entry from the join list (<code>remove_rel_from_joinlist</code>, <code>analyzejoins.c:462</code>). A sub-list stays a sub-list:</p>
<pre><code class="language-c">sublist = remove_rel_from_joinlist((List *) jlnode, relid, nremoved);
/* Avoid including empty sub-lists in the result */
if (sublist)
    result = lappend(result, sublist);
</code></pre>
<p>So the borders drawn while ~435 tables were counted survive, even though only ~11 tables remain. At the default limit of 8, <code>course</code> and <code>enrollment</code> ended up on different sides of a border.</p>
<p>Postgres's own code admits the order is a compromise. This comment sits right above the removal call:</p>
<pre><code class="language-c">/*
 * Remove any useless outer joins.  Ideally this would be done during
 * jointree preprocessing, but the necessary information isn't available
 * until we've built baserel data structures and classified qual clauses.
 */
</code></pre>
<p>In other words: the facts needed to decide "this join is useless" are collected in the same walk that builds the join list. So by the time Postgres knows what's useless, the groups are already drawn.</p>
<hr />
<h2>5. Seeing it happen: I added logging inside the planner</h2>
<p>Reading the code told me <em>what</em> could happen. To see <em>what did</em> happen, I compiled PostgreSQL 15.8 from the official source and added log lines inside <code>deconstruct_recurse</code> (every JOIN decision) and <code>make_rel_from_joinlist</code> (every final list):</p>
<pre><code class="language-c">elog(NOTICE, "JOIN|right=%s|L=%d|R=%d|sum=%d|jcl=%d|act=%s", ...);
</code></pre>
<p>I loaded a schema-only copy of the database (tables, views, and all <strong>270</strong> primary keys and unique constraints, <strong>no rows</strong>) and ran the report query.</p>
<blockquote>
<p><strong>Tip:</strong> the constraints matter. My first copy had no primary keys, so Postgres couldn't prove any join was useless, nothing was removed, and the trace showed <strong>766</strong> join-list entries instead of <strong>45</strong>. An experiment without unique constraints measures the wrong tree.</p>
</blockquote>
<p>What came out, per run:</p>
<table>
<thead>
<tr>
<th></th>
<th align="right">limit = 8</th>
<th align="right">limit = 64</th>
</tr>
</thead>
<tbody><tr>
<td>JOIN decisions evaluated</td>
<td align="right"><strong>739</strong></td>
<td align="right"><strong>739</strong></td>
</tr>
<tr>
<td>merges</td>
<td align="right">656</td>
<td align="right">730</td>
</tr>
<tr>
<td><strong>refusals (cuts)</strong></td>
<td align="right"><strong>83</strong></td>
<td align="right"><strong>9</strong></td>
</tr>
<tr>
<td>separate join problems</td>
<td align="right">75</td>
<td align="right">45</td>
</tr>
<tr>
<td>the <code>enrollment</code> table ends up in a group of</td>
<td align="right"><strong>1</strong> (alone)</td>
<td align="right"><strong>5</strong></td>
</tr>
<tr>
<td>execution time (copy of real data)</td>
<td align="right"><strong>137,779 ms</strong></td>
<td align="right"><strong>91 ms</strong></td>
</tr>
</tbody></table>
<p>Same 739 decisions either way. The limit turned 83 cuts into 9, and that's the whole difference.</p>
<p>At limit 8, the top of the tree showed three cuts, each <strong>just</strong> over the limit:</p>
<pre><code>  L +  R  =  sum   vs 8   -&gt;  decision
  7 +  4  =  11    vs 8   -&gt;  refuse    &lt;-- cut
  8 +  2  =  10    vs 8   -&gt;  refuse    &lt;-- cut
  6 +  3  =   9    vs 8   -&gt;  refuse    &lt;-- cut
</code></pre>
<p>At limit 64, the decisive JOIN merged two halves of the query, one of 34 table references and one of 30:</p>
<pre><code> 34 + 30  =  64    vs 64  -&gt;  MERGE     &lt;-- the merge that puts course and enrollment together
</code></pre>
<p><strong>34 + 30 = 64, against a limit of exactly 64. It passes by a margin of zero.</strong></p>
<h3>5.1 Why the safe values have holes</h3>
<p>Look at what each decision returns:</p>
<ul>
<li>a <strong>merge</strong> returns a list of length L + R (it gets longer);</li>
<li>a <strong>refusal</strong> returns a list of length <strong>2</strong> (it gets shorter).</li>
</ul>
<p>So a merge that succeeds low in the tree makes a list <em>longer</em>, and that longer list can then fail a check higher up. A refusal low in the tree makes a list <em>shorter</em>, which can then pass higher up. Change the limit and some decisions flip at the bottom, which flips other decisions further up. Whether <code>course</code> and <code>enrollment</code> end up together jumps around as you change the limit. That's the holes: 12 fast, 14 slow, 16 fast.</p>
<hr />
<h2>6. What we shipped, and two traps</h2>
<h3>6.1 The fix</h3>
<pre><code class="language-sql">BEGIN;
SET LOCAL join_collapse_limit = 64;
-- the report query (and its count query)
COMMIT;
</code></pre>
<p><strong>137,779 ms → 91 ms.</strong></p>
<h3>6.2 Trap 1: bigger is not safer</h3>
<p>My own first recommendation was <strong>100</strong>. The trace showed that at 100 the tree collapses so far that the biggest single list reaches <strong>12</strong> tables, exactly <code>geqo_threshold</code>, so Postgres switches to the genetic optimiser and plans stop being deterministic. Confirmed on the copy of real data by changing <code>geqo_seed</code> (if the plan changes when the seed changes, GEQO is running):</p>
<pre><code>jcl=48  : no GEQO     jcl=64  : no GEQO     jcl=72 : no GEQO
jcl=80  : no GEQO     jcl=100 : GEQO FIRES
</code></pre>
<p>The usable window for this query was <strong>48–80</strong>: fast plan, deterministic. 64 sits in the middle of it.</p>
<p>Raising <code>from_collapse_limit</code> as well (the sibling setting for comma-joins and subqueries) didn't help either. It made several values flip from fast to slow. It's a second axis with its own holes.</p>
<h3>6.3 Trap 2: never set it globally</h3>
<p>A global 64 is dangerous for other queries. In a separate test with a 12-table join where nothing can be removed, <code>join_collapse_limit = 64</code> made Postgres switch that query to GEQO, while the default of 8 didn't. So:</p>
<ul>
<li>set it with <strong><code>SET LOCAL</code></strong>, only in the transaction that runs this query;</li>
<li><code>SET LOCAL</code> outside a transaction <strong>does nothing</strong>: Postgres prints a warning and keeps the old value. Make sure your code really opens a transaction;</li>
<li><code>SET LOCAL</code> also resets at COMMIT, so the setting doesn't leak to the next request on a pooled connection.</li>
</ul>
<h3>6.4 Fixes that don't need the setting at all</h3>
<p>The setting is a workaround. These fix the cause:</p>
<ul>
<li><strong>Select fewer columns in the hot query.</strong> Fetching 25 IDs first and then the details for just those 25 ran in <strong>5.5 ms</strong> at the default limit, with no planner setting at all.</li>
<li><strong>Add the unique index that join removal needs.</strong> No setting can remove a join Postgres can't prove is unique.</li>
<li><strong>Fewer view layers</strong> in hot paths.</li>
</ul>
<p>One more honest number: planning alone took about <strong>440 ms</strong> for this query, because of view expansion. That's a separate cost, and the setting doesn't touch it.</p>
<hr />
<h2>7. The twist: Postgres changed this in August 2026</h2>
<p>On 28 August 2026, Tom Lane committed <strong><code>2ebf25e7d7</code></strong>, "Perform join removal by editing the query's jointree".</p>
<h3>7.1 What it does</h3>
<p>Before (15.8 up to the latest released versions):</p>
<ol>
<li>build facts + join list (all tables counted);</li>
<li>remove useless joins, <strong>patching</strong> the planner's notes by hand;</li>
<li>plan with the <strong>old</strong> join list.</li>
</ol>
<p>After:</p>
<ol>
<li>build facts + join list;</li>
<li>remove useless joins by <strong>cutting them out of the query tree itself</strong> (the LEFT JOIN node is replaced by its left side, <code>analyzejoins.c:399</code> in master: <code>return j-&gt;larg;</code>);</li>
<li><strong>throw away all notes and the join list, and start again</strong> (<code>goto restart</code>, <code>planmain.c:286</code> in master);</li>
<li>repeat until nothing more can be removed, then plan.</li>
</ol>
<p>The final join list is built <strong>only from the tables that survived</strong>. In effect: remove first, then group.</p>
<h3>7.2 Why he did it: a bug that returned wrong rows</h3>
<p>He didn't do it for speed. He did it because patching the notes by hand kept causing bugs. Bug #19560 is a good example (reported on 19beta2, a regression starting in 16; 15.x is not affected):</p>
<pre><code class="language-sql">CREATE TABLE items (id text, owner text);
CREATE TABLE follows (item_id text, user_id text, UNIQUE (user_id, item_id));
INSERT INTO items VALUES ('item1', 'alice');

WITH viewer AS (SELECT 'bob' AS id)
SELECT count(*) FROM items
LEFT JOIN follows ON follows.item_id = items.id AND follows.user_id = 'bob'
LEFT JOIN viewer ON TRUE
WHERE items.owner = viewer.id;
</code></pre>
<p>The only item belongs to alice and the viewer is bob, so the answer is <strong>0</strong>. Postgres returned <strong>1</strong>. The plan became just <code>Aggregate -&gt; Seq Scan on items</code>: the <code>WHERE</code> condition <strong>vanished</strong>. <code>follows</code> was removable (a unique key, no columns used), and removing it changed the planner's internal notes so that a new filter should have been created. The filters had already been created earlier, and nothing re-checked them.</p>
<p>Tom's commit message: the old code "implemented the removal by updating those structures in-place … nothing keeps it in step with the rest of the planner", and "We have seen many related bugs over the years too, so it's time to do something drastic."</p>
<h3>7.3 Which versions have it (as of 1 November 2026)</h3>
<table>
<thead>
<tr>
<th>Version</th>
<th>Has the fix?</th>
</tr>
</thead>
<tbody><tr>
<td>15.x</td>
<td><strong>Never.</strong> It was back-patched only to 16</td>
</tr>
<tr>
<td>16.15, 17.11, 18.6 (latest releases)</td>
<td><strong>Not yet</strong>: the fix is on those branches, but in no release tag yet</td>
</tr>
<tr>
<td>Next minor releases, 12 Nov 2026</td>
<td>Expected (fixes on a stable branch normally ship in the next minor)</td>
</tr>
<tr>
<td>19 (RC1; final release planned 19 Nov 2026)</td>
<td><strong>Yes</strong></td>
</tr>
</tbody></table>
<h3>7.4 What it does NOT fix</h3>
<ul>
<li><strong>More survivors than the limit.</strong> Our query keeps about 11 tables. 11 &gt; 8, so even on the new code the survivors are split again. In a synthetic test, 6 survivors at limit 8 went from 213–270 ms to 33–36 ms; <strong>11 survivors at limit 8 kept the same bad plan.</strong> On 16+ we'd still need the limit at 11 or more.</li>
<li><strong>Chains cost planning time.</strong> Each restart repeats the planning setup. Independent useless joins are all removed in one pass, but a join that only becomes removable after another one is gone waits for the next pass. Views built on views create exactly those chains. Measured by Richard Guo in the discussion of the change:</li>
</ul>
<table>
<thead>
<tr>
<th>Shape</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>64 independent useless joins</td>
<td>1.11 ms</td>
<td><strong>0.57 ms</strong></td>
</tr>
<tr>
<td>a chain of 64 dependent removals</td>
<td>1.98 ms</td>
<td><strong>20.9 ms</strong></td>
</tr>
</tbody></table>
<p>How long the chains are in real layered-view schemas, and what that costs, hasn't been measured publicly as far as I know.</p>
<hr />
<h2>8. Check your own query in 15 minutes</h2>
<ol>
<li><strong>Run <code>EXPLAIN (ANALYZE, BUFFERS)</code>.</strong> Look for <code>Join Filter</code> on a join you expected to use an index, with a big <code>Rows Removed by Join Filter</code>. Compare with what you'd expect: <code>Index Cond: (child.parent_id = parent.id)</code>.</li>
<li><strong>Count tables before and after removal.</strong> If your query reads from views, count the tables the view expands into (or look at the plan). If far more tables exist in the views than appear in the plan, removal is happening, and the grouping was done on the bigger count.</li>
<li><strong>Try a few limits, inside a transaction:</strong><pre><code class="language-sql">BEGIN; SET LOCAL join_collapse_limit = 12; EXPLAIN ANALYZE &lt;query&gt;; ROLLBACK;
</code></pre>
Test several values, not just one. Expect holes.</li>
<li><strong>Check GEQO.</strong> Run the plan with two different <code>geqo_seed</code> values. If the plan changes, the genetic optimiser is in play at that limit.</li>
<li><strong>Ship it scoped.</strong> <code>SET LOCAL</code> inside the transaction of that one query, never globally. Re-test when the views change: our winning merge had a margin of zero.</li>
<li><strong>Fix the cause when you can:</strong> fewer columns in the hot query, the missing unique index, fewer view layers.</li>
</ol>
<hr />
<h2>What I'd tell my team</h2>
<p>When a tool shows you <em>what</em> is slow but not <em>why</em>, the source code can. Postgres is open, and the comments in the planner are honest about its own compromises. A fix solves one query. Understanding why it works solves the next ten.</p>
<p><strong>Do you build views on top of views in Postgres? How many levels deep did you go before the planner started fighting you?</strong></p>
<hr />
<p><strong>References (public)</strong></p>
<ul>
<li>Postgres commit <code>2ebf25e7d7</code>: <a href="https://github.com/postgres/postgres/commit/2ebf25e7d70a8fce31ace78d723fa9271ab8af72">https://github.com/postgres/postgres/commit/2ebf25e7d70a8fce31ace78d723fa9271ab8af72</a></li>
<li>Bug #19560 report: <a href="https://www.postgresql.org/message-id/19560-54cd7ede78d5e355%40postgresql.org">https://www.postgresql.org/message-id/19560-54cd7ede78d5e355%40postgresql.org</a></li>
<li>Discussion thread: <a href="https://www.postgresql.org/message-id/flat/1186816.1784573544%40sss.pgh.pa.us">https://www.postgresql.org/message-id/flat/1186816.1784573544%40sss.pgh.pa.us</a></li>
<li>PostgreSQL docs, explicit JOIN control and <code>join_collapse_limit</code>: <a href="https://www.postgresql.org/docs/current/explicit-joins.html">https://www.postgresql.org/docs/current/explicit-joins.html</a></li>
<li>PostgreSQL roadmap (release dates): <a href="https://www.postgresql.org/developer/roadmap/">https://www.postgresql.org/developer/roadmap/</a></li>
<li>Source files: <code>src/backend/optimizer/plan/planmain.c</code>, <code>initsplan.c</code>, <code>analyzejoins.c</code>, <code>src/backend/optimizer/path/allpaths.c</code> (line numbers from REL_15_8 and master as of 2026-11)</li>
</ul>
]]></content:encoded></item></channel></rss>