<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Payyavula Balanagaraju</title>
    <description>The latest articles on DEV Community by Payyavula Balanagaraju (@payyavula_balanagaraju_6d).</description>
    <link>https://dev.to/payyavula_balanagaraju_6d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4221214%2F649d4460-7797-42c7-afad-1a1bf884d48d.png</url>
      <title>DEV Community: Payyavula Balanagaraju</title>
      <link>https://dev.to/payyavula_balanagaraju_6d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/payyavula_balanagaraju_6d"/>
    <language>en</language>
    <item>
      <title>Changing one number from 32 to 34 made a Postgres query 6,000 faster. Here's exactly why.</title>
      <dc:creator>Payyavula Balanagaraju</dc:creator>
      <pubDate>Sun, 01 Nov 2026 21:58:23 +0000</pubDate>
      <link>https://dev.to/payyavula_balanagaraju_6d/changing-one-number-from-32-to-34-made-a-postgres-query-6000x-faster-heres-exactly-why-4p79</link>
      <guid>https://dev.to/payyavula_balanagaraju_6d/changing-one-number-from-32-to-34-made-a-postgres-query-6000x-faster-heres-exactly-why-4p79</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Who this is for:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; in Postgres 15 and every version released so far, the planner splits your joins into groups &lt;em&gt;before&lt;/em&gt; 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. &lt;code&gt;join_collapse_limit&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The problem
&lt;/h2&gt;

&lt;p&gt;A report on the platform I work on started timing out. One query, run with a few filters, took &lt;strong&gt;137,779 ms&lt;/strong&gt;: more than two minutes.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;SELECT ... FROM report_view WHERE ...&lt;/code&gt;. Underneath, it is not simple at all.&lt;/p&gt;

&lt;p&gt;EXPLAIN ANALYZE showed the slow part clearly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
  -&amp;gt;  Bitmap Heap Scan on course             (estimated rows=1, actual rows=181)
  -&amp;gt;  Hash Left Join ...                     (loops=181)
        -&amp;gt;  Hash (rows=895194) (actual time=538.801) loops=181     ← 538.8 ms × 181 = 97.5 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things were wrong at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The plan shape.&lt;/strong&gt; Postgres built &lt;em&gt;both&lt;/em&gt; sides of the join completely, then compared them row by row (&lt;code&gt;Join Filter&lt;/code&gt;) and threw away 8.9 million rows. The fast way would have been: take each of the 181 courses and &lt;em&gt;look up&lt;/em&gt; its enrollments through an index. In a plan that looks like &lt;code&gt;Index Cond: (course_id = course.id)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The row estimate.&lt;/strong&gt; Postgres expected &lt;strong&gt;1&lt;/strong&gt; course and got &lt;strong&gt;181&lt;/strong&gt;. It planned an expensive inner step thinking it would run once. It ran 181 times.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The first fix worked, and that was the problem
&lt;/h2&gt;

&lt;p&gt;The first suggestion I got (from an AI assistant) was to raise one planner setting, &lt;code&gt;join_collapse_limit&lt;/code&gt;. It worked. The report became fast.&lt;/p&gt;

&lt;p&gt;But I didn't know &lt;em&gt;why&lt;/em&gt; it worked, so I tested every value from 8 to 140, three times each, on a copy of real data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 8 10 12 14 16 18 20 22 24 26 28 30 32 34 ............ 140
 ✗  ✗  ✓  ✗  ✓  ✓  ✓  ✓  ✓  ✗  ✗  ✓  ✗  ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Values&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Slow&lt;/strong&gt; (bad plan)&lt;/td&gt;
