<?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: Rahman</title>
    <description>The latest articles on DEV Community by Rahman (@rahmanfrr).</description>
    <link>https://dev.to/rahmanfrr</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%2F4105540%2F74d721d1-cdda-4124-9e8d-20cdbf66ffe7.png</url>
      <title>DEV Community: Rahman</title>
      <link>https://dev.to/rahmanfrr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rahmanfrr"/>
    <language>en</language>
    <item>
      <title>SQL Interview Question: Find App Store Power Purchasers with Two-Level GROUP BY</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:30:32 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/sql-interview-question-find-app-store-power-purchasers-with-two-level-group-by-42l4</link>
      <guid>https://dev.to/rahmanfrr/sql-interview-question-find-app-store-power-purchasers-with-two-level-group-by-42l4</guid>
      <description>&lt;p&gt;Some SQL problems look simple until you read the rules twice. This one is tagged Apple and rated hard, and it teaches a useful pattern: &lt;strong&gt;grouping the results of a grouping&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let's solve it step by step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem in Plain English
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a power purchaser?
&lt;/h3&gt;

&lt;p&gt;A power purchaser is a customer who made &lt;strong&gt;at least 3 in-app purchases in each&lt;/strong&gt; of these months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;April 2023&lt;/li&gt;
&lt;li&gt;May 2023&lt;/li&gt;
&lt;li&gt;June 2023&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss the mark in even one month, and the customer is out. No purchases in a month also means out.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should the query return?
&lt;/h3&gt;

&lt;p&gt;For every power purchaser, return:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;total_amount_spent&lt;/code&gt;: the sum of &lt;strong&gt;all&lt;/strong&gt; their purchases from April 1 to June 30, 2023, rounded to 2 decimal places&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sort by &lt;code&gt;total_amount_spent&lt;/code&gt; from high to low. If two users tie, the smaller &lt;code&gt;user_id&lt;/code&gt; comes first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules that trip people up
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A purchase with a &lt;code&gt;NULL&lt;/code&gt; amount still &lt;strong&gt;counts as a purchase&lt;/strong&gt;. It adds &lt;code&gt;0&lt;/code&gt; to the total.&lt;/li&gt;
&lt;li&gt;A purchase with &lt;code&gt;0.00&lt;/code&gt; also counts as a purchase and adds &lt;code&gt;0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The total includes every purchase in the window, not only the first 3 in each month.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Tables
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;users&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;user_id&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;join_date&lt;/td&gt;
&lt;td&gt;date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;email&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;purchases&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;purchase_id&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;user_id&lt;/td&gt;
&lt;td&gt;integer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;purchase_date&lt;/td&gt;
&lt;td&gt;date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;amount&lt;/td&gt;
&lt;td&gt;decimal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A Quick Example
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;users&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;join_date&lt;/th&gt;
&lt;th&gt;email&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2022-03-14&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:j.alvarado@icloud.com"&gt;j.alvarado@icloud.com&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2021-08-02&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:s.moreau@icloud.com"&gt;s.moreau@icloud.com&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;purchases&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;purchase_id&lt;/th&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;purchase_date&lt;/th&gt;
&lt;th&gt;amount&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;601&lt;/td&gt;
&lt;td&gt;2023-04-03&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-04-03&lt;/td&gt;
&lt;td&gt;1.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-04-01&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-05-01&lt;/td&gt;
&lt;td&gt;2.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-05-02&lt;/td&gt;
&lt;td&gt;1.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-05-03&lt;/td&gt;
&lt;td&gt;4.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-05-04&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-06-01&lt;/td&gt;
&lt;td&gt;1.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-06-02&lt;/td&gt;
&lt;td&gt;2.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;2023-06-03&lt;/td&gt;
&lt;td&gt;9.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-04-03&lt;/td&gt;
&lt;td&gt;1.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-04-03&lt;/td&gt;
&lt;td&gt;2.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-04-01&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-05-01&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-05-02&lt;/td&gt;
&lt;td&gt;1.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-05-03&lt;/td&gt;
&lt;td&gt;2.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-06-01&lt;/td&gt;
&lt;td&gt;1.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;2023-06-02&lt;/td&gt;
&lt;td&gt;2.99&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;User &lt;code&gt;601&lt;/code&gt; made 3 purchases in April (one was &lt;code&gt;0.00&lt;/code&gt;), 4 in May, and 3 in June. Every month reaches 3, so this user qualifies. The total across all 10 purchases is &lt;code&gt;28.91&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;User &lt;code&gt;602&lt;/code&gt; made 3 in April, 3 in May, but only 2 in June. That is one month short, so this user is out.&lt;/p&gt;

&lt;p&gt;Expected output:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;email&lt;/th&gt;
&lt;th&gt;total_amount_spent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:j.alvarado@icloud.com"&gt;j.alvarado@icloud.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;28.91&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to Think About It
&lt;/h2&gt;

&lt;p&gt;The trick is that we need &lt;strong&gt;two rounds of grouping&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Group by user and month to find which months have 3 or more purchases.&lt;/li&gt;
&lt;li&gt;Group those good months by user to find who has all 3 months.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A regular &lt;code&gt;WHERE&lt;/code&gt; can't do this, because we are filtering on counts. That is the job of &lt;code&gt;HAVING&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Solution
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Count purchases per user, per month
&lt;/h3&gt;

&lt;p&gt;First, keep only the April to June 2023 rows. Then group by user and month.&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MONTH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;purchase_date&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;purchase_month&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;purchase_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;purchase_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2023-04-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2023-06-30'&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MONTH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;purchase_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For our example, this gives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;purchase_month&lt;/th&gt;
&lt;th&gt;purchase_count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;602&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;COUNT(*)&lt;/code&gt; counts rows, so &lt;code&gt;NULL&lt;/code&gt; amounts are counted too. That is what the problem wants.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Keep only months with 3 or more purchases
&lt;/h3&gt;

&lt;p&gt;Add a &lt;code&gt;HAVING&lt;/code&gt; clause to drop weak months.&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;HAVING&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now user &lt;code&gt;602&lt;/code&gt;'s June row (count 2) is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Find users who passed in all 3 months
&lt;/h3&gt;

&lt;p&gt;Below, &lt;code&gt;monthly_counts&lt;/code&gt; stands for the result of Steps 1 and 2. In the final query, we wrap it in a CTE with that name.&lt;/p&gt;

&lt;p&gt;Each remaining row is one qualifying month for one user. So if a user has exactly 3 rows, they passed April, May, and June.&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;user_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;monthly_counts&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;user_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&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="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user with no purchases in a month never appears for that month, so they can't reach 3 rows. The "missing month" rule is handled automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Add up all spending and sort
&lt;/h3&gt;

&lt;p&gt;Now join back to &lt;code&gt;purchases&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt;. Joining to the list of power users keeps only qualifying customers. Then sum every purchase in the window, not just the ones that passed the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Query
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;monthly_counts&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MONTH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;purchase_date&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;purchase_month&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;purchase_count&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;purchase_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2023-04-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2023-06-30'&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MONTH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;purchase_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;HAVING&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;power_users&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="n"&gt;user_id&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;monthly_counts&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;user_id&lt;/span&gt;
  &lt;span class="k"&gt;HAVING&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="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;user_id&lt;/span&gt;&lt;span class="p"&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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&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;p&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&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;AS&lt;/span&gt; &lt;span class="n"&gt;total_amount_spent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;purchases&lt;/span&gt; &lt;span class="n"&gt;p&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;user_id&lt;/span&gt; &lt;span class="o"&gt;=&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;user_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;power_users&lt;/span&gt; &lt;span class="n"&gt;pu&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pu&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;p&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purchase_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2023-04-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2023-06-30'&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;u&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;total_amount_spent&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&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;user_id&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small notes on this version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SUM&lt;/code&gt; skips &lt;code&gt;NULL&lt;/code&gt; values, so they add &lt;code&gt;0&lt;/code&gt; as required. &lt;code&gt;COALESCE&lt;/code&gt; is a safety net in case a user's amounts are all &lt;code&gt;NULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The CTEs (the &lt;code&gt;WITH&lt;/code&gt; blocks) give each step a name, so the query reads from top to bottom.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common Mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;WHERE COUNT(*) &amp;gt;= 3&lt;/code&gt;.&lt;/strong&gt; Aggregates can't go in &lt;code&gt;WHERE&lt;/code&gt;. Use &lt;code&gt;HAVING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Summing only the qualifying purchases.&lt;/strong&gt; The total must cover every purchase in the window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropping &lt;code&gt;NULL&lt;/code&gt; or &lt;code&gt;0.00&lt;/code&gt; rows.&lt;/strong&gt; They count toward the 3-purchase minimum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking the total count instead of each month.&lt;/strong&gt; A user with 9 purchases in April alone should not pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Small Things to Watch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;EXTRACT(MONTH ...)&lt;/code&gt; works in PostgreSQL, MySQL, and DuckDB. In SQL Server, use &lt;code&gt;MONTH(purchase_date)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If your data covered more than one year, group by the year as well. Otherwise April 2022 and April 2023 would be mixed together.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;purchase_date&lt;/code&gt; were a timestamp, &lt;code&gt;BETWEEN ... AND '2023-06-30'&lt;/code&gt; would miss most of June 30. Use &lt;code&gt;&amp;lt; '2023-07-01'&lt;/code&gt; instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-Up
