<?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: Yash Chavan</title>
    <description>The latest articles on DEV Community by Yash Chavan (@danny0728).</description>
    <link>https://dev.to/danny0728</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%2F4244841%2Fa8262d64-7738-4482-b027-e36fc7cb3f78.jpg</url>
      <title>DEV Community: Yash Chavan</title>
      <link>https://dev.to/danny0728</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danny0728"/>
    <language>en</language>
    <item>
      <title>I Wrote a Wasteful JOIN on Purpose. It Ran Faster, Until It Didn't.</title>
      <dc:creator>Yash Chavan</dc:creator>
      <pubDate>Sat, 14 Nov 2026 18:33:47 +0000</pubDate>
      <link>https://dev.to/danny0728/i-wrote-a-wasteful-join-on-purpose-it-ran-faster-until-it-didnt-2ifj</link>
      <guid>https://dev.to/danny0728/i-wrote-a-wasteful-join-on-purpose-it-ran-faster-until-it-didnt-2ifj</guid>
      <description>&lt;p&gt;On my projects, GORM wrote most of the SQL. We used it for almost everything and dropped down to a SQL library only for the complex queries. Over time GORM became the comfortable default, and the idea of writing raw SQL with sqlc and pgx made me uneasy.&lt;/p&gt;

&lt;p&gt;The worry was specific. GORM has its own way of building queries, and I trusted its defaults to produce something reasonably efficient. When I write the SQL myself, nothing stops me from taking a strange route: joining through a table I don't need, because the result comes out the same. The rows are right, so nothing looks wrong, and the wasted work stays hidden.&lt;/p&gt;

&lt;p&gt;sqlc will tell me if a query is invalid or a type doesn't match. It won't tell me the query is doing pointless work.&lt;/p&gt;

&lt;p&gt;So I wrote a wasteful join on purpose and measured it. On small data it made no real difference. On 5 million rows it finished ahead of the clean query. Then I changed one setting, and it took nearly twice as long.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;The test uses three tables: &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;addresses&lt;/code&gt; (one per user, with a &lt;code&gt;city&lt;/code&gt;) and &lt;code&gt;orders&lt;/code&gt; (many per user).&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;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;   &lt;span class="kt"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&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;addresses&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;      &lt;span class="kt"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nf"&gt;users&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;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;city&lt;/span&gt;    &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&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;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="kt"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="nf"&gt;users&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;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;total&lt;/span&gt;      &lt;span class="kt"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="kt"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="nb"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_user_id_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&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="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;addresses_city_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;city&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;The task: fetch every order placed by a user who lives in Pune.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query A&lt;/strong&gt; takes the route I was afraid of. It goes through &lt;code&gt;users&lt;/code&gt;, although the result needs nothing from that table.&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&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;span class="n"&gt;o&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;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&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="n"&gt;u&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Pune'&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;Query B&lt;/strong&gt; goes straight from &lt;code&gt;orders&lt;/code&gt; to &lt;code&gt;addresses&lt;/code&gt;.&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&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;span class="n"&gt;o&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;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&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="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Pune'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both return exactly the same rows. Every order has a &lt;code&gt;user_id&lt;/code&gt; that points at a real user, so joining &lt;code&gt;users&lt;/code&gt; adds nothing and removes nothing.&lt;/p&gt;

&lt;p&gt;I ran each query with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, which executes the query and prints the plan PostgreSQL chose along with the real timings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;Everything runs in one PostgreSQL 16 container. The two files, &lt;code&gt;setup.sql&lt;/code&gt; and &lt;code&gt;queries.sql&lt;/code&gt;, are in &lt;a href="https://github.com/Danny0728/postgres-wasteful-join-test" rel="noopener noreferrer"&gt;this GitHub repository&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run --name pgtest -e &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pass -d postgres:16
&lt;span class="c"&gt;# wait about 10 seconds for the server to start&lt;/span&gt;

docker cp setup.sql pgtest:/setup.sql
docker cp queries.sql pgtest:/queries.sql

docker &lt;span class="nb"&gt;exec&lt;/span&gt; pgtest psql -U postgres -f /setup.sql
docker &lt;span class="nb"&gt;exec&lt;/span&gt; pgtest psql -U postgres -f /queries.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setup.sql&lt;/code&gt; creates the tables and fills them with &lt;code&gt;generate_series&lt;/code&gt;. I used two sizes.&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;Small&lt;/th&gt;
&lt;th&gt;Large&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Users&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;5,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Addresses&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;5,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orders&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;5,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows returned for Pune&lt;/td&gt;
&lt;td&gt;100,369&lt;/td&gt;
&lt;td&gt;1,000,484&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Addresses are spread evenly over five cities, so Pune holds one fifth of the users.&lt;/p&gt;

&lt;p&gt;The small size is the default. For the large one, the same file takes the row counts on the command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; pgtest psql -U postgres -v &lt;span class="nv"&gt;users&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000000 -v &lt;span class="nv"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5000000 -f /setup.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Round 1: small data
&lt;/h2&gt;

