<?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: Rohit Chaurasia</title>
    <description>The latest articles on DEV Community by Rohit Chaurasia (@rohit9007).</description>
    <link>https://dev.to/rohit9007</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%2F4167227%2F23c9b049-bc17-4840-9cec-102702009cb0.png</url>
      <title>DEV Community: Rohit Chaurasia</title>
      <link>https://dev.to/rohit9007</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rohit9007"/>
    <language>en</language>
    <item>
      <title>I scanned 1,152 dbt models for joins that inflate SUM. Here is what I found.</title>
      <dc:creator>Rohit Chaurasia</dc:creator>
      <pubDate>Tue, 06 Oct 2026 19:12:07 +0000</pubDate>
      <link>https://dev.to/rohit9007/i-scanned-1152-dbt-models-for-joins-that-inflate-sum-here-is-what-i-found-1ea</link>
      <guid>https://dev.to/rohit9007/i-scanned-1152-dbt-models-for-joins-that-inflate-sum-here-is-what-i-found-1ea</guid>
      <description>&lt;p&gt;There is a bug in analytics SQL that never throws an error.&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;revenue&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;left&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_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;order_id&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&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;customer_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Customer 1 has two orders worth ₹150 in total. They paid in five instalments. After the join, each order appears once per payment, and SUM adds it every time. The query says ₹400.&lt;/p&gt;

&lt;p&gt;This is called a fan out, or a fan trap. Its cousin is the chasm trap: one table joined to two one to many tables at once, where the two multiply each other. BI tools have some protection against it inside their semantic layers. Plain SQL and dbt have none.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: track the grain
&lt;/h2&gt;

&lt;p&gt;The grain of a table is what one row means. One row per order. One row per (customer, day). If you know the grain of both sides of a join, you know whether the join can repeat rows. If it can, any SUM or COUNT over the repeated side is suspicious.&lt;/p&gt;

&lt;p&gt;You already declare most of this in dbt. A unique test on &lt;code&gt;order_id&lt;/code&gt; says &lt;code&gt;stg_orders&lt;/code&gt; is one row per order. &lt;a href="https://github.com/rohitchaurasia195-web/GRAINGUARD" rel="noopener noreferrer"&gt;grainguard&lt;/a&gt; reads those declarations, infers the rest from the SQL (&lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt;, &lt;code&gt;QUALIFY ROW_NUMBER() = 1&lt;/code&gt;, &lt;code&gt;LIMIT 1&lt;/code&gt;) and carries the grain through CTEs, subqueries and refs in dependency order.&lt;/p&gt;

&lt;p&gt;Then for every aggregate it asks one question: can the rows this column comes from be repeated by a join? If yes, and the aggregate is sensitive to duplicates (SUM, COUNT, AVG), it reports it with a fix. MIN, MAX and COUNT(DISTINCT) are never reported.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR FANOUT_AGG  models/marts/customer_revenue.sql
  SUM(o.amount) is computed after LEFT join to stg_payments (alias p)
  on p.order_id = o.order_id [one row per (payment_id)].
  Rows of stg_orders (alias o) can be matched more than once,
  so this value is inflated.
  Fix: Aggregate stg_payments to one row per join key in a CTE before joining.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I found in public projects
&lt;/h2&gt;

&lt;p&gt;I ran grainguard 0.1.0 on 14 public dbt projects.&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Models&lt;/td&gt;
&lt;td&gt;1,152 (3.2% could not be parsed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Joins&lt;/td&gt;
&lt;td&gt;765: 236 provably safe, 66 provably fan out, 463 unknown grain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregates after a join&lt;/td&gt;
&lt;td&gt;346&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flagged&lt;/td&gt;
&lt;td&gt;89 (39 fan outs, 50 chasm traps) in 14 models&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three lessons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First&lt;/strong&gt;, 61% of joins go to relations whose grain is not declared anywhere in the project. Often the unique tests live in a separate source package. A tool can only prove what it is told, and neither can a reviewer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second&lt;/strong&gt;, every flagged aggregate I read by hand was a latent fan out: correct only if some uniqueness assumption holds, and nothing in the project states or tests that assumption. These are not bugs today. They are bugs the day the data changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third&lt;/strong&gt;, real code made the tool better. Two idioms caused false positives at first and are now handled with tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;grainguard
grainguard check path/to/your/dbt_project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs no database and no credentials, and the exit code makes it easy to put in CI.&lt;/p&gt;

&lt;p&gt;Code, survey script and results: &lt;a href="https://github.com/rohitchaurasia195-web/GRAINGUARD" rel="noopener noreferrer"&gt;https://github.com/rohitchaurasia195-web/GRAINGUARD&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If it is wrong on your SQL, please open an issue. That is the most useful thing you can send me.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>dbt</category>
      <category>dataengineering</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