&lt;td&gt;8, 10, 14, 26, 28, 32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Fast&lt;/strong&gt; (index lookup)&lt;/td&gt;
&lt;td&gt;12, 16–24, 30, 34 and up&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At &lt;strong&gt;32&lt;/strong&gt; the query took &lt;strong&gt;140,611 ms&lt;/strong&gt;. At &lt;strong&gt;34&lt;/strong&gt; it took &lt;strong&gt;23.9 ms&lt;/strong&gt;. That's almost 6,000× from changing one number by two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is not a threshold. It's a range with holes in it.&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Background: what the planner does with your joins (two minutes)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3.1 Views disappear
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;about 435 table references&lt;/strong&gt;: one inner view alone contributed 121, and it appeared three times.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 Postgres can throw away useless LEFT JOINs ("join removal")
&lt;/h3&gt;

&lt;p&gt;Most of those 435 tables aren't needed for this report. Postgres can remove a LEFT JOIN if &lt;strong&gt;all&lt;/strong&gt; of these are true (&lt;code&gt;join_is_removable&lt;/code&gt;, &lt;code&gt;src/backend/optimizer/plan/analyzejoins.c:159&lt;/code&gt; in 15.8):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It's a &lt;strong&gt;LEFT&lt;/strong&gt; join.&lt;/li&gt;
&lt;li&gt;The right side is &lt;strong&gt;one single table&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;That table can match &lt;strong&gt;at most one row&lt;/strong&gt;, proven by a primary key or unique index on the join columns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No column&lt;/strong&gt; of that table is used anywhere above the join: SELECT, WHERE, ORDER BY, or another join's ON.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;strong&gt;about 11&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Rule 4 is why the columns you select matter. With 2 columns selected, only &lt;strong&gt;3&lt;/strong&gt; tables survived and the query was fast at the default settings. With the report's &lt;strong&gt;27&lt;/strong&gt; columns, &lt;strong&gt;10–11&lt;/strong&gt; survived and it was slow. Each extra column can bring back a table that would otherwise be removed.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Postgres limits how hard it searches
&lt;/h3&gt;

&lt;p&gt;To join N tables, the planner tries different orders. The number of possible orders grows like N!:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tables&lt;/th&gt;
&lt;th&gt;Possible orders (≈ N!)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;40,320&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;~40 million&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So Postgres protects itself with two settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;join_collapse_limit&lt;/code&gt;&lt;/strong&gt; (default &lt;strong&gt;8&lt;/strong&gt;): tables written with explicit &lt;code&gt;JOIN&lt;/code&gt; syntax are grouped into lists of at most this many items. The planner searches join orders &lt;strong&gt;inside each list&lt;/strong&gt;, and treats each finished list as one fixed piece.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;geqo_threshold&lt;/code&gt;&lt;/strong&gt; (default &lt;strong&gt;12&lt;/strong&gt;): if a single list has 12 or more items, Postgres stops searching exhaustively and uses &lt;strong&gt;GEQO&lt;/strong&gt;, a genetic (randomised) optimiser. Plans can then change from run to run.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. The real cause: the order of two steps
&lt;/h2&gt;