&lt;p&gt;On the small dataset the two queries were level. Across five runs, Query A had a median of 59.6 ms and Query B 65.2 ms.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Query A (through &lt;code&gt;users&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Query B (direct)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;60.6 ms&lt;/td&gt;
&lt;td&gt;70.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;73.3 ms&lt;/td&gt;
&lt;td&gt;64.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;58.1 ms&lt;/td&gt;
&lt;td&gt;63.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;59.6 ms&lt;/td&gt;
&lt;td&gt;65.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;58.2 ms&lt;/td&gt;
&lt;td&gt;65.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The plan for Query A shows the extra work clearly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-&amp;gt;  Seq Scan on users u  (cost=0.00..819.00 rows=50000 width=8) (actual time=0.003..2.741 rows=50000 loops=1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL read all 50,000 users even though the result uses none of their columns. It did not drop the join for me. That detour cost about 7 ms: the address-and-user stage finished at roughly 10 ms in Query A, against 2.5 ms for the equivalent stage in Query B.&lt;/p&gt;

&lt;p&gt;So why wasn't Query A slower overall? The two plans also fetched orders differently. Query A looked up each user's orders through the index, 10,000 small lookups. Query B read all 500,000 orders and kept the matches. On my machine the index lookups saved more time than the detour cost.&lt;/p&gt;

&lt;p&gt;That part is not a rule. The same files on another machine gave the opposite order, with Query B ahead. At this size the difference is too small to matter, and the run time says nothing about the wasted join.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: 5 million rows
&lt;/h2&gt;

&lt;p&gt;On the large dataset, with PostgreSQL's default settings, the query with the unnecessary join finished first. Query A took 1,379 ms and Query B took 1,829 ms, so the wasteful query was about 25% faster on the clock.&lt;/p&gt;

&lt;p&gt;The reason is in the first lines of Query A's plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gather
  Workers Planned: 2
  Workers Launched: 2
  -&amp;gt;  Parallel Hash Join
        -&amp;gt;  Parallel Seq Scan on orders o
        -&amp;gt;  Parallel Hash
              -&amp;gt;  Parallel Hash Join
                    -&amp;gt;  Parallel Seq Scan on users u  (... rows=1666667 loops=3)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL ran Query A as a parallel query: the main process plus two workers, three CPU cores in total. Query B's plan had no &lt;code&gt;Gather&lt;/code&gt; node, so it ran on one core.&lt;/p&gt;

&lt;p&gt;Query A was not doing less work. It was doing more work, spread over three cores, and it still read all 5 million users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: a single CPU core for each query
&lt;/h2&gt;

&lt;p&gt;With both queries limited to a single core, the unnecessary join made the query take 1.9 times as long: 3,185 ms for Query A against 1,681 ms for Query B.&lt;/p&gt;

&lt;p&gt;I switched parallel workers off for the session and ran the same file again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; pgtest psql -U postgres -c &lt;span class="s2"&gt;"SET max_parallel_workers_per_gather = 0;"&lt;/span&gt; -f /queries.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan shows where the time went. In Query A, reading 5 million users and joining them to the Pune addresses was not finished until about 1,600 ms. Query B had its Pune addresses ready at about 276 ms. The detour through &lt;code&gt;users&lt;/code&gt; cost roughly 1.3 seconds.&lt;/p&gt;

&lt;p&gt;A busy production server may not have spare cores to hand out, so I treat this as the more realistic number.&lt;/p&gt;

&lt;h2&gt;
  
  
  All three results side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Query A (through &lt;code&gt;users&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Query B (direct)&lt;/th&gt;
&lt;th&gt;What it looks like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small data, median of 5 runs&lt;/td&gt;
&lt;td&gt;59.6 ms&lt;/td&gt;
&lt;td&gt;65.2 ms&lt;/td&gt;
&lt;td&gt;No real difference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 million rows, default settings&lt;/td&gt;
&lt;td&gt;1,379 ms&lt;/td&gt;
&lt;td&gt;1,829 ms&lt;/td&gt;
&lt;td&gt;A looks 25% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 million rows, single CPU core&lt;/td&gt;
&lt;td&gt;3,185 ms&lt;/td&gt;
&lt;td&gt;1,681 ms&lt;/td&gt;
&lt;td&gt;A takes 1.9 times as long&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same unnecessary join looked harmless, then helpful, then expensive. The only things that changed were the data size and how many cores the query was given.&lt;/p&gt;

&lt;p&gt;In all three cases the plan showed the extra scan of &lt;code&gt;users&lt;/code&gt;. The run time never did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do differently now
&lt;/h2&gt;

&lt;p&gt;Before running any of this, I expected the direct query to win every time. It seemed like common sense: fewer tables, less work. Common sense was right in the end, but the clock hid it twice.&lt;/p&gt;

&lt;p&gt;Two habits came out of this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decide what the query needs before writing it.&lt;/strong&gt; I start from the table that holds the rows I want and add only the tables the result or the filter really needs. PostgreSQL chooses the order it reads tables in, but in this test it never removed the table I had included by mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the plan, not the run time.&lt;/strong&gt; For any query with a join I now run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and look for two things: a scan of a table the result doesn't use, and a &lt;code&gt;Gather&lt;/code&gt; node that could be making a slow plan look fast.
For anyone moving from GORM to sqlc, this is the gap to know about. sqlc checks that the SQL is valid and that the types match. Whether the query is efficient is still my job, and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is the tool for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits of this test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It ran on one machine, with PostgreSQL 16 in Docker Desktop and the default configuration.&lt;/li&gt;
&lt;li&gt;The large-data timings are single runs. The small-data figures are medians of five runs.&lt;/li&gt;
&lt;li&gt;The large dataset has about one order per user and the small one about ten, so the two differ in shape as well as size.&lt;/li&gt;
&lt;li&gt;It covers one query shape. A different join, filter or index could behave differently.&lt;/li&gt;
&lt;li&gt;I have not yet compared these queries with the SQL that GORM generates for the same task. That is the next test.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
      <category>go</category>
    </item>
  </channel>
</rss>