&lt;/h2&gt;

&lt;p&gt;The pattern here is worth remembering: &lt;strong&gt;group, filter with &lt;code&gt;HAVING&lt;/code&gt;, then group again&lt;/strong&gt;. It shows up in many interview questions about streaks, "every month" rules, and consistency checks.&lt;/p&gt;

&lt;p&gt;Try changing the rules and see if you can adapt the query. For example, what if a user only needed 3 purchases in &lt;em&gt;any two&lt;/em&gt; of the three months?&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>interview</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Meta's "Combined Reaction Volume" SQL Question, Explained Simply</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:30:09 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/metas-combined-reaction-volume-sql-question-explained-simply-25o6</link>
      <guid>https://dev.to/rahmanfrr/metas-combined-reaction-volume-sql-question-explained-simply-25o6</guid>
      <description>&lt;p&gt;Your Feed post gets turned into a Reel. Somebody watches it and taps like. Does the database record that as one like, or two?&lt;/p&gt;

&lt;p&gt;That messy little detail is the whole point of a Meta SQL interview question called Combined Reaction Volume. It looks like a basic counting problem. It isn't. And if you rush it, you'll get a number that's technically correct and completely wrong at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Meta keeps reactions to Feed posts and Reels in two separate tables, because the two products are built by separate teams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;post_reactions&lt;/code&gt; — likes, loves, and other reactions on regular Feed posts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reel_reactions&lt;/code&gt; — the same kinds of reactions, but on Reels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the catch. A Reel can get cross-posted to the Feed. When that happens, one single tap of the like button can end up logged in &lt;em&gt;both&lt;/em&gt; tables. On top of that, retry logic sometimes fires twice and inserts the exact same row into one table by mistake.&lt;/p&gt;

&lt;p&gt;So the real question isn't "how many rows are there." It's "how many &lt;em&gt;real&lt;/em&gt; reactions happened," once you strip out the copies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as "the same reaction"
&lt;/h2&gt;

&lt;p&gt;The question defines a reaction event as a match on three things: &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;content_id&lt;/code&gt;, and &lt;code&gt;reaction_date&lt;/code&gt;. If those three line up between two rows — even across the two different tables, even if one row has a &lt;code&gt;NULL&lt;/code&gt; reaction type — you treat them as one event, not two.&lt;/p&gt;

&lt;p&gt;The task: for each user, count their distinct reaction events between January 1 and March 31, 2024, and only keep users with at least 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where people go wrong
&lt;/h2&gt;

&lt;p&gt;The instinct is to smash both tables together and count. Something like this:&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="c1"&gt;-- Looks right. Silently double-counts.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;reaction_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&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;content_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;post_reactions&lt;/span&gt;
    &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&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;content_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reel_reactions&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;combined&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;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UNION ALL&lt;/code&gt; stacks every row from both tables on top of each other, duplicates included. Run this against the sample data and user 10 comes back with 6 reactions. The real number is 5. That one cross-posted like got counted twice, and the query has no idea it did anything wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solving it, step by step
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Cut each table down to just what defines an event.&lt;/strong&gt;&lt;br&gt;
Drop &lt;code&gt;reaction_id&lt;/code&gt; and &lt;code&gt;reaction_type&lt;/code&gt;, they're not part of what makes an event unique. Keep only &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;content_id&lt;/code&gt;, and &lt;code&gt;reaction_date&lt;/code&gt;, and filter both tables to the Q1 2024 range.&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;post_reactions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-03-31'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — Combine the two tables with &lt;code&gt;UNION&lt;/code&gt;, not &lt;code&gt;UNION ALL&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
This is the actual trick. Plain &lt;code&gt;UNION&lt;/code&gt; automatically drops exact duplicate rows, whether they came from the same table twice or from two different tables. Swap &lt;code&gt;UNION ALL&lt;/code&gt; for &lt;code&gt;UNION&lt;/code&gt; and the double-counted like disappears on its own.&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="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;content_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reel_reactions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-03-31'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3 — Count what's left, per user.&lt;/strong&gt;&lt;br&gt;
Wrap the combined result in a subquery and group by &lt;code&gt;user_id&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;distinct_reaction_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="n"&gt;combined_reactions&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;user_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4 — Apply the 5-event minimum, then sort.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;HAVING&lt;/code&gt; filters on the group total, not on individual rows, so it comes after &lt;code&gt;GROUP BY&lt;/code&gt;. Sort by count descending, and by &lt;code&gt;user_id&lt;/code&gt; ascending to keep ties in a fixed order.&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;HAVING&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distinct_reaction_count&lt;/span&gt; &lt;span class="k"&gt;DESC&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;ASC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put it all together:&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;distinct_reaction_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&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;content_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;post_reactions&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-03-31'&lt;/span&gt;
    &lt;span class="k"&gt;UNION&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&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;content_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reel_reactions&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;reaction_date&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-03-31'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;combined_reactions&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;user_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distinct_reaction_count&lt;/span&gt; &lt;span class="k"&gt;DESC&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;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this one matters
&lt;/h2&gt;

&lt;p&gt;Most people learn &lt;code&gt;UNION&lt;/code&gt; as "the thing that stacks two tables together" and &lt;code&gt;UNION ALL&lt;/code&gt; as "the faster version of the same thing." This question flips that around. &lt;code&gt;UNION&lt;/code&gt; isn't just a stacking tool, it's a deduplication tool, and knowing when you actually want duplicates removed versus when you don't is the real skill being tested here, not the syntax.&lt;/p&gt;

&lt;p&gt;Practice these types of questions on &lt;a href="https://datacurlew.in" rel="noopener noreferrer"&gt;DataCurlew&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>interview</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I built a 100% private WhatsApp chat analyzer (Zero servers, zero tracking)</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Sat, 19 Sep 2026 08:11:31 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/i-built-a-100-private-whatsapp-chat-analyzer-zero-servers-zero-tracking-1cpl</link>
      <guid>https://dev.to/rahmanfrr/i-built-a-100-private-whatsapp-chat-analyzer-zero-servers-zero-tracking-1cpl</guid>
      <description>&lt;p&gt;Most chat analysis tools ask you to upload private conversation archives to a remote server. &lt;/p&gt;

&lt;p&gt;Even if the site claims "we delete logs immediately," handing unencrypted personal chats to an unfamiliar API is a major privacy compromise.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;&lt;a href="https://purplechats.pages.dev" rel="noopener noreferrer"&gt;PurpleChats&lt;/a&gt;&lt;/strong&gt; to solve this with one rule: &lt;strong&gt;zero backend&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every calculation—from unzipping &lt;code&gt;.zip&lt;/code&gt; exports to generating interactive Spotify Wrapped-style cards—runs strictly in the user's browser.&lt;/p&gt;




&lt;h3&gt;
  
  
  What It Does
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;🎁 &lt;strong&gt;WhatsApp Wrapped Mode&lt;/strong&gt;: 3D interactive story cards showing chat streaks, late-night chatter, and response time percentiles.&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;Group Dynamics&lt;/strong&gt;: Response latency rankings, conversation starter badges, and interactive interaction graphs.&lt;/li&gt;
&lt;li&gt;🔒 &lt;strong&gt;100% Client-Side Privacy&lt;/strong&gt;: No databases, no telemetry, and zero network calls. When you close the tab, the data is gone.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  How It Works Under the Hood
&lt;/h3&gt;

&lt;p&gt;Parsing tens of thousands of messages on the main browser thread causes frame drops. &lt;/p&gt;

&lt;p&gt;To keep the UI running at a clean 60fps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In-Memory Unzipping&lt;/strong&gt;: Uses &lt;code&gt;fflate&lt;/code&gt; to unpack &lt;code&gt;.zip&lt;/code&gt; archives directly into RAM without saving temporary files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers&lt;/strong&gt;: All regex matching, date normalization, and metric calculations run in a background worker. A 30,000-message chat processes in under &lt;strong&gt;180ms&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas Card Export&lt;/strong&gt;: Uses &lt;code&gt;html-to-image&lt;/code&gt; to let users export shareable metric cards as PNGs directly from the DOM.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  Tech Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework&lt;/strong&gt;: React 18 + TypeScript + Vite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Styling&lt;/strong&gt;: Tailwind CSS (editorial warm-paper design)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing&lt;/strong&gt;: Web Workers + &lt;code&gt;fflate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting&lt;/strong&gt;: Cloudflare Pages (free static CDN)&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Try It Out
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Live App&lt;/strong&gt;: &lt;a href="https://purplechats.pages.dev" rel="noopener noreferrer"&gt;https://purplechats.pages.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 &lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/Rahmman001/PurpleChats" rel="noopener noreferrer"&gt;https://github.com/Rahmman001/PurpleChats&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is fully open source under the MIT license. Feedback and contributions are welcome!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why I Put PostgreSQL in WebAssembly to Fix SQL Interview Prep</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Thu, 17 Sep 2026 10:17:20 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/why-i-put-postgresql-in-webassembly-to-fix-sql-interview-prep-24fc</link>
      <guid>https://dev.to/rahmanfrr/why-i-put-postgresql-in-webassembly-to-fix-sql-interview-prep-24fc</guid>
      <description>&lt;p&gt;A few months ago, a backend engineer friend was preparing for a Meta data engineering screen. He asked me to review his SQL prep routine. &lt;/p&gt;