&lt;p&gt;Here is the order inside the planner's main function, &lt;code&gt;query_planner()&lt;/code&gt;, in Postgres 15.8 (&lt;code&gt;src/backend/optimizer/plan/planmain.c&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;184&lt;/td&gt;
&lt;td&gt;&lt;code&gt;joinlist = deconstruct_jointree(root);&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Walks the whole join tree &lt;strong&gt;once&lt;/strong&gt; 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) &lt;strong&gt;builds the join list, applying &lt;code&gt;join_collapse_limit&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;221&lt;/td&gt;
&lt;td&gt;&lt;code&gt;joinlist = remove_useless_joins(root, joinlist);&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Then&lt;/strong&gt; removes the useless LEFT JOINs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;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.&lt;/strong&gt; That's the whole problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 How the join list is built
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;deconstruct_recurse&lt;/code&gt; visits the join tree bottom-up. At every &lt;code&gt;JOIN&lt;/code&gt; it has the list from the left side and the list from the right side, and applies one rule (&lt;code&gt;src/backend/optimizer/plan/initsplan.c:1037-1058&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leftjoinlist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;list_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rightjoinlist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;
         &lt;span class="n"&gt;join_collapse_limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* OK to combine subproblems */&lt;/span&gt;
    &lt;span class="n"&gt;joinlist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leftjoinlist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rightjoinlist&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* can't combine, but needn't force join order above here */&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;joinlist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_make2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leftpart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rightpart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In plain words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;left items + right items ≤ limit&lt;/strong&gt; → merge them into one flat list (all orders will be tried across them);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;otherwise&lt;/strong&gt; → don't merge; return exactly two items, the left side as a &lt;strong&gt;sub-list&lt;/strong&gt; and the right side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A tiny example. &lt;code&gt;A JOIN B JOIN C&lt;/code&gt; with a limit of 2 (Postgres reads it as &lt;code&gt;(A JOIN B) JOIN C&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;JOIN node&lt;/th&gt;
&lt;th&gt;Left list&lt;/th&gt;
&lt;th&gt;Right list&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;≤ 2?&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;A JOIN B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[A]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[B]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[A, B]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(…) JOIN C&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[A, B]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[C]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[[A, B], C]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The result &lt;code&gt;[[A, B], C]&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 Why a sub-list blocks the fast plan
&lt;/h3&gt;

&lt;p&gt;When the planner meets a sub-list, it plans it &lt;strong&gt;completely and alone&lt;/strong&gt; first (&lt;code&gt;make_rel_from_joinlist&lt;/code&gt;, &lt;code&gt;src/backend/optimizer/path/allpaths.c:3325&lt;/code&gt;). The level above gets one finished result and can't look inside it.&lt;/p&gt;

&lt;p&gt;The fast plan we wanted is "for each course, use an index to find its enrollments". Postgres calls this a &lt;em&gt;parameterized nested loop&lt;/em&gt;. It only works when both tables are in the &lt;strong&gt;same&lt;/strong&gt; list. If &lt;code&gt;enrollment&lt;/code&gt; sits inside a sub-list and &lt;code&gt;course&lt;/code&gt; 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 &lt;strong&gt;doesn't exist&lt;/strong&gt; as an option.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 Removal doesn't fix the shape
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;remove_useless_joins&lt;/code&gt; removes a table, it deletes that table's entry from the join list (&lt;code&gt;remove_rel_from_joinlist&lt;/code&gt;, &lt;code&gt;analyzejoins.c:462&lt;/code&gt;). A sub-list stays a sub-list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;sublist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;remove_rel_from_joinlist&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;jlnode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nremoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cm"&gt;/* Avoid including empty sub-lists in the result */&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sublist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lappend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sublist&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the borders drawn while ~435 tables were counted survive, even though only ~11 tables remain. At the default limit of 8, &lt;code&gt;course&lt;/code&gt; and &lt;code&gt;enrollment&lt;/code&gt; ended up on different sides of a border.&lt;/p&gt;

&lt;p&gt;Postgres's own code admits the order is a compromise. This comment sits right above the removal call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
 * 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.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Seeing it happen: I added logging inside the planner
&lt;/h2&gt;

&lt;p&gt;Reading the code told me &lt;em&gt;what&lt;/em&gt; could happen. To see &lt;em&gt;what did&lt;/em&gt; happen, I compiled PostgreSQL 15.8 from the official source and added log lines inside &lt;code&gt;deconstruct_recurse&lt;/code&gt; (every JOIN decision) and &lt;code&gt;make_rel_from_joinlist&lt;/code&gt; (every final list):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;elog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOTICE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"JOIN|right=%s|L=%d|R=%d|sum=%d|jcl=%d|act=%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I loaded a schema-only copy of the database (tables, views, and all &lt;strong&gt;270&lt;/strong&gt; primary keys and unique constraints, &lt;strong&gt;no rows&lt;/strong&gt;) and ran the report query.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; 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 &lt;strong&gt;766&lt;/strong&gt; join-list entries instead of &lt;strong&gt;45&lt;/strong&gt;. An experiment without unique constraints measures the wrong tree.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What came out, per run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;limit = 8&lt;/th&gt;
&lt;th&gt;limit = 64&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JOIN decisions evaluated&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;739&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;739&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;merges&lt;/td&gt;
&lt;td&gt;656&lt;/td&gt;
&lt;td&gt;730&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;refusals (cuts)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;83&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;separate join problems&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the &lt;code&gt;enrollment&lt;/code&gt; table ends up in a group of&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1&lt;/strong&gt; (alone)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;execution time (copy of real data)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;137,779 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same 739 decisions either way. The limit turned 83 cuts into 9, and that's the whole difference.&lt;/p&gt;

&lt;p&gt;At limit 8, the top of the tree showed three cuts, each &lt;strong&gt;just&lt;/strong&gt; over the limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  L +  R  =  sum   vs 8   -&amp;gt;  decision
  7 +  4  =  11    vs 8   -&amp;gt;  refuse    &amp;lt;-- cut
  8 +  2  =  10    vs 8   -&amp;gt;  refuse    &amp;lt;-- cut
  6 +  3  =   9    vs 8   -&amp;gt;  refuse    &amp;lt;-- cut
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At limit 64, the decisive JOIN merged two halves of the query, one of 34 table references and one of 30:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 34 + 30  =  64    vs 64  -&amp;gt;  MERGE     &amp;lt;-- the merge that puts course and enrollment together
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;34 + 30 = 64, against a limit of exactly 64. It passes by a margin of zero.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 Why the safe values have holes
&lt;/h3&gt;

&lt;p&gt;Look at what each decision returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;merge&lt;/strong&gt; returns a list of length L + R (it gets longer);&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;refusal&lt;/strong&gt; returns a list of length &lt;strong&gt;2&lt;/strong&gt; (it gets shorter).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a merge that succeeds low in the tree makes a list &lt;em&gt;longer&lt;/em&gt;, and that longer list can then fail a check higher up. A refusal low in the tree makes a list &lt;em&gt;shorter&lt;/em&gt;, which can then pass higher up. Change the limit and some decisions flip at the bottom, which flips other decisions further up. Whether &lt;code&gt;course&lt;/code&gt; and &lt;code&gt;enrollment&lt;/code&gt; end up together jumps around as you change the limit. That's the holes: 12 fast, 14 slow, 16 fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. What we shipped, and two traps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  6.1 The fix
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;join_collapse_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- the report query (and its count query)&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;137,779 ms → 91 ms.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6.2 Trap 1: bigger is not safer
&lt;/h3&gt;

&lt;p&gt;My own first recommendation was &lt;strong&gt;100&lt;/strong&gt;. The trace showed that at 100 the tree collapses so far that the biggest single list reaches &lt;strong&gt;12&lt;/strong&gt; tables, exactly &lt;code&gt;geqo_threshold&lt;/code&gt;, so Postgres switches to the genetic optimiser and plans stop being deterministic. Confirmed on the copy of real data by changing &lt;code&gt;geqo_seed&lt;/code&gt; (if the plan changes when the seed changes, GEQO is running):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jcl=48  : no GEQO     jcl=64  : no GEQO     jcl=72 : no GEQO
jcl=80  : no GEQO     jcl=100 : GEQO FIRES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usable window for this query was &lt;strong&gt;48–80&lt;/strong&gt;: fast plan, deterministic. 64 sits in the middle of it.&lt;/p&gt;

&lt;p&gt;Raising &lt;code&gt;from_collapse_limit&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.3 Trap 2: never set it globally
&lt;/h3&gt;

&lt;p&gt;A global 64 is dangerous for other queries. In a separate test with a 12-table join where nothing can be removed, &lt;code&gt;join_collapse_limit = 64&lt;/code&gt; made Postgres switch that query to GEQO, while the default of 8 didn't. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;set it with &lt;strong&gt;&lt;code&gt;SET LOCAL&lt;/code&gt;&lt;/strong&gt;, only in the transaction that runs this query;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET LOCAL&lt;/code&gt; outside a transaction &lt;strong&gt;does nothing&lt;/strong&gt;: Postgres prints a warning and keeps the old value. Make sure your code really opens a transaction;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET LOCAL&lt;/code&gt; also resets at COMMIT, so the setting doesn't leak to the next request on a pooled connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6.4 Fixes that don't need the setting at all
&lt;/h3&gt;

&lt;p&gt;The setting is a workaround. These fix the cause:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Select fewer columns in the hot query.&lt;/strong&gt; Fetching 25 IDs first and then the details for just those 25 ran in &lt;strong&gt;5.5 ms&lt;/strong&gt; at the default limit, with no planner setting at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the unique index that join removal needs.&lt;/strong&gt; No setting can remove a join Postgres can't prove is unique.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer view layers&lt;/strong&gt; in hot paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more honest number: planning alone took about &lt;strong&gt;440 ms&lt;/strong&gt; for this query, because of view expansion. That's a separate cost, and the setting doesn't touch it.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The twist: Postgres changed this in August 2026
&lt;/h2&gt;

&lt;p&gt;On 28 August 2026, Tom Lane committed &lt;strong&gt;&lt;code&gt;2ebf25e7d7&lt;/code&gt;&lt;/strong&gt;, "Perform join removal by editing the query's jointree".&lt;/p&gt;

&lt;h3&gt;
  
  
  7.1 What it does
&lt;/h3&gt;

&lt;p&gt;Before (15.8 up to the latest released versions):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;build facts + join list (all tables counted);&lt;/li&gt;
&lt;li&gt;remove useless joins, &lt;strong&gt;patching&lt;/strong&gt; the planner's notes by hand;&lt;/li&gt;
&lt;li&gt;plan with the &lt;strong&gt;old&lt;/strong&gt; join list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;build facts + join list;&lt;/li&gt;
&lt;li&gt;remove useless joins by &lt;strong&gt;cutting them out of the query tree itself&lt;/strong&gt; (the LEFT JOIN node is replaced by its left side, &lt;code&gt;analyzejoins.c:399&lt;/code&gt; in master: &lt;code&gt;return j-&amp;gt;larg;&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;throw away all notes and the join list, and start again&lt;/strong&gt; (&lt;code&gt;goto restart&lt;/code&gt;, &lt;code&gt;planmain.c:286&lt;/code&gt; in master);&lt;/li&gt;
&lt;li&gt;repeat until nothing more can be removed, then plan.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final join list is built &lt;strong&gt;only from the tables that survived&lt;/strong&gt;. In effect: remove first, then group.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.2 Why he did it: a bug that returned wrong rows
&lt;/h3&gt;

&lt;p&gt;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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;follows&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'item1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;viewer&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'bob'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;follows&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;follows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;follows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'bob'&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;viewer&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only item belongs to alice and the viewer is bob, so the answer is &lt;strong&gt;0&lt;/strong&gt;. Postgres returned &lt;strong&gt;1&lt;/strong&gt;. The plan became just &lt;code&gt;Aggregate -&amp;gt; Seq Scan on items&lt;/code&gt;: the &lt;code&gt;WHERE&lt;/code&gt; condition &lt;strong&gt;vanished&lt;/strong&gt;. &lt;code&gt;follows&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;h3&gt;
  
  
  7.3 Which versions have it (as of 1 November 2026)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Has the fix?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;15.x&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Never.&lt;/strong&gt; It was back-patched only to 16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16.15, 17.11, 18.6 (latest releases)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not yet&lt;/strong&gt;: the fix is on those branches, but in no release tag yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next minor releases, 12 Nov 2026&lt;/td&gt;
&lt;td&gt;Expected (fixes on a stable branch normally ship in the next minor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;19 (RC1; final release planned 19 Nov 2026)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  7.4 What it does NOT fix
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More survivors than the limit.&lt;/strong&gt; Our query keeps about 11 tables. 11 &amp;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; &lt;strong&gt;11 survivors at limit 8 kept the same bad plan.&lt;/strong&gt; On 16+ we'd still need the limit at 11 or more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chains cost planning time.&lt;/strong&gt; 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:&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;64 independent useless joins&lt;/td&gt;
&lt;td&gt;1.11 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.57 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a chain of 64 dependent removals&lt;/td&gt;
&lt;td&gt;1.98 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.9 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How long the chains are in real layered-view schemas, and what that costs, hasn't been measured publicly as far as I know.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Check your own query in 15 minutes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;.&lt;/strong&gt; Look for &lt;code&gt;Join Filter&lt;/code&gt; on a join you expected to use an index, with a big &lt;code&gt;Rows Removed by Join Filter&lt;/code&gt;. Compare with what you'd expect: &lt;code&gt;Index Cond: (child.parent_id = parent.id)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count tables before and after removal.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try a few limits, inside a transaction:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;join_collapse_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test several values, not just one. Expect holes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check GEQO.&lt;/strong&gt; Run the plan with two different &lt;code&gt;geqo_seed&lt;/code&gt; values. If the plan changes, the genetic optimiser is in play at that limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship it scoped.&lt;/strong&gt; &lt;code&gt;SET LOCAL&lt;/code&gt; inside the transaction of that one query, never globally. Re-test when the views change: our winning merge had a margin of zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the cause when you can:&lt;/strong&gt; fewer columns in the hot query, the missing unique index, fewer view layers.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What I'd tell my team
&lt;/h2&gt;

&lt;p&gt;When a tool shows you &lt;em&gt;what&lt;/em&gt; is slow but not &lt;em&gt;why&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you build views on top of views in Postgres? How many levels deep did you go before the planner started fighting you?&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;References (public)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres commit &lt;code&gt;2ebf25e7d7&lt;/code&gt;: &lt;a href="https://github.com/postgres/postgres/commit/2ebf25e7d70a8fce31ace78d723fa9271ab8af72" rel="noopener noreferrer"&gt;https://github.com/postgres/postgres/commit/2ebf25e7d70a8fce31ace78d723fa9271ab8af72&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bug #19560 report: &lt;a href="https://www.postgresql.org/message-id/19560-54cd7ede78d5e355%40postgresql.org" rel="noopener noreferrer"&gt;https://www.postgresql.org/message-id/19560-54cd7ede78d5e355%40postgresql.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Discussion thread: &lt;a href="https://www.postgresql.org/message-id/flat/1186816.1784573544%40sss.pgh.pa.us" rel="noopener noreferrer"&gt;https://www.postgresql.org/message-id/flat/1186816.1784573544%40sss.pgh.pa.us&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL docs, explicit JOIN control and &lt;code&gt;join_collapse_limit&lt;/code&gt;: &lt;a href="https://www.postgresql.org/docs/current/explicit-joins.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/explicit-joins.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL roadmap (release dates): &lt;a href="https://www.postgresql.org/developer/roadmap/" rel="noopener noreferrer"&gt;https://www.postgresql.org/developer/roadmap/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source files: &lt;code&gt;src/backend/optimizer/plan/planmain.c&lt;/code&gt;, &lt;code&gt;initsplan.c&lt;/code&gt;, &lt;code&gt;analyzejoins.c&lt;/code&gt;, &lt;code&gt;src/backend/optimizer/path/allpaths.c&lt;/code&gt; (line numbers from REL_15_8 and master as of 2026-11)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>backend</category>
      <category>database</category>
    </item>
  </channel>
</rss>