&lt;p&gt;He was using the usual suspects: two popular coding websites and a paid course. But watching him practice was painful. &lt;/p&gt;

&lt;p&gt;Every single query submission triggered a loading spinner that hung for three to six seconds while a remote backend spun up a container, connected to an ephemeral database, ran the query, and shipped back a JSON blob. Worse, whenever he tried using PostgreSQL-specific functions—like &lt;code&gt;DATE_TRUNC('month', ts)&lt;/code&gt; or window functions with specific frame clauses—half the platforms choked. Why? Because under the hood, many "SQL interview" platforms run SQLite with light string-replacement hacks rather than real Postgres.&lt;/p&gt;

&lt;p&gt;Day-to-day SQL is usually simple: an ORM query, a basic &lt;code&gt;GROUP BY&lt;/code&gt;, or a quick dashboard filter. &lt;/p&gt;

&lt;p&gt;FAANG technical interviews are completely different. Companies like Meta, Google, and Amazon test edge-case relational logic: 7-day rolling user retention across discontinuous date gaps, dense ranking ties with multi-column partitions, and recursive hierarchy trees.&lt;/p&gt;

&lt;p&gt;Practicing those problems shouldn't feel like waiting on dial-up internet.&lt;/p&gt;

&lt;p&gt;I wanted something faster. No servers. No container spin-ups. Real PostgreSQL. Instant grading. &lt;/p&gt;

&lt;p&gt;Here is how I built &lt;strong&gt;DataCurlew&lt;/strong&gt; using PostgreSQL 16 compiled to WebAssembly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: Why Run Postgres in the Browser?
&lt;/h2&gt;

&lt;p&gt;Traditionally, an online code execution platform looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser (Monaco/CodeMirror) 
  → API Gateway 
  → Queue (RabbitMQ / Redis) 
  → Sandbox Worker (Docker / Firecracker) 
  → Ephemeral DB 
  → Response (3–5 seconds total)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running that architecture has two major problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; Developers hate feedback delays. When you are debugging a nested CTE, waiting 4 seconds per run destroys your mental flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure Cost:&lt;/strong&gt; Hosting thousands of isolated Docker containers executing arbitrary SQL costs real money, which gets passed on to users through steep monthly subscriptions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Enter PGlite and WebAssembly
&lt;/h3&gt;

&lt;p&gt;ElectricSQL recently open-sourced &lt;strong&gt;PGlite&lt;/strong&gt;—a lightweight build of official PostgreSQL packaged into a WebAssembly binary. It isn't a mock or an emulator. It is the real C source of PostgreSQL compiled via Emscripten down to roughly 3MB gzipped.&lt;/p&gt;

&lt;p&gt;That shifted the entire architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser Tab (React 18)
  ├── CodeMirror 6 (Editor)
  ├── PGlite WASM (In-Memory PostgreSQL 16 Engine)
  └── Grading Engine (Deterministic Table Diffing)

Total Execution Latency: ~1.4ms
Server Load: Zero
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you hit &lt;strong&gt;Run Query&lt;/strong&gt; on DataCurlew, the query doesn't leave your machine. It executes in-memory inside the browser's WebAssembly runtime. &lt;/p&gt;

&lt;p&gt;The query runs in &lt;strong&gt;1.4 milliseconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Because it runs real PostgreSQL 16, every single Postgres feature works out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Window functions with custom frame specifications (&lt;code&gt;ROWS BETWEEN 1 PRECEDING AND CURRENT ROW&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Common Table Expressions (including &lt;code&gt;WITH RECURSIVE&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Full datetime math (&lt;code&gt;INTERVAL '7 days'&lt;/code&gt;, &lt;code&gt;DATE_TRUNC&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Native JSONB manipulation (&lt;code&gt;jsonb_array_elements&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Statistical aggregates (&lt;code&gt;PERCENTILE_CONT&lt;/code&gt;, &lt;code&gt;FILTER (WHERE ...)&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Two Hard Problems We Had to Solve
&lt;/h2&gt;

&lt;p&gt;Moving the database into the client sounds simple until you actually grade user submissions. &lt;/p&gt;

&lt;h3&gt;
  
  
  1. Fast, Deterministic Sandbox Resets
&lt;/h3&gt;

&lt;p&gt;In an interview, candidates write destructive or state-altering statements (&lt;code&gt;DROP TABLE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;), or they introduce infinite loops in recursive CTEs.&lt;/p&gt;

&lt;p&gt;If the user mutates an input table, how do you prevent that mutation from corrupting the hidden test cases?&lt;/p&gt;

&lt;p&gt;Instead of re-downloading schemas over the network for every run, we store compiled DDL blueprints in client memory. Before evaluating a candidate's solution against hidden datasets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The engine spins an isolated in-memory PGlite instance.&lt;/li&gt;
&lt;li&gt;Tables are seeded with test fixtures via raw SQL buffers.&lt;/li&gt;
&lt;li&gt;The user query executes against that isolated session.&lt;/li&gt;
&lt;li&gt;A deterministic diff compares the resulting row matrix against the expected ground-truth dataset.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This entire sequence completes in under 20 milliseconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Table Comparison is Harder Than It Looks
&lt;/h3&gt;

&lt;p&gt;In standard algorithmic problems, checking &lt;code&gt;output === expected&lt;/code&gt; works. In SQL, two queries can be mathematically identical while producing subtle tabular differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Type Coercion:&lt;/strong&gt; One query returns &lt;code&gt;42&lt;/code&gt; as an integer; another returns &lt;code&gt;'42'&lt;/code&gt; as text, or &lt;code&gt;42.00&lt;/code&gt; as a numeric type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floating-Point Rounding:&lt;/strong&gt; Financial or retention aggregations can have microscopic rounding variances across WASM platforms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NULL Semantics:&lt;/strong&gt; In SQL, &lt;code&gt;NULL = NULL&lt;/code&gt; evaluates to unknown, not true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row Ordering:&lt;/strong&gt; Unless an explicit &lt;code&gt;ORDER BY&lt;/code&gt; is specified in the problem statement, relational sets have no guaranteed order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We built a custom normalization and diffing layer (&lt;code&gt;compareResults.ts&lt;/code&gt;) with strict unit test coverage that reconciles column types, evaluates unordered row hashes when order is arbitrary, and checks exact sorting rules when the problem demands strict rank order.&lt;/p&gt;




&lt;h2&gt;
  
  
  Breaking Down Real FAANG Questions: The Playbooks
&lt;/h2&gt;

&lt;p&gt;Beyond the sandbox, what engineers actually need is strategic intuition. &lt;/p&gt;

&lt;p&gt;Most platforms just post the problem and a short solution. But when candidates freeze in an interview, it's almost never because they forgot the syntax of &lt;code&gt;JOIN&lt;/code&gt;. It's because they couldn't decompose the problem statement into relational steps.&lt;/p&gt;

&lt;p&gt;We built dedicated &lt;strong&gt;Company Playbooks&lt;/strong&gt; for companies like Amazon, Google, and Meta:&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="c1"&gt;-- Example: Meta Interview Question&lt;/span&gt;
&lt;span class="c1"&gt;-- Finding monthly retention with window functions&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;monthly_activity&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream_date&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;active_month&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;streams&lt;/span&gt; 
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&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;SELECT&lt;/span&gt; 
  &lt;span class="n"&gt;active_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;DENSE_RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&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;DESC&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;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;monthly_activity&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;active_month&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each problem breakdown includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactive Multi-Table Schemas:&lt;/strong&gt; Candidates can toggle between multiple input tables, explore edge-case data fixtures, and see the expected output matrix before typing code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relational Logic Walkthroughs:&lt;/strong&gt; Step-by-step breakdowns showing &lt;em&gt;why&lt;/em&gt; a CTE or self-join was chosen over a subquery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immediate Sandbox Handoff:&lt;/strong&gt; One click opens the problem in the in-browser sandbox with the exact schema pre-loaded.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Takeaways from Shipping WebAssembly to Production
&lt;/h2&gt;

&lt;p&gt;If you are considering compiling heavy C/C++ runtimes into WebAssembly for developer tools:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The browser is an operating system now.&lt;/strong&gt; Don't default to server-side workers for code execution if a client-side WASM runtime exists. The latency win changes the product feel completely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start matters.&lt;/strong&gt; We pre-warm the PGlite instance as soon as the candidate navigates toward a problem. By the time they finish reading the task statement, the engine is warm and responsive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it accessible.&lt;/strong&gt; You don't need an account, a credit card, or a Docker daemon running to practice. You just open the page and write SQL.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Check It Out
&lt;/h2&gt;

&lt;p&gt;If you have an upcoming SQL interview—or just want to test how fast PostgreSQL runs inside your browser tab—try it out:&lt;/p&gt;

&lt;p&gt;🔗 &lt;strong&gt;&lt;a href="https://datacurlew.com" rel="noopener noreferrer"&gt;DataCurlew (datacurlew.com)&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
💻 &lt;strong&gt;&lt;a href="https://datacurlew.com/learn" rel="noopener noreferrer"&gt;Free SQL Learning Path&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
🏢 &lt;strong&gt;&lt;a href="https://datacurlew.com/company/Meta" rel="noopener noreferrer"&gt;Meta SQL Playbook&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I would love to hear feedback from other engineers: what SQL edge cases do you find hardest in technical interviews, and what features would make your practice workflow smoother?&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>webassembly</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Data Modeling Concepts Nobody Mentions After Star Schema 101</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:22:38 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/the-data-modeling-concepts-nobody-mentions-after-star-schema-101-587l</link>
      <guid>https://dev.to/rahmanfrr/the-data-modeling-concepts-nobody-mentions-after-star-schema-101-587l</guid>
      <description>&lt;p&gt;Most data modeling tutorials stop at the same place: here's a fact table, here's a dimension table, join them, done. And that's genuinely enough to get a warehouse working. It's also enough to make you think you're finished learning.&lt;/p&gt;

&lt;p&gt;Then two things happen in the same month. A sales deal gets credit split between three reps instead of one, and your fact table — built for one rep per deal — has no clean way to hold that. And finance asks for "account balance on the last day of every month," and you realize your fact table only stores balance-change events, not balances. Neither of these is a bug. They're just the next layer of modeling that nobody mentioned in the "intro to star schemas" post.&lt;/p&gt;

&lt;p&gt;Here's that next layer, in plain words, with a running example: an online furniture store.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a dimension needs its own dimensions: snowflake schema
&lt;/h2&gt;

&lt;p&gt;In a basic star schema, &lt;code&gt;dim_product&lt;/code&gt; might hold everything about a product in one flat row — including its category name and department name, repeated for every single product in that category.&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="c1"&gt;-- dim_product (star schema — flat, some repetition)&lt;/span&gt;
&lt;span class="n"&gt;product_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;product_name&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;category_name&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;department_name&lt;/span&gt;
&lt;span class="mi"&gt;201&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Oak&lt;/span&gt; &lt;span class="n"&gt;Desk&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Desks&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Furniture&lt;/span&gt;
&lt;span class="mi"&gt;202&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Pine&lt;/span&gt; &lt;span class="n"&gt;Desk&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Desks&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Furniture&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If "Desks" ever gets renamed to "Work Desks," you're updating that string in every row that mentions it. A snowflake schema splits the dimension further, so category and department live in their own tables:&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="c1"&gt;-- dim_product (snowflaked)&lt;/span&gt;
&lt;span class="n"&gt;product_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;product_name&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;category_key&lt;/span&gt;
&lt;span class="mi"&gt;201&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Oak&lt;/span&gt; &lt;span class="n"&gt;Desk&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;

&lt;span class="c1"&gt;-- dim_category&lt;/span&gt;
&lt;span class="n"&gt;category_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;category_name&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;department_key&lt;/span&gt;
&lt;span class="mi"&gt;55&lt;/span&gt;           &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Desks&lt;/span&gt;          &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a rename touches one row. The cost is that a query which used to need one join now needs two or three. Neither version is "correct" — a snowflake schema wins when a dimension's attributes actually change independently and often; a flat star schema wins when the query speed matters more than that theoretical tidiness. Most teams default to star and only snowflake the one or two dimensions that genuinely need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every fact table looks the same
&lt;/h2&gt;

&lt;p&gt;The sales-and-refunds fact table from a basic tutorial — one row per event — is called a &lt;strong&gt;transaction fact table&lt;/strong&gt;. It's the most common type, but it's not the only shape a fact table can take.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;periodic snapshot fact table&lt;/strong&gt; captures a measurement at a regular interval, whether or not anything happened. Think of a warehouse's inventory count, taken every night at midnight:&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="c1"&gt;-- fact_inventory_daily&lt;/span&gt;
&lt;span class="n"&gt;snapshot_date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;product_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;warehouse_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;units_on_hand&lt;/span&gt;
&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;             &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;             &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;39&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody "did" anything to generate the September 15th row — it's just a photograph of the state of things that day. This is the shape you want for "what was our balance on the last day of the month," because a transaction table only tells you about changes, not states.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;accumulating snapshot fact table&lt;/strong&gt; is for tracking something with a defined start and end, where you update the same row as it moves through stages — an order going from placed to packed to shipped to delivered:&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="c1"&gt;-- fact_order_fulfillment&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;placed_date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;packed_date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;shipped_date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;delivered_date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;days_to_deliver&lt;/span&gt;
&lt;span class="mi"&gt;5001&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;05&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="mi"&gt;5002&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;03&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;09&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;03&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;            &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of one row per status change, there's one row per order, and columns get filled in as milestones happen. This is the natural shape for pipeline and fulfillment reporting — you can instantly see how many orders are stuck between "packed" and "shipped" without scanning an event log.&lt;/p&gt;

&lt;h2&gt;
  
  
  When there's nothing to measure: factless fact tables
&lt;/h2&gt;

&lt;p&gt;A fact table doesn't have to have a number in it. Say you want to track which students attended which class sessions. There's no "amount," no "quantity" — just the fact that an event happened.&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="c1"&gt;-- fact_attendance&lt;/span&gt;
&lt;span class="n"&gt;student_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;class_session_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;date_key&lt;/span&gt;
&lt;span class="mi"&gt;77&lt;/span&gt;          &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;1204&lt;/span&gt;               &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;20250915&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get your measure from &lt;code&gt;COUNT(*)&lt;/code&gt;, not from a column. This shows up more than people expect — eligibility events, promotions applied, security check-ins. If you catch yourself adding a fake &lt;code&gt;count = 1&lt;/code&gt; column just so the table "feels like" a proper fact table, that's usually a sign it's a factless fact table and that's fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Too many tiny dimensions: junk dimensions
&lt;/h2&gt;

&lt;p&gt;Say your orders have a handful of small yes/no or short-code attributes: is it gift-wrapped, what payment method was used, was it a rush order. Giving each one its own dimension table is technically correct and practically annoying — five tiny tables, five extra keys cluttering the fact table.&lt;/p&gt;

&lt;p&gt;A junk dimension bundles them into one table, with one row per real combination that shows up in your data:&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="c1"&gt;-- dim_order_attributes (junk dimension)&lt;/span&gt;
&lt;span class="n"&gt;attributes_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;is_gift_wrapped&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;payment_method&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;is_rush_order&lt;/span&gt;
&lt;span class="mi"&gt;1&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;             &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;credit_card&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="mi"&gt;2&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;            &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;paypal&lt;/span&gt;         &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="mi"&gt;3&lt;/span&gt;              &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;             &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;credit_card&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fact table holds one foreign key instead of three or four. It's not elegant in the textbook sense — it's a deliberate tradeoff of purity for a cleaner schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata that doesn't need a dimension: degenerate dimensions
&lt;/h2&gt;

&lt;p&gt;Every order has an order number. It's not really "context" the way a customer or product is — it doesn't have its own attributes, it's just an identifier from the source system. Rather than build a &lt;code&gt;dim_order&lt;/code&gt; table with one column in it, you store the order number directly in the fact 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="c1"&gt;-- fact_sales&lt;/span&gt;
&lt;span class="n"&gt;order_line_id&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;order_number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;date_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;customer_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="mi"&gt;9001&lt;/span&gt;          &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ORD&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;58291&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;20250915&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;           &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;39&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;98&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a degenerate dimension — a dimension-like attribute that lives in the fact table because building a separate table for it would be pure overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  When one fact needs many dimension values: bridge tables
&lt;/h2&gt;

&lt;p&gt;Here's the many-to-many problem from the start of this post. A deal in your CRM can have three sales reps sharing credit for it. A fact table row expects one key per dimension, so &lt;code&gt;fact_deals&lt;/code&gt; can't just hold three &lt;code&gt;rep_key&lt;/code&gt; columns without breaking the model the moment a fourth rep gets added.&lt;/p&gt;

&lt;p&gt;A bridge table sits between the fact and the dimension, and carries a weighting factor so the numbers don't double- or triple-count:&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="c1"&gt;-- bridge_deal_reps&lt;/span&gt;
&lt;span class="n"&gt;deal_id&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;rep_key&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;credit_weight&lt;/span&gt;
&lt;span class="mi"&gt;5001&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="mi"&gt;5001&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="mi"&gt;5001&lt;/span&gt;    &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply revenue by &lt;code&gt;credit_weight&lt;/code&gt; when reporting by rep, and the totals still add up correctly across the team even though three people are attached to one deal. This same pattern handles patients with multiple diagnoses, students with multiple majors — anywhere the real world refuses to be one-to-one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping fact tables speaking the same language: conformed dimensions
&lt;/h2&gt;

&lt;p&gt;Once you have more than one fact table — &lt;code&gt;fact_sales&lt;/code&gt; and &lt;code&gt;fact_support_tickets&lt;/code&gt;, say — someone will eventually ask "which customers bought furniture and also filed a support ticket last quarter?" That question only works cleanly if both fact tables point to the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;dim_customer&lt;/code&gt; and &lt;code&gt;dim_date&lt;/code&gt; tables, with the same keys and the same definitions.&lt;/p&gt;

&lt;p&gt;That shared table is a conformed dimension. It's less a technique and more a rule: build &lt;code&gt;dim_date&lt;/code&gt; once, build &lt;code&gt;dim_customer&lt;/code&gt; once, and make every fact table in the warehouse reference those same tables instead of each team quietly building their own slightly-different version. Skip this, and you're back to the finance-vs-marketing mismatch problem, just one layer deeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  One step further: Data Vault, briefly
&lt;/h2&gt;

&lt;p&gt;If you ever land somewhere with heavy audit or compliance requirements — banking, insurance, healthcare — you might run into Data Vault modeling instead of star schemas. It splits everything into three table types: &lt;strong&gt;hubs&lt;/strong&gt; (just the business keys, like customer IDs), &lt;strong&gt;links&lt;/strong&gt; (relationships between hubs), and &lt;strong&gt;satellites&lt;/strong&gt; (the descriptive attributes, each with a timestamp). It's more rigid and more verbose than a star schema, but it makes "what did we know, and when did we know it" fully traceable. You probably won't build one as an early-career engineer, but knowing the name and the shape means you won't be lost the first time you see one in a legacy system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson here
&lt;/h2&gt;

&lt;p&gt;None of these are things to use everywhere, all the time. A junk dimension on a table with one boolean column is overkill. A bridge table where the relationship really is one-to-one is overkill in the other direction. The skill isn't memorizing all seven patterns — it's recognizing which real-world shape you're looking at (a snapshot, a many-to-many, an event with no number attached) and reaching for the model built for that shape instead of forcing everything into the one you learned first.&lt;/p&gt;

&lt;p&gt;Which one of these have you had to reach for on the job, and what broke that made you go looking for it?&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>datamodeling</category>
      <category>sql</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Solving the Classic SQL "Gaps and Islands" Problem: 3 Modern Approaches</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Tue, 15 Sep 2026 03:15:39 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/solving-the-classic-sql-gaps-and-islands-problem-3-modern-approaches-gn</link>
      <guid>https://dev.to/rahmanfrr/solving-the-classic-sql-gaps-and-islands-problem-3-modern-approaches-gn</guid>
      <description>&lt;p&gt;If you've ever needed to find consecutive streaks in data — days a user logged in back to back, uninterrupted stretches of sensor readings, runs of matching status codes — you've run into the "gaps and islands" problem. The "islands" are the consecutive runs. The "gaps" are the breaks between them. SQL doesn't have a built-in &lt;code&gt;FIND_STREAKS()&lt;/code&gt; function, so you build it with window functions instead.&lt;/p&gt;

&lt;p&gt;Here's the sample data we'll use throughout — a table of user login dates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;login_date&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;2026-01-01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-01-02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-01-03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-01-05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-01-06&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;User 1 logged in three days straight, skipped the 4th, then logged in two more days. That's two islands — a 3-day streak and a 2-day streak — separated by one gap. Every approach below should output exactly that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;streak_start&lt;/th&gt;
&lt;th&gt;streak_end&lt;/th&gt;
&lt;th&gt;streak_length&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;2026-01-01&lt;/td&gt;
&lt;td&gt;2026-01-03&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-01-05&lt;/td&gt;
&lt;td&gt;2026-01-06&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Quick refresher: the window functions doing the work
&lt;/h2&gt;

&lt;p&gt;Before the approaches, three building blocks, briefly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)&lt;/code&gt;&lt;/strong&gt; — numbers rows 1, 2, 3... within each group, in the order you specify. Doesn't skip numbers, doesn't care about ties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LAG(col) OVER (...)&lt;/code&gt;&lt;/strong&gt; — looks at the &lt;em&gt;previous&lt;/em&gt; row's value for that column, for the current row. &lt;code&gt;LEAD()&lt;/code&gt; is the same idea but looks &lt;em&gt;forward&lt;/em&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SUM(col) OVER (PARTITION BY ... ORDER BY ...)&lt;/code&gt;&lt;/strong&gt; — a running total, recalculated row by row, instead of collapsing everything into one number the way a normal &lt;code&gt;SUM()&lt;/code&gt; in a &lt;code&gt;GROUP BY&lt;/code&gt; would.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three approaches below are really just different combinations of these three ideas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: The row-number trick
&lt;/h2&gt;

&lt;p&gt;This is the classic, and the cleverest one to understand. Here's the logic: if a run of dates has zero gaps, then subtracting a simple counter (1, 2, 3...) from each date should always land on the &lt;em&gt;same&lt;/em&gt; result — because both the dates and the counter are increasing by exactly one each row. The moment there's a gap, that arithmetic breaks, and the result shifts to a new value. That shift is exactly what makes it usable as a group ID — rows in the same island always land on the same computed value, and rows in a different island always land on a different one.&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;WITH&lt;/span&gt; &lt;span class="n"&gt;numbered&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;login_date&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;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;login_days&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_date&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;streak_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_date&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;streak_end&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;streak_length&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;numbered&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rn&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&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;streak_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walking through it: &lt;code&gt;rn&lt;/code&gt; gives each row a position (1, 2, 3, 4, 5). Subtracting &lt;code&gt;rn&lt;/code&gt; days from &lt;code&gt;login_date&lt;/code&gt; gives Jan 1, Jan 1, Jan 1, Jan 2, Jan 2 — the first three rows collapse to the same value because there's no gap between them, and the last two collapse to a different shared value because the gap on Jan 4 threw the counter out of sync. Group by that computed value, and each group is one island.&lt;/p&gt;

&lt;p&gt;It's elegant once it clicks, but it only works cleanly for evenly-spaced values like whole days or plain integers. Try it on timestamps or irregular intervals and the subtraction stops producing clean matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: Flag-then-sum
&lt;/h2&gt;

&lt;p&gt;More flexible, and easier to explain to a teammate six months from now, because every step is visible instead of relying on one clever subtraction. First, compare each row to the one before it using &lt;code&gt;LAG()&lt;/code&gt;, and flag it with a &lt;code&gt;1&lt;/code&gt; whenever there's a gap — meaning this row starts a brand new island. Then run a cumulative &lt;code&gt;SUM()&lt;/code&gt; over that flag. Since the flag only increases when a new island starts, that running total &lt;em&gt;is&lt;/em&gt; the island number — it stays flat across a streak and steps up by one every time a new streak begins.&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;WITH&lt;/span&gt; &lt;span class="n"&gt;flagged&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt;
      &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;is_new_streak&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;login_days&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_date&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;streak_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_date&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;streak_end&lt;/span&gt;&lt;span class="p"&gt;,&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;AS&lt;/span&gt; &lt;span class="n"&gt;streak_length&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&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;is_new_streak&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;login_date&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;streak_id&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;flagged&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;grouped&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;streak_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&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;streak_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;is_new_streak&lt;/code&gt; flag is a single &lt;code&gt;CASE&lt;/code&gt; expression you fully control. Right now it checks "is the gap more than 1 day," but you could just as easily change the condition to "is the gap more than 1 hour," or "did the status code change," and the rest of the query wouldn't need to change at all. That's what makes this the one worth reaching for by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: Match the boundaries directly
&lt;/h2&gt;

&lt;p&gt;No grouping, no aggregation step — just find every row where a streak &lt;em&gt;starts&lt;/em&gt; (the day before it is missing from the data) and every row where a streak &lt;em&gt;ends&lt;/em&gt; (the day after it is missing), then line up the Nth start with the Nth end.&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;WITH&lt;/span&gt; &lt;span class="n"&gt;boundaries&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;is_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;LEAD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;is_end&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;login_days&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;starts&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;streak_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;login_date&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;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;boundaries&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;ends&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;login_date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;streak_end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;login_date&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;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;boundaries&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;streak_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;streak_end&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;starts&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&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;e&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;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rn&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;streak_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;is_start&lt;/code&gt; checks whether the previous row is one day back — if not (or if there is no previous row), this row is the beginning of an island. &lt;code&gt;is_end&lt;/code&gt; does the mirror check looking forward. Filtering down to just the start rows and just the end rows gives two short lists — the first start pairs with the first end, the second with the second, and so on, because islands can't overlap or interleave.&lt;/p&gt;

&lt;p&gt;This skips the group-by step entirely, which can matter on very large tables where aggregation is the expensive part of the query plan. The tradeoff is it takes a moment longer to read for anyone seeing it for the first time — there's more setup before the payoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to reach for
&lt;/h2&gt;

&lt;p&gt;Approach 2 is the safest default — readable, flexible, handles unusual gap definitions without a rewrite, and reads almost like plain English once you know what &lt;code&gt;LAG&lt;/code&gt; and the running &lt;code&gt;SUM()&lt;/code&gt; are doing. Reach for Approach 1 when your data is simple integers or daily dates and you want the shortest possible query, and you're confident nobody will need to change the gap logic later. Reach for Approach 3 when you're working at a scale where skipping the aggregation step actually shows up in your query time — profile first, don't guess.&lt;/p&gt;

&lt;p&gt;All three lean on the same two window-function ideas: comparing a row to its neighbor, and turning that comparison into a group. Once that clicks, gaps and islands stops being a "classic hard problem" and starts being a five-minute pattern you reach for on autopilot.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Nobody Warns You the Data Engineering Interview Isn't About Data Engineering</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Mon, 14 Sep 2026 03:59:31 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/nobody-warns-you-the-data-engineering-interview-isnt-about-data-engineering-16md</link>
      <guid>https://dev.to/rahmanfrr/nobody-warns-you-the-data-engineering-interview-isnt-about-data-engineering-16md</guid>
      <description>&lt;p&gt;I've sat on both sides of the interview table more times than I can count. Here's the thing nobody tells you going in: the interview almost never tests what it claims to.&lt;/p&gt;

&lt;p&gt;You think it's testing SQL. It's testing whether you panic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whiteboard SQL problem
&lt;/h2&gt;

&lt;p&gt;Somewhere along the way, "write a window function while a stranger watches" became a proxy for "will you be good at this job." It isn't. I've worked with people who nailed a &lt;code&gt;ROW_NUMBER() OVER (PARTITION BY...)&lt;/code&gt; from memory, then shipped a pipeline that silently dropped 4% of records because nobody asked what "duplicate" meant to the business. I've also watched someone fumble join syntax live, apologize twice, and turn out to be exactly who you want paged at 2am when a DAG falls over.&lt;/p&gt;

&lt;p&gt;The instinct that predicts job performance isn't recall. It's asking &lt;em&gt;"what happens when this data is wrong, and who notices first?"&lt;/em&gt; Almost nobody tests for that directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually separates candidates
&lt;/h2&gt;

&lt;p&gt;Two things, from what I've seen over and over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether they narrate their reasoning instead of going silent and typing&lt;/li&gt;
&lt;li&gt;Whether they ask a clarifying question before writing a line of code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's most of the signal. Not cleverness — just, do they think like someone maintaining this in six months, or someone racing to produce an answer?&lt;/p&gt;

&lt;p&gt;I once watched a candidate pick a "wrong" schema — normalized where I'd have gone semi-denormalized — but she named the exact tradeoff and the metric she'd watch to know if she'd guessed wrong. We hired her. The "correct" answer was never really the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I contradict myself
&lt;/h2&gt;

&lt;p&gt;And yet I still ask SQL questions. Still make people code live, which everyone hates, me included. Because there's a floor you can't skip — if someone can't reason about a &lt;code&gt;GROUP BY&lt;/code&gt; at all, no amount of narrated thinking saves that. The theater is annoying. It just isn't &lt;em&gt;pure&lt;/em&gt; noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're early career
&lt;/h2&gt;

&lt;p&gt;Stop sounding like you already know everything. Experienced interviewers can smell a memorized answer — the explanation stops adapting the moment someone pushes on it.&lt;/p&gt;

&lt;p&gt;Instead: say "let me think out loud" and actually do it. Ask about data volume before picking an approach. Admit a design's weakness instead of defending it like it's your firstborn. None of that takes more study hours. It takes being a little less afraid of looking uncertain for forty-five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I keep coming back to
&lt;/h2&gt;

&lt;p&gt;The interview is a lossy proxy. Someone great freezes and gets filtered out; someone mediocre gets through on a rehearsed question. That's not a scandal, it's just what happens compressing "will you be good at this for two years" into forty-five minutes and a shared screen.&lt;/p&gt;

&lt;p&gt;So prep less syntax you'll forget by Thursday. Prep the muscle of thinking out loud when you don't know the answer yet. That's the actual job.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>interview</category>
      <category>career</category>
      <category>sql</category>
    </item>
    <item>
      <title>Recursive CTEs: How SQL Secretly Learned to Loop</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Sun, 13 Sep 2026 02:06:19 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/recursive-ctes-how-sql-secretly-learned-to-loop-457f</link>
      <guid>https://dev.to/rahmanfrr/recursive-ctes-how-sql-secretly-learned-to-loop-457f</guid>
      <description>&lt;p&gt;Ask a SQL query to find "all employees under this manager," and things get ugly fast if you don't know how many levels deep the org chart goes. A regular join handles one level. Two joins handle two levels. Nobody's writing seven joins for seven levels of middle management.&lt;/p&gt;

&lt;p&gt;This is exactly the problem recursive CTEs exist to solve — and most people who've written SQL for years have never touched one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: data that references itself
&lt;/h2&gt;

&lt;p&gt;Picture a plain &lt;code&gt;employees&lt;/code&gt; table where each row points to its own manager:&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;employees&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;INT&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="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;manager_id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;employees&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple structure. The pain shows up the moment you ask "give me this manager and everyone below them, however many layers down." A &lt;code&gt;JOIN&lt;/code&gt; can walk exactly one level of that relationship. It has no concept of "keep going until there's nothing left."&lt;/p&gt;

&lt;h2&gt;
  
  
  The recursive CTE
&lt;/h2&gt;

&lt;p&gt;A recursive CTE is a query that references itself, built from two parts glued together with &lt;code&gt;UNION ALL&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;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;org_chart&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="c1"&gt;-- Anchor member: where the recursion starts&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;  &lt;span class="c1"&gt;-- the manager we're starting from&lt;/span&gt;

  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

  &lt;span class="c1"&gt;-- Recursive member: joins back to the CTE itself&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;org_chart&lt;/span&gt; &lt;span class="n"&gt;oc&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oc&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;org_chart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what's actually happening:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;anchor member&lt;/strong&gt; runs once and produces the starting row(s) — in this case, the one manager we care about.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;recursive member&lt;/strong&gt; joins the real table back to the CTE's &lt;em&gt;own results so far&lt;/em&gt;, pulling in the next level down.&lt;/li&gt;
&lt;li&gt;The database repeats step 2 — feeding each round's output back in as input — until a round produces zero new rows, then stops.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That &lt;code&gt;depth&lt;/code&gt; column isn't required, but it's worth keeping. It turns "an unordered pile of employees" into something you can actually order and indent to look like a real org chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second use case: category trees
&lt;/h2&gt;

&lt;p&gt;Org charts are the classic example, but this pattern shows up anywhere data nests: product categories, comment threads, folder structures, bill-of-materials breakdowns. Same shape, same fix:&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;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;category_tree&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;

  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' &amp;gt; '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;category_tree&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ct&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;category_tree&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;path&lt;/code&gt; column builds a breadcrumb trail (&lt;code&gt;Electronics &amp;gt; Laptops &amp;gt; Gaming&lt;/code&gt;) for free, just by concatenating as it recurses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this bites people
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;UNION ALL&lt;/code&gt;, not &lt;code&gt;UNION&lt;/code&gt;.&lt;/strong&gt; Swap in plain &lt;code&gt;UNION&lt;/code&gt; and the database now has to deduplicate every intermediate round against every other round, which is expensive and usually unnecessary — you already know these are distinct rows by construction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cyclic data causes infinite loops.&lt;/strong&gt; If your hierarchy has a cycle (employee A manages B, B somehow manages A — yes, this happens with bad data), a naive recursive CTE will spin forever or until it hits an engine-specific safety limit. Guard against it by tracking visited IDs in an array and checking before each recursive step, or add a hard depth cap:&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Engine support varies.&lt;/strong&gt; PostgreSQL, SQL Server, and MySQL 8.0+ all support &lt;code&gt;WITH RECURSIVE&lt;/code&gt;. Older MySQL versions (pre-8.0) don't support it at all — you'd be stuck with application-level recursion or a different table design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's not free at scale.&lt;/strong&gt; For very deep or very wide hierarchies queried constantly, a recursive CTE recomputes the walk every single time. If read performance matters more than write simplicity, patterns like closure tables or materialized paths trade some insert/update complexity for much faster reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Most SQL work never needs this. But the moment you're modeling anything that nests — org charts, categories, threads, parts lists — a recursive CTE replaces what would otherwise be a loop in application code, a stack of unknown-depth joins, or a recursive function call. It's one of the few places SQL quietly does something genuinely clever.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Window Functions Are the SQL Superpower Nobody Taught You</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Sat, 12 Sep 2026 05:30:45 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/window-functions-are-the-sql-superpower-nobody-taught-you-47nn</link>
      <guid>https://dev.to/rahmanfrr/window-functions-are-the-sql-superpower-nobody-taught-you-47nn</guid>
      <description>&lt;p&gt;For a long time, I avoided window functions. They looked complicated. &lt;code&gt;PARTITION BY&lt;/code&gt;, &lt;code&gt;OVER()&lt;/code&gt;, weird syntax that didn't look like the rest of SQL. So I did what a lot of people do — solved the same problems with clunky self-joins and subqueries, and just accepted that some queries were going to be long and slow.&lt;/p&gt;

&lt;p&gt;Then I actually learned window functions, and it felt like discovering a whole feature had been sitting in the toolbox the entire time, unused.&lt;/p&gt;


&lt;div class="crayons-card c-embed"&gt;

  &lt;br&gt;
&lt;strong&gt;The short version:&lt;/strong&gt; &lt;code&gt;OVER()&lt;/code&gt; lets you calculate across a group of rows without collapsing them into one. Same rows in, same rows out — just smarter.&lt;br&gt;

&lt;/div&gt;


&lt;p&gt;Here's what they do, why they matter, and a few uses that come up constantly in real work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea, in plain words
&lt;/h2&gt;

&lt;p&gt;A normal &lt;code&gt;GROUP BY&lt;/code&gt; collapses rows. Ten rows go in, one summary row comes out per group. That's useful, but sometimes you want the opposite — keep every row, and just attach some extra calculated info to each one, pulled from the other rows around it.&lt;/p&gt;

&lt;p&gt;That's a window function. Same number of rows in, same number out. Nothing collapses.&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;employee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;department&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;dept_avg&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every employee keeps their own row. But now each row also shows the average salary for their department, calculated across the whole group. No &lt;code&gt;GROUP BY&lt;/code&gt; needed, no subquery, no join.&lt;/p&gt;


&lt;h2&gt;
  
  
  Running totals without the pain
&lt;/h2&gt;

&lt;p&gt;Want a running total of sales, day by day? Without window functions, this usually turns into a self-join or a correlated subquery. Slow. Annoying to read six months later when you have to touch it again.&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;sale_date&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;SUM&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="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sale_date&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;running_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;ORDER BY&lt;/code&gt; inside the &lt;code&gt;OVER()&lt;/code&gt; tells the database to add up everything from the start of the data through the current row. One line. No subquery.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sale_date&lt;/th&gt;
&lt;th&gt;amount&lt;/th&gt;
&lt;th&gt;running_total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jan 1&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jan 2&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jan 3&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;h2&gt;
  
  
  Top N per group, the way it should be done
&lt;/h2&gt;

&lt;p&gt;This is the classic problem: find the top 3 highest-paid employees in each department. Doing this without window functions usually means some ugly combination of subqueries and &lt;code&gt;LIMIT&lt;/code&gt;, applied per group, which SQL doesn't naturally support.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;RANK()&lt;/code&gt;, it's clean:&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="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;employee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;department&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;salary&lt;/span&gt; &lt;span class="k"&gt;DESC&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;rank&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;PARTITION BY&lt;/code&gt; splits the data into departments. &lt;code&gt;ORDER BY salary DESC&lt;/code&gt; ranks people within each department. Then you just filter down to &lt;code&gt;rank &amp;lt;= 3&lt;/code&gt;. That's the whole trick.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Cheat sheet: which ranking function do you actually want?
  &lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ROW_NUMBER()&lt;/code&gt; — always gives unique numbers, even for ties (1, 2, 3, 4…)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RANK()&lt;/code&gt; — ties get the same number, but leaves a gap after (1, 1, 3, 4…)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DENSE_RANK()&lt;/code&gt; — ties get the same number, no gap (1, 1, 2, 3…)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick based on whether you care about the gap. Most of the time, &lt;code&gt;DENSE_RANK()&lt;/code&gt; is what people actually want and &lt;code&gt;RANK()&lt;/code&gt; is what they reach for out of habit.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;

&lt;p&gt;I rewrote a reporting query at a past job that used two nested subqueries and a &lt;code&gt;LIMIT&lt;/code&gt; inside a correlated join just to pull the top 5 customers per region. Forty-some lines. The &lt;code&gt;RANK()&lt;/code&gt; version above did the same thing in nine.&lt;/p&gt;


&lt;h2&gt;
  
  
  Comparing a row to the one before it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LAG()&lt;/code&gt; grabs a value from a previous row. &lt;code&gt;LEAD()&lt;/code&gt; grabs one from a row ahead. Trends, gaps, row-to-row comparisons — this is the pair you reach for.&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_date&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;days_since_last_order&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This tells you how many days passed between each user's orders — no self-join required. Swap &lt;code&gt;LAG&lt;/code&gt; for &lt;code&gt;LEAD&lt;/code&gt; and you get the days &lt;em&gt;until&lt;/em&gt; the next order instead. Same idea, opposite direction.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mistake almost everyone makes at first
&lt;/h2&gt;

&lt;p&gt;Forgetting &lt;code&gt;ORDER BY&lt;/code&gt; inside &lt;code&gt;OVER()&lt;/code&gt; when you actually need it. Without it, functions like &lt;code&gt;SUM()&lt;/code&gt; add up the &lt;em&gt;entire&lt;/em&gt; partition for every row, not a running total.&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="c1"&gt;-- wrong: every row shows the department's full total, not a running total&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- right: running total within each department, row by row&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;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;department&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sale_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;Same function, one missing clause, completely different result. This one gets people every time.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  Why this is worth learning properly
&lt;/h2&gt;

&lt;p&gt;Window functions replace a whole category of clunky, slow queries with something readable and fast. Running totals, rankings, row-to-row comparisons, moving averages — all of it gets simpler once &lt;code&gt;OVER()&lt;/code&gt; stops looking scary. It's one of those SQL features that feels intimidating for about twenty minutes, and then becomes something you reach for every single week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt;&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://www.postgresql.org/docs/current/tutorial-window.html" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.postgresql.org%2Fmedia%2Fimg%2Fabout%2Fpress%2Felephant.png" height="557" class="m-0" width="540"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.postgresql.org/docs/current/tutorial-window.html" rel="noopener noreferrer" class="c-link"&gt;
            PostgreSQL: Documentation: 18: 3.5.&amp;nbsp;Window Functions
          &lt;/a&gt;
        &lt;/h2&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.postgresql.org%2Ffavicon.ico" width="48" height="48"&gt;
          postgresql.org
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>sql</category>
      <category>dataengineering</category>
      <category>database</category>
      <category>programming</category>
    </item>
    <item>
      <title>The 5-Minute Trick That Landed Me 3 Job Offers</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Fri, 11 Sep 2026 04:12:01 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/the-5-minute-trick-that-landed-me-3-job-offers-jff</link>
      <guid>https://dev.to/rahmanfrr/the-5-minute-trick-that-landed-me-3-job-offers-jff</guid>
      <description>&lt;p&gt;I used to prep for interviews the normal way. Read the job post twice. Practice "tell me about yourself" in the mirror. Show up hoping for the best.&lt;/p&gt;

&lt;p&gt;Three offers later, I do one thing differently. Five minutes, right before I log in or walk through the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick
&lt;/h2&gt;

&lt;p&gt;I write down one sentence about the company's biggest problem right now. Not their mission. Not their "values." Their actual problem.&lt;/p&gt;

&lt;p&gt;How do I find it? Quick scan. Recent news, a Glassdoor review or two, sometimes just the job post itself. If a company's hiring four people for one role, something's broken or something's growing fast. Either way, that's the problem.&lt;/p&gt;

&lt;p&gt;I write the sentence by hand. Something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"They're scaling support faster than they can train people, and it's probably showing up as slow response times."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Takes two minutes, maybe three.&lt;/p&gt;

&lt;p&gt;Then I turn it into one question. That's the other two minutes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I noticed you're growing the support team fast — is that mostly about response time, or are you prepping for a bigger launch?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;p&gt;I ask that question in almost every interview now. Every single time, the room shifts a little. The interviewer leans in. Sometimes they laugh and say "yeah, actually, funny you ask." Sometimes they go quiet for a second because nobody's asked them that all week.&lt;/p&gt;

&lt;p&gt;Here's the reason. Everyone else walks in ready to talk about themselves. I walk in already thinking about their problem. That's it. That's the whole trick.&lt;/p&gt;

&lt;p&gt;It's not a magic script. It's not a personality hack. It's five minutes of thinking about them instead of me, right before the pressure hits.&lt;/p&gt;

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

&lt;p&gt;Before your next interview:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write one sentence about their real problem.&lt;/li&gt;
&lt;li&gt;Turn it into one question.&lt;/li&gt;
&lt;li&gt;Watch what happens.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>career</category>
      <category>interview</category>
      <category>jobsearch</category>
      <category>productivity</category>
    </item>
    <item>
      <title>If You Understand These 5 AI Terms, You're Ahead of 90% of People</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Thu, 10 Sep 2026 05:22:50 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/if-you-understand-these-5-ai-terms-youre-ahead-of-90-of-people-53h2</link>
      <guid>https://dev.to/rahmanfrr/if-you-understand-these-5-ai-terms-youre-ahead-of-90-of-people-53h2</guid>
      <description>&lt;p&gt;A few months back I sat in a meeting where someone said, dead serious, "we should fine-tune the RAG." Nobody blinked. Everyone just nodded, the way you nod when a doctor says a Latin word and you'd rather die than ask what it means.&lt;/p&gt;

&lt;p&gt;Thing is, that sentence didn't mean anything. Fine-tuning and RAG aren't even the same species of tool. Nobody in the room knew that. Including, I'm pretty sure, the guy who said it.&lt;/p&gt;

&lt;p&gt;That's where we're at with AI right now. Everyone's using the words. Almost nobody knows what's underneath them. So here are five, told as stories instead of definitions, because that's honestly all these terms are once you scrape the jargon off. Read these and you'll know more than half the people who use them for a living.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Context Window — the whiteboard that erases itself
&lt;/h2&gt;

&lt;p&gt;You're explaining something to a friend on a whiteboard. You keep writing. The board's only so big, though — so when you hit the edge, the oldest stuff at the top starts getting wiped to make room for the new.&lt;/p&gt;

&lt;p&gt;That's a &lt;strong&gt;context window&lt;/strong&gt;. How much an AI can hold in its head at once. Not forever. Just for now, and only so much of it.&lt;/p&gt;

&lt;p&gt;It's why a chatbot feels razor-sharp for the first ten minutes of a conversation and then seems to forget something you told it three messages ago. It's not being lazy or dumb. The board filled up. The beginning got erased so the end had somewhere to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. RAG — the open-book exam
&lt;/h2&gt;

&lt;p&gt;Two students, same test. One memorized the whole textbook, cover to cover. The other memorized nothing — but she's allowed to bring the book in and flip to the right page.&lt;/p&gt;

&lt;p&gt;That second student is doing &lt;strong&gt;RAG&lt;/strong&gt; — Retrieval-Augmented Generation, if you want the full name, though honestly nobody says it out loud. Instead of trying to remember everything, the AI goes and grabs the actual document — a manual, your company's files, this morning's news — and reads the relevant part before it answers.&lt;/p&gt;

&lt;p&gt;Why this matters: it's the difference between a machine that guesses and one that checks. Closed-book AI would rather invent an answer than admit it doesn't know. Open-book AI can point at the page and say, here, this is where I got it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Fine-Tuning — the apprentice learning one craft
&lt;/h2&gt;

&lt;p&gt;Picture someone who already knows a bit of everything. A jack-of-all-trades. Now you need them to become a master violin maker — specifically violins, nothing else. You don't hand them a new book. You put a violin in their hands and have them build it, over and over, until the way they &lt;em&gt;work&lt;/em&gt; has actually changed.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;fine-tuning&lt;/strong&gt;. Not new facts. New habits. Tone, style, the specific way it handles one job, reshaped through repetition.&lt;/p&gt;

&lt;p&gt;People mix this up with the open-book exam constantly, and it costs real money when they do. Problem is "it doesn't know something"? Hand it the book. Problem is "it knows the facts but talks wrong for the job every single time"? That's a habit, not a knowledge gap — retrain it. Two different problems. Two different fixes. Mixing them up is expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Hallucination — the confident stranger giving directions
&lt;/h2&gt;

&lt;p&gt;You know this person. You ask for directions and they answer instantly — full confidence, pointing down the street, no hesitation — and they are completely, catastrophically wrong. They didn't lie. They just couldn't stand the silence of "I don't know," so their brain filled it with something that sounded plausible instead.&lt;/p&gt;

&lt;p&gt;That's a &lt;strong&gt;hallucination&lt;/strong&gt;. An AI stating something false with the exact same tone of voice as something true. Not a glitch. Not a typo. It has no built-in sense of doubt, so when the real answer isn't there, it manufactures one that fits.&lt;/p&gt;

&lt;p&gt;Carry this one with you: confidence is not proof. Ever. The wrong answer and the right answer come out of the machine sounding identically sure of themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Agent — the assistant who finally leaves the office
&lt;/h2&gt;

&lt;p&gt;Most AI you've touched is like a smart person stuck behind a glass window. Talks to you. Answers questions. Hands you a note through the slot. Can't actually get up and go do the thing.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;agent&lt;/strong&gt; walks out from behind the glass. It doesn't just answer — it acts, checks what happened, decides what's next, on its own, in a loop. Book the flight. Check the confirmation. Notice the date's wrong. Fix it. Try again. Nobody's holding its hand through each step.&lt;/p&gt;

&lt;p&gt;That loop is the whole appeal. It's also the scary part, and I don't think enough people say this out loud: a glass-window AI can only hand you a bad sentence. One that's out in the world can take a bad &lt;em&gt;action&lt;/em&gt; first — send the email, book the wrong day, delete the wrong file — and you find out after.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what
&lt;/h2&gt;

&lt;p&gt;None of this is hard. That's kind of the unsettling part, if I'm honest. The gap between &lt;em&gt;sounds like an expert&lt;/em&gt; and &lt;em&gt;actually is one&lt;/em&gt; turns out to be five plain stories wide.&lt;/p&gt;

&lt;p&gt;Next time someone in a meeting says a sentence that doesn't quite add up — you won't nod along. You'll know exactly which word they got wrong.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>beginners</category>
      <category>productivity</category>
      <category>career</category>
    </item>
    <item>
      <title>Why Learning Data Engineering Is Still a Smart Bet in 2026 (Even With AI Eating the Easy Parts)</title>
      <dc:creator>Rahman</dc:creator>
      <pubDate>Thu, 10 Sep 2026 04:05:46 +0000</pubDate>
      <link>https://dev.to/rahmanfrr/why-learning-data-engineering-is-still-a-smart-bet-in-2026-even-with-ai-eating-the-easy-parts-4poa</link>
      <guid>https://dev.to/rahmanfrr/why-learning-data-engineering-is-still-a-smart-bet-in-2026-even-with-ai-eating-the-easy-parts-4poa</guid>
      <description>&lt;p&gt;A friend messaged me last month, half-panicking: "Isn't AI going to write all the ETL pipelines soon? Why learn this now?" Fair question. Short answer: AI already ate the boilerplate. That's exactly why the people left standing are getting paid more, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  The market isn't shrinking. The entry point is
&lt;/h2&gt;

&lt;p&gt;Hiring for data engineering grew double digits year over year going into 2026, and the global market for data engineering services is sitting north of $100 billion, headed toward roughly double that by the early 2030s. That's infrastructure money, not a niche corner of tech.&lt;/p&gt;

&lt;p&gt;What actually changed is the junior on-ramp. Entry-level postings dropped sharply — AI now handles the staging SQL, the scaffolded Airflow DAGs, the repetitive schema mapping that used to be someone's first job.&lt;/p&gt;

&lt;p&gt;What didn't disappear: the person who can debug why a pipeline silently dropped 4% of records overnight, or decide whether a workload belongs in Snowflake or a lakehouse. AI raised the bar on data quality and governance. Someone still has to own that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pay reflects it
&lt;/h2&gt;

&lt;p&gt;Numbers vary by survey — normal for comp data — but the pattern holds. Mid-level engineers generally clear $120K to $150K base. Senior engineers regularly land north of $170K, with total comp at larger companies well past $200K. Even remote roles aren't cheap anymore; remote mid-level pay competes with plenty of on-site jobs outside the big hubs.&lt;/p&gt;

&lt;p&gt;I'm not saying chase the number. I'm saying the market's voting with its budget, and it's voting for this skill set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually worth learning
&lt;/h2&gt;

&lt;p&gt;Not everything under "data engineering" carries equal weight right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQL, deeply&lt;/strong&gt; — window functions, query plan reading, knowing why an index isn't getting used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming, not just batch&lt;/strong&gt; — Kafka or Flink is the biggest differentiator between candidates today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration and modeling&lt;/strong&gt; — Airflow plus dbt is close to a default stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One cloud platform, deeply&lt;/strong&gt; — pick AWS, GCP, or Azure and go past the surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what's missing: knowing every tool. Nobody's hiring the longest skills section. They're hiring whoever can explain a tradeoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're starting from zero
&lt;/h2&gt;

&lt;p&gt;Don't try to start &lt;em&gt;in&lt;/em&gt; data engineering. Start adjacent — backend dev, data analysis, DevOps — and transfer in once you've got something real to point to: a pipeline you built, a migration you owned. The field has quietly stopped being a realistic first job, and pretending otherwise just gets you filtered out.&lt;/p&gt;

&lt;h2&gt;
  
  
  My actual take
&lt;/h2&gt;

&lt;p&gt;The panic about AI replacing data engineers has it backwards. AI didn't remove the need for the skill — it removed the need for the &lt;em&gt;shallow version&lt;/em&gt; of it. What's left is demand for people who understand systems, not syntax. Harder bar. Exactly why it's worth clearing.&lt;/p&gt;

&lt;p&gt;The easy jobs are gone. The real ones pay better than they ever did.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>career</category>
      <category>sql</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
