<?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: Vivek Kumar</title>
    <description>The latest articles on DEV Community by Vivek Kumar (@vivekdraxlr).</description>
    <link>https://dev.to/vivekdraxlr</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%2F3870326%2F6ff71248-2d18-4571-9d3e-9cd0e728f3a2.png</url>
      <title>DEV Community: Vivek Kumar</title>
      <link>https://dev.to/vivekdraxlr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vivekdraxlr"/>
    <language>en</language>
    <item>
      <title>Funnel Analysis in SQL: Find Exactly Where Users Drop Off</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Fri, 14 Aug 2026 17:30:09 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/funnel-analysis-in-sql-find-exactly-where-users-drop-off-kp2</link>
      <guid>https://dev.to/vivekdraxlr/funnel-analysis-in-sql-find-exactly-where-users-drop-off-kp2</guid>
      <description>&lt;p&gt;Your product has a signup funnel. Someone visits the pricing page, starts a trial, invites a teammate, and (hopefully) subscribes. Your CEO asks a simple-sounding question in standup: &lt;strong&gt;"Where are we losing people?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your answer is a shrug and a promise to "pull some numbers," this article is for you. Funnel analysis is one of the highest-leverage things you can do with the data you already have — and you don't need a dedicated product analytics tool to do it. You need an &lt;code&gt;events&lt;/code&gt; table and a handful of SQL patterns.&lt;/p&gt;

&lt;p&gt;The catch is that funnels are deceptively easy to get &lt;em&gt;wrong&lt;/em&gt;. Count the steps naively and you'll produce numbers that look precise and are quietly inflated by 30%. Let's build a funnel the right way, and see exactly which mistakes to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data we're working with
&lt;/h2&gt;

&lt;p&gt;Assume a single wide &lt;code&gt;events&lt;/code&gt; table — the shape most product analytics setups converge on:&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;events&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;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;     &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_name&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 'viewed_pricing', 'started_trial', ...&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our funnel has four steps:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;event_name&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;1. Viewed pricing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;viewed_pricing&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2. Started trial&lt;/td&gt;
&lt;td&gt;&lt;code&gt;started_trial&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;3. Invited a teammate&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invited_teammate&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;4. Subscribed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;subscribed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The naive version (and why it lies)
&lt;/h2&gt;

&lt;p&gt;The tempting first attempt: count distinct users for each event, independently.&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="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'viewed_pricing'&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;viewed&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="k"&gt;DISTINCT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'started_trial'&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;trial&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="k"&gt;DISTINCT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invited_teammate'&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;invited&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="k"&gt;DISTINCT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'subscribed'&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;subscribed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs fast and gives you four numbers. It's also wrong for a funnel. It counts &lt;em&gt;anyone&lt;/em&gt; who ever fired each event, in any order, at any time. A user who subscribed in January and viewed the pricing page again in June counts toward both "viewed" and "subscribed" — even though their subscribe never followed &lt;em&gt;this&lt;/em&gt; pricing view. As Fivetran's team puts it, calculating each step independently and lumping them together mixes historical actions from different time periods into one funnel and artificially inflates conversion.&lt;/p&gt;

&lt;p&gt;A funnel is fundamentally about &lt;strong&gt;order&lt;/strong&gt;: step 3 only counts if it happened &lt;em&gt;after&lt;/em&gt; the same user's step 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The right way: ordered, per-user steps
&lt;/h2&gt;

&lt;p&gt;The reliable pattern is to figure out, for each user, the furthest step they reached in sequence. Window functions make this clean. First, stamp each user's first timestamp for each step:&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;step_times&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;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'viewed_pricing'&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;t1&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'started_trial'&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;t2&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invited_teammate'&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;t3&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'subscribed'&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;t4&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&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;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;step_times&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a user "reached step 3" only if &lt;code&gt;t1 &amp;lt;= t2 &amp;lt;= t3&lt;/code&gt; — each step's first occurrence comes after the previous one. We translate that into a single &lt;code&gt;step_reached&lt;/code&gt; number:&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;step_times&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;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'viewed_pricing'&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;t1&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'started_trial'&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;t2&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invited_teammate'&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;t3&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'subscribed'&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;t4&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&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;progress&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;CASE&lt;/span&gt;
      &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t4&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t4&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
      &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;             &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
      &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;                          &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
      &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;                                       &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&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;step_reached&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;step_times&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&lt;/span&gt; &lt;span class="o"&gt;&amp;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;AS&lt;/span&gt; &lt;span class="n"&gt;viewed&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&lt;/span&gt; &lt;span class="o"&gt;&amp;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;trial&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;invited&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;subscribed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because we use &lt;code&gt;&amp;gt;=&lt;/code&gt; on &lt;code&gt;step_reached&lt;/code&gt;, each stage is a proper subset of the one before it. The funnel can only ever go down — which is what a funnel is supposed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning counts into a conversion table
&lt;/h2&gt;

&lt;p&gt;Raw counts are hard to read. What people actually want is the drop-off rate at each step. Unpivot the stages into rows and compute conversion against both the previous step and the top of funnel:&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;counts&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;-- the progress CTE from above, aggregated&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&lt;/span&gt; &lt;span class="o"&gt;&amp;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;AS&lt;/span&gt; &lt;span class="n"&gt;s1&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&lt;/span&gt; &lt;span class="o"&gt;&amp;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;s2&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;s3&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_reached&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;s4&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;stages&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="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Viewed pricing'&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;counts&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Started trial'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;s2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;counts&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Invited teammate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s2&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;counts&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Subscribed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="n"&gt;s4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;counts&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;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;reached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;reached&lt;/span&gt; &lt;span class="o"&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;reached&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="mi"&gt;1&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;pct_of_top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;reached&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&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="mi"&gt;1&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;pct_of_prev&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stages&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;step&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produces the report your CEO actually wanted:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Users&lt;/th&gt;
&lt;th&gt;% of top&lt;/th&gt;
&lt;th&gt;% of previous&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Viewed pricing&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Started trial&lt;/td&gt;
&lt;td&gt;4,200&lt;/td&gt;
&lt;td&gt;42.0%&lt;/td&gt;
&lt;td&gt;42.0%&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Invited teammate&lt;/td&gt;
&lt;td&gt;1,500&lt;/td&gt;
&lt;td&gt;15.0%&lt;/td&gt;
&lt;td&gt;35.7%&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Subscribed&lt;/td&gt;
&lt;td&gt;1,050&lt;/td&gt;
&lt;td&gt;10.5%&lt;/td&gt;
&lt;td&gt;70.0%&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the story is obvious. The single biggest leak isn't the final subscribe step (70% convert once they invite someone) — it's the jump from &lt;em&gt;viewing pricing&lt;/em&gt; to &lt;em&gt;starting a trial&lt;/em&gt;. That's where a product team should spend its energy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a time window (this is the realistic version)
&lt;/h2&gt;

&lt;p&gt;"Subscribed at some point after viewing pricing" is a weak definition. Someone who viewed pricing in 2024 and subscribed in 2026 didn't really move through this funnel. Real attribution almost always has a window — 30 minutes for a session, 7 days for a marketing funnel, 14 days for a trial.&lt;/p&gt;

&lt;p&gt;You enforce it by requiring each step to fall within N days of the funnel's start (&lt;code&gt;t1&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;CASE&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t4&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;
       &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t4&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;t1&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;'14 days'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;
       &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t3&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;t1&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;'14 days'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;
       &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;t1&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;'14 days'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;                    &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&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;step_reached&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect your conversion numbers to &lt;em&gt;drop&lt;/em&gt; when you add the window. That's not the query breaking — it's the previous version having been too generous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Counting events instead of users.&lt;/strong&gt; Use &lt;code&gt;COUNT(DISTINCT user_id)&lt;/code&gt;, not &lt;code&gt;COUNT(*)&lt;/code&gt;. One enthusiastic user viewing the pricing page 40 times should count once, or your top-of-funnel is meaningless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring order.&lt;/strong&gt; The independent-count version treats "subscribed then viewed pricing" the same as "viewed pricing then subscribed." Always anchor later steps to earlier timestamps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting the time window.&lt;/strong&gt; Without one, a funnel silently accumulates matches across a user's entire history and inflates conversion. Pick a window that reflects how the funnel is actually supposed to work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Off-by-one in window frames.&lt;/strong&gt; If you extend this to rolling windows, remember &lt;code&gt;ROWS BETWEEN 7 PRECEDING AND CURRENT ROW&lt;/code&gt; spans &lt;strong&gt;8&lt;/strong&gt; rows, not 7 — one of the most common bugs in window-function queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mismatched grain.&lt;/strong&gt; If a funnel should be per-session rather than per-user (a user can enter a funnel many times), partition by a &lt;code&gt;session_id&lt;/code&gt;, not &lt;code&gt;user_id&lt;/code&gt;. Choose your grain before you write a line of SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Funnel analysis lives or dies on two rules: count &lt;strong&gt;distinct users&lt;/strong&gt;, and respect &lt;strong&gt;event order&lt;/strong&gt; within a &lt;strong&gt;time window&lt;/strong&gt;. The independent-count query is fast and wrong; the ordered &lt;code&gt;step_reached&lt;/code&gt; pattern is only a few more lines and actually answers the question. Once you have &lt;code&gt;step_reached&lt;/code&gt;, everything else — conversion tables, segmenting by plan or channel, comparing this month to last — is just a &lt;code&gt;GROUP BY&lt;/code&gt; away. And because these are subsets of the previous step, the funnel behaves like a funnel instead of a pile of unrelated metrics.&lt;/p&gt;

&lt;p&gt;The best part: this all runs against the database you already have. No new pipeline, no event-tracking migration — just SQL you can drop into a saved query and turn into a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How do you handle funnels where users can re-enter — per session, per day, or first-touch only? And what time window do you use for your signup funnel? Drop your approach (or your gnarliest funnel query) in the comments. If you'd rather not hand-write this every time, tools like &lt;a href="https://www.draxlr.com/" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt; let you save funnel queries and turn them into shareable dashboards straight on top of your existing database.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://www.fivetran.com/blog/funnel-analysis" rel="noopener noreferrer"&gt;Fivetran — Funnel Analysis and Conversion Metrics in SQL&lt;/a&gt;, &lt;a href="https://cube.dev/blog/sql-queries-for-funnel-analysis" rel="noopener noreferrer"&gt;Cube — SQL Queries for Funnel Analysis&lt;/a&gt;, &lt;a href="https://vikramoberoi.com/posts/funnel-analysis-in-sql-using-window-functions-range-frames-and-regular-expressions/" rel="noopener noreferrer"&gt;Vikram Oberoi — Funnel analysis in SQL using window functions and range frames&lt;/a&gt;, &lt;a href="https://www.metabase.com/learn/grow-your-data-skills/business-analysis-methods/how-to-do-funnel-analysis" rel="noopener noreferrer"&gt;Metabase Learn — How to do funnel analysis&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>analytics</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Self-Correcting Text-to-SQL: How Execution Feedback Loops Fix Broken Queries</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Fri, 14 Aug 2026 06:41:01 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/self-correcting-text-to-sql-how-execution-feedback-loops-fix-broken-queries-1alk</link>
      <guid>https://dev.to/vivekdraxlr/self-correcting-text-to-sql-how-execution-feedback-loops-fix-broken-queries-1alk</guid>
      <description>&lt;p&gt;You wire up an LLM to your database, ask it "how much revenue did we make last month," and it hands back a beautiful SQL query. You run it. &lt;code&gt;ERROR: column "total_amount" does not exist&lt;/code&gt;. The column is actually called &lt;code&gt;amount_cents&lt;/code&gt;. The model guessed, and it guessed wrong.&lt;/p&gt;

&lt;p&gt;This is the single biggest reason text-to-SQL demos look magical and text-to-SQL in production feels flaky. A model writing SQL from a natural language question is doing it blind — it never sees whether the query actually runs, returns rows, or returns nonsense. The fix that has quietly become the standard in 2025 research and production systems is deceptively simple: &lt;strong&gt;let the model run its own query, look at what happened, and try again.&lt;/strong&gt; That loop is called execution feedback, and it's the difference between a party trick and a tool you can ship.&lt;/p&gt;

&lt;p&gt;In this article you'll learn what an execution feedback loop actually looks like in code, why "self-correction" only works when it's grounded in real results, and the guardrails you need so a self-retrying AI doesn't melt your database or your latency budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why single-shot text-to-SQL fails
&lt;/h2&gt;

&lt;p&gt;A model generating SQL in one pass has three ways to be wrong, and only one of them is a syntax problem:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Failure type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Does it error?&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Syntax error&lt;/td&gt;
&lt;td&gt;Missing comma, unbalanced parenthesis&lt;/td&gt;
&lt;td&gt;Yes — loud&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Schema error&lt;/td&gt;
&lt;td&gt;Wrong column or table name&lt;/td&gt;
&lt;td&gt;Yes — loud&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Semantic error&lt;/td&gt;
&lt;td&gt;Right syntax, wrong logic (e.g. summed `quantity` instead of `amount`)&lt;/td&gt;
&lt;td&gt;No — silent&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The loud errors are actually the good news. A database that rejects a query is handing you a precise, machine-readable description of what's wrong. The insight from systems like MAC-SQL, CHESS, and ReFoRCE is that this error message is far more useful as a correction signal than asking the model to "double-check your work" in the abstract. Research is consistent on this point: self-correction using execution results reliably improves accuracy, while self-correction &lt;em&gt;without&lt;/em&gt; external feedback often doesn't help at all — and can even make the query worse as the model second-guesses a correct answer.&lt;/p&gt;

&lt;p&gt;So the winning pattern isn't a smarter prompt. It's a feedback loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core loop
&lt;/h2&gt;

&lt;p&gt;Here's the whole idea in pseudocode. Generate, execute, and if execution fails, feed the error back and regenerate — up to a cap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;text_to_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&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="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;llm_generate_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;previous_sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# None on first pass
&lt;/span&gt;            &lt;span class="n"&gt;previous_error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# None on first pass
&lt;/span&gt;        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result_or_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;safe_execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result_or_error&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result_or_error&lt;/span&gt;      &lt;span class="c1"&gt;# feed this back next iteration
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The magic is entirely in what you put in the prompt on the second pass. Instead of the original question alone, the model now sees its own failed attempt and the exact database error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The previous query failed. Fix it.

Question: How much revenue did we make last month?

Your previous SQL:
  SELECT SUM(total_amount) FROM orders
  WHERE created_at &amp;gt;= date_trunc('month', now() - interval '1 month');

Database error:
  column "total_amount" does not exist
  HINT: Perhaps you meant to reference the column "orders.amount_cents".

Rewrite the query using only columns that exist in the schema below.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres literally suggests the right column name in its &lt;code&gt;HINT&lt;/code&gt;. A model handed that hint fixes the query on the next pass almost every time:&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="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue_dollars&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;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;now&lt;/span&gt;&lt;span class="p"&gt;()&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 month'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire mechanism. One retry loop turns a brittle guesser into something that converges on a working query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching silent errors: empty results and sanity checks
&lt;/h2&gt;

&lt;p&gt;Syntax and schema errors are self-announcing. Semantic errors are the dangerous ones — the query runs fine and returns a confident, wrong number. You can catch a useful subset of these by treating suspicious &lt;em&gt;results&lt;/em&gt; as a form of error worth feeding back.&lt;/p&gt;

&lt;p&gt;The most valuable signal is an empty result set. If a user asks "which customers churned last month" and the query returns zero rows, that's rarely correct — it usually means a bad join or an over-strict filter. Feed that back too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;safe_execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Query executed but returned 0 rows. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This is likely a bad JOIN or an overly strict WHERE clause. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Re-examine the filters and join conditions.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# trigger another repair pass
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can layer on cheap sanity checks as additional feedback signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A revenue query that returns a negative total&lt;/li&gt;
&lt;li&gt;A count that exceeds the known row count of the table&lt;/li&gt;
&lt;li&gt;A per-user aggregate where one &lt;code&gt;user_id&lt;/code&gt; appears twice (a fan-out join bug)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are proof of a wrong answer, but as feedback prompts they nudge the model to reconsider before a human ever sees the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails: making a retrying AI safe
&lt;/h2&gt;

&lt;p&gt;The moment you let a model execute SQL — and re-execute it several times — you've built something that needs seatbelts. Four are non-negotiable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read-only, always.&lt;/strong&gt; Run every generated query through a connection whose database role has &lt;code&gt;SELECT&lt;/code&gt;-only privileges. Don't rely on the LLM to avoid &lt;code&gt;DROP&lt;/code&gt;; enforce it at the database layer where it can't be prompted away.&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;-- One-time setup: a role the AI connects as&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;CONNECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;ai_readonly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- No INSERT, UPDATE, DELETE, DROP — ever.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. A keyword denylist as a second layer.&lt;/strong&gt; Before executing, reject any query containing &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, or &lt;code&gt;GRANT&lt;/code&gt;. Belt and suspenders — the read-only role is the real defense, but a static check catches problems earlier and cheaper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Statement timeouts and row limits.&lt;/strong&gt; A retry loop can generate an accidental cross join that scans billions of rows. Cap it:&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;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- and wrap the model's query:&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="p"&gt;(&lt;/span&gt; &lt;span class="cm"&gt;/* model SQL */&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. A hard cap on retries.&lt;/strong&gt; This is the one people forget, and it bites twice. First, latency: each retry adds roughly 1.5–3 seconds of model plus execution time, so an uncapped loop can leave a user staring at a spinner for 15 seconds. Second, over-correction — models sometimes make a &lt;em&gt;correct&lt;/em&gt; query worse on later passes, second-guessing themselves into a mangled answer. Three attempts is a sane default. If it hasn't worked by then, fail loudly and log the transcript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Feeding back the wrong thing.&lt;/strong&gt; The database error string is gold — pass it through verbatim, including hints. Teams that summarize or truncate the error ("the query didn't work") throw away the exact signal that makes the loop work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrying forever.&lt;/strong&gt; No cap means unbounded latency and cost. Always set &lt;code&gt;max_attempts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting self-critique alone.&lt;/strong&gt; Asking the model "are you sure this SQL is correct?" without executing it is theater. The improvement comes from real execution results, not introspection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logging nothing.&lt;/strong&gt; You cannot debug or improve a loop you can't see. Log every attempt: the question, each generated query, each error, and the final outcome. Those transcripts are also the best training data you'll ever get for few-shot examples.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping the human confirmation early on.&lt;/strong&gt; In the first weeks of production, show the generated SQL to the user before running it. It builds trust and surfaces semantic errors that no automated check will catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Single-shot text-to-SQL fails because the model writes queries blind. An execution feedback loop — generate, run, feed the error back, retry — grounds the model in reality and is the single highest-leverage upgrade you can make. Treat empty result sets and failed sanity checks as errors worth feeding back, not just syntax failures. And wrap the whole thing in guardrails: a read-only role, a keyword denylist, statement timeouts, row limits, and a hard retry cap. Do that, and text-to-SQL stops being a demo and starts being infrastructure.&lt;/p&gt;

&lt;p&gt;The good news is you don't have to build all of this by hand. Tools like &lt;a href="https://www.draxlr.com" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt; handle AI-powered SQL generation, safe read-only execution, and turning the results into shareable dashboards — so you get the feedback loop and the guardrails without wiring them up yourself.&lt;/p&gt;

&lt;p&gt;How are you handling AI-generated SQL in your stack today — full auto-execute, human-in-the-loop, or something in between? What's the worst query an LLM has handed you? Drop it in the comments; I collect these.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://arxiv.org/abs/2502.00675" rel="noopener noreferrer"&gt;ReFoRCE: A Text-to-SQL Agent with Self-Refinement, Consensus Enforcement, and Column Exploration&lt;/a&gt;, &lt;a href="https://arxiv.org/pdf/2507.02529" rel="noopener noreferrer"&gt;RetrySQL: text-to-SQL training with retry data for self-correcting query generation&lt;/a&gt;, &lt;a href="https://medium.com/@vi.ha.engr/bridging-natural-language-and-databases-best-practices-for-llm-generated-sql-fcba0449d4e5" rel="noopener noreferrer"&gt;Bridging Natural Language and Databases: Best Practices for LLM-Generated SQL&lt;/a&gt;, &lt;a href="https://www.datadoghq.com/blog/llm-guardrails-best-practices/" rel="noopener noreferrer"&gt;LLM Guardrails: Best Practices for Deploying LLM Apps Securely (Datadog)&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Time-Series Charts in SQL: Bucketing, Gap-Filling, and Time Zones That Don't Lie</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:47:17 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/time-series-charts-in-sql-bucketing-gap-filling-and-time-zones-that-dont-lie-4b64</link>
      <guid>https://dev.to/vivekdraxlr/time-series-charts-in-sql-bucketing-gap-filling-and-time-zones-that-dont-lie-4b64</guid>
      <description>&lt;p&gt;Almost every chart in a dashboard is a time series. Signups per day, revenue per week, active users per hour, API calls per minute. They all boil down to the same shape: group rows into time buckets, count or sum something, plot the result.&lt;/p&gt;

&lt;p&gt;It sounds trivial. Then you ship it, and a customer emails: "Why does my chart show zero signups on Tuesday? We definitely had signups." Or worse, they don't email — they just quietly stop trusting the numbers, because Tuesday's bar is missing entirely and the line jumps straight from Monday to Wednesday as if nothing happened.&lt;/p&gt;

&lt;p&gt;Time-series charts have three failure modes that bite almost everyone building customer-facing analytics: buckets that don't line up, gaps that get silently skipped, and time zones that shift the whole chart by a day. This post walks through each one with plain SQL you can drop into a dashboard query, using a realistic SaaS schema. By the end you'll be able to produce a chart series that's dense, correctly bucketed, and shows each customer their own local days.&lt;/p&gt;

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

&lt;p&gt;Let's assume a multi-tenant SaaS app with a familiar &lt;code&gt;events&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- the tenant&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;     &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- 'signup', 'login', 'purchase', ...&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;Note &lt;code&gt;created_at&lt;/code&gt; is &lt;code&gt;TIMESTAMPTZ&lt;/code&gt; (timestamp &lt;em&gt;with&lt;/em&gt; time zone). Store everything in UTC and keep this type everywhere — it's the single most important decision for getting time-series charts right later. If you're storing naive &lt;code&gt;TIMESTAMP&lt;/code&gt; columns, fix that first; the rest of this post assumes you know what instant each row actually happened at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Bucketing with date_trunc
&lt;/h2&gt;

&lt;p&gt;The workhorse for grouping timestamps into intervals is &lt;code&gt;date_trunc&lt;/code&gt;. It chops a timestamp down to the start of a given unit — hour, day, week, 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;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&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;bucket&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;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'signup'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'7 days'&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you one row per day that &lt;em&gt;had&lt;/em&gt; at least one signup:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;bucket&lt;/th&gt;
&lt;th&gt;signups&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-25 00:00:00+00&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-26 00:00:00+00&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-28 00:00:00+00&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look closely. July 27 is missing. Not because of a bug — there were simply no signups that day, so &lt;code&gt;GROUP BY&lt;/code&gt; produced no row. Your charting library receives three points and draws a line straight from the 26th to the 28th. The dip to zero on the 27th vanishes, and the trend looks smoother and healthier than it actually was.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;date_trunc&lt;/code&gt; handles the "line them up" problem. It does nothing about the "missing days" problem. For that you need a spine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Gap-filling with generate_series
&lt;/h2&gt;

&lt;p&gt;The fix is to generate a complete, dense list of buckets and &lt;code&gt;LEFT JOIN&lt;/code&gt; your aggregated data onto it. Postgres ships &lt;code&gt;generate_series&lt;/code&gt; for exactly 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="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;spine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signups&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="n"&gt;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'6 days'&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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&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;AS&lt;/span&gt; &lt;span class="n"&gt;spine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&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;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&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;bucket&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;signups&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'signup'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'7 days'&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="n"&gt;e&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;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&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;spine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every day in the range shows up, and the empty ones report zero:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;bucket&lt;/th&gt;
&lt;th&gt;signups&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-25&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-26&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;generate_series&lt;/code&gt; builds the calendar, the &lt;code&gt;LEFT JOIN&lt;/code&gt; attaches whatever data exists, and &lt;code&gt;COALESCE&lt;/code&gt; turns the &lt;code&gt;NULL&lt;/code&gt; from days-with-no-match into a real zero. This pattern is portable, works on plain PostgreSQL with no extensions, and is the single technique that separates an honest line chart from a misleading one.&lt;/p&gt;

&lt;p&gt;If you're on TimescaleDB you get &lt;code&gt;time_bucket()&lt;/code&gt; and &lt;code&gt;time_bucket_gapfill()&lt;/code&gt; which do bucketing and gap-filling in one call — and let you bucket by arbitrary intervals like &lt;code&gt;5 minutes&lt;/code&gt; or &lt;code&gt;4 hours&lt;/code&gt;, which &lt;code&gt;date_trunc&lt;/code&gt; can't. But you don't need an extension to get correct charts. The &lt;code&gt;generate_series&lt;/code&gt; + &lt;code&gt;LEFT JOIN&lt;/code&gt; spine works everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Time zones, or "whose Tuesday is it?"
&lt;/h2&gt;

&lt;p&gt;Here's the one that silently corrupts numbers for months before anyone notices. &lt;code&gt;date_trunc('day', created_at)&lt;/code&gt; truncates in UTC. If your customer is in Los Angeles, an event at 6pm Monday their time happened at 1am Tuesday UTC — so it lands in Tuesday's bucket, not Monday's. Every evening event gets shoved into the next day. Daily totals are wrong, and "which day is our busiest" points at the wrong day.&lt;/p&gt;

&lt;p&gt;The fix is to convert to the customer's zone &lt;em&gt;before&lt;/em&gt; truncating, using &lt;code&gt;AT TIME ZONE&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;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="s1"&gt;'America/Los_Angeles'&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;bucket&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;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;created_at AT TIME ZONE 'America/Los_Angeles'&lt;/code&gt; converts the UTC instant into local wall-clock time, so &lt;code&gt;date_trunc('day', ...)&lt;/code&gt; now means "midnight-to-midnight in LA." That's the day boundary the customer actually experiences.&lt;/p&gt;

&lt;p&gt;In a multi-tenant app, the zone isn't a constant — it's a property of the tenant. Join it in:&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;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&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;created_at&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timezone&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;bucket&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;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&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;workspaces&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workspace_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store an IANA zone name like &lt;code&gt;America/Los_Angeles&lt;/code&gt; per workspace (not a fixed &lt;code&gt;-08:00&lt;/code&gt; offset — offsets don't know about daylight saving). Then the same query renders correct local days for every customer, whether they're in California, Berlin, or Sydney. Combine this with the gap-filling spine from Step 2, generating that spine in the customer's zone too, and the chart is both dense and locally correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete, chart-ready query
&lt;/h2&gt;

&lt;p&gt;Putting all three together — bucketed, gap-filled, and time-zone-aware for a single tenant:&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;bounds&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;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;workspaces&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;spine&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;generate_series&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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&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;tz&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;))&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;'29 days'&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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&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;tz&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&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;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;daily&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;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&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;created_at&lt;/span&gt; &lt;span class="k"&gt;AT&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&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;tz&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bounds&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;bucket&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&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;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'signup'&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;signups&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&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;event_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'purchase'&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;purchases&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&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;workspace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
    &lt;span class="k"&gt;AND&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;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'31 days'&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="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;bucket&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&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="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;signups&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="n"&gt;signups&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="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purchases&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="n"&gt;purchases&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;spine&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;daily&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&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;bucket&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;bucket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a 30-day, two-metric series with no gaps, bucketed on the customer's local calendar — exactly what a line or bar chart needs, ready to hand straight to the frontend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Filtering by local time instead of UTC.&lt;/strong&gt; Keep your &lt;code&gt;WHERE created_at &amp;gt;= ...&lt;/code&gt; bound in UTC (that's what the index is on). Convert to local time only inside &lt;code&gt;date_trunc&lt;/code&gt; for the grouping. Wrapping the column in &lt;code&gt;AT TIME ZONE&lt;/code&gt; in the &lt;code&gt;WHERE&lt;/code&gt; clause can also throw away your index and force a full scan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed offsets instead of IANA zones.&lt;/strong&gt; &lt;code&gt;AT TIME ZONE '-08:00'&lt;/code&gt; breaks twice a year when daylight saving flips. Always store and use named zones like &lt;code&gt;Europe/London&lt;/code&gt; so the database applies DST for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting the range bounds match the spine.&lt;/strong&gt; If your &lt;code&gt;generate_series&lt;/code&gt; covers 30 days but the aggregate subquery only pulls 7, you'll get 23 days of zeros that look real. Make the spine and the data query cover the same window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gap-filling a metric where zero is wrong.&lt;/strong&gt; &lt;code&gt;COALESCE(..., 0)&lt;/code&gt; is right for counts and sums. But for a running balance or a gauge like "active subscriptions," a missing bucket should carry the last known value forward (last-observation-carried-forward), not drop to zero. Pick the fill strategy that matches what the metric means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucketing by week without agreeing on the start day.&lt;/strong&gt; &lt;code&gt;date_trunc('week', ...)&lt;/code&gt; starts weeks on Monday in Postgres. If your product defines the week as starting Sunday, your chart and your customer's mental model disagree by a day. Decide, document it, and be consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not pre-aggregating for big tenants.&lt;/strong&gt; Scanning millions of rows on every dashboard load is slow. Once the raw query is correct, roll it into a daily summary table or materialized view keyed by &lt;code&gt;(workspace_id, day)&lt;/code&gt;, and query that for the chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Bucketing, gap-filling, and time zones are the three things that make time-series charts either trustworthy or quietly wrong. &lt;code&gt;date_trunc&lt;/code&gt; lines rows up into intervals but leaves holes; a &lt;code&gt;generate_series&lt;/code&gt; spine plus a &lt;code&gt;LEFT JOIN&lt;/code&gt; and &lt;code&gt;COALESCE&lt;/code&gt; fills those holes so a zero day reads as zero instead of vanishing; and &lt;code&gt;AT TIME ZONE&lt;/code&gt; with a per-tenant IANA zone makes sure each customer sees their own days, not UTC's. Get all three right and the chart matches what the customer actually experienced — which is the whole point of putting analytics in front of them.&lt;/p&gt;

&lt;p&gt;If you're wiring these queries into a real dashboard, tools like &lt;a href="https://draxlr.com" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt; let you turn SQL like this into embeddable charts without hand-rolling the frontend — handy once the query is correct and you just want it on a screen.&lt;/p&gt;

&lt;p&gt;How do you handle gap-filling and per-customer time zones in your dashboards — spine joins, a time-series extension, or pre-aggregated rollups? Drop your approach in the comments; I'm always curious what patterns people land on at scale.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://www.tigerdata.com/blog/sql-functions-for-time-series-analysis" rel="noopener noreferrer"&gt;Tiger Data — Mind the Gap: SQL Functions for Time-Series Analysis&lt;/a&gt;, &lt;a href="https://www.tigerdata.com/blog/simplified-time-series-analytics-using-the-time_bucket-function" rel="noopener noreferrer"&gt;Tiger Data — Simplified time-series analytics: time_bucket()&lt;/a&gt;, &lt;a href="https://www.postgresql.org/docs/current/functions-datetime.html" rel="noopener noreferrer"&gt;PostgreSQL Documentation — Date/Time Functions and Operators&lt;/a&gt;, &lt;a href="https://neon.com/docs/functions/date_trunc" rel="noopener noreferrer"&gt;Neon Docs — Postgres date_trunc() function&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>postgres</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Cohort Retention Analysis in SQL: The Query That Tells You If Your Product Is Actually Sticky</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Thu, 13 Aug 2026 06:55:31 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/cohort-retention-analysis-in-sql-the-query-that-tells-you-if-your-product-is-actually-sticky-npe</link>
      <guid>https://dev.to/vivekdraxlr/cohort-retention-analysis-in-sql-the-query-that-tells-you-if-your-product-is-actually-sticky-npe</guid>
      <description>&lt;p&gt;Your signup chart is going up and to the right. Revenue is growing. Everyone's happy. And yet something feels off — support tickets keep repeating, the same features get "discovered" by the same users every month, and nobody can quite say whether the people who joined in January are still around in June.&lt;/p&gt;

&lt;p&gt;That gnawing feeling has a name, and it has a query. &lt;strong&gt;Cohort retention analysis&lt;/strong&gt; is the single most honest report you can run against your database. It takes your users, groups them by when they joined, and tracks how many of each group keep coming back over time. A growing signup number can hide a leaky bucket. A retention cohort table cannot — it shows you, month by month, exactly how fast your bucket leaks.&lt;/p&gt;

&lt;p&gt;In this article we'll build a full cohort retention query from scratch using plain SQL, look at the difference between the three kinds of retention people confuse constantly, and cover the one mistake that makes almost every hand-rolled retention report wrong. By the end you'll have a query you can point at your own &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;events&lt;/code&gt; tables today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two tables you need
&lt;/h2&gt;

&lt;p&gt;You don't need an analytics warehouse for this. Two tables most SaaS apps already have will do:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Columns we care about&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;events&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;occurred_at&lt;/code&gt;, &lt;code&gt;event_name&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;events&lt;/code&gt; table is whatever signals "this user is getting value" — a login, a report run, a message sent, an API call. Pick the action that means someone is actually &lt;em&gt;using&lt;/em&gt; the product, not just a passive ping. This choice matters more than any SQL trick below: if you count "opened a marketing email" as activity, your retention will look great and mean nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define the cohort
&lt;/h2&gt;

&lt;p&gt;A cohort is just a group of users bucketed by the period they joined. We'll use signup month. In PostgreSQL, &lt;code&gt;date_trunc&lt;/code&gt; snaps a timestamp down to the start of a period, which makes the bucketing trivial:&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;-- Each user's cohort = the month they signed up&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cohort_month&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives every user a &lt;code&gt;cohort_month&lt;/code&gt; like &lt;code&gt;2026-01-01&lt;/code&gt;. Everyone who signed up in January shares the same anchor, and that anchor never changes for them. This CTE is the foundation of everything else — and, importantly, the source of your &lt;strong&gt;denominator&lt;/strong&gt;. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Find each user's activity months
&lt;/h2&gt;

&lt;p&gt;Next we bucket every activity event into the month it 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="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&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;occurred_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;activity_month&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&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;DISTINCT&lt;/code&gt; matters here. A power user might fire 400 events in March, but for retention we only care &lt;em&gt;whether&lt;/em&gt; they were active that month, not how many times. Skip the &lt;code&gt;DISTINCT&lt;/code&gt; and your later counts will double-count active users and blow past 100% retention — a classic "that can't be right" moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Join them and measure the gap
&lt;/h2&gt;

&lt;p&gt;Now we connect each cohort to its activity and compute how many months after signup each activity happened. This is where the shape of the report appears:&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;cohort&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="k"&gt;AS&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cohort_month&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;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="k"&gt;DISTINCT&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;occurred_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;activity_month&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;-- whole months between signup and the activity&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="nb"&gt;YEAR&lt;/span&gt;  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_month&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;cohort_month&lt;/span&gt;&lt;span class="p"&gt;))&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="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;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_month&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;cohort_month&lt;/span&gt;&lt;span class="p"&gt;)))::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;month_number&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="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cohort&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_month&lt;/span&gt; &lt;span class="o"&gt;&amp;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;cohort_month&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;month_number = 0&lt;/code&gt; is the signup month, &lt;code&gt;1&lt;/code&gt; is the month after, and so on. The result is a long, tidy table: one row per (cohort, month offset) with a count of how many users were active.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Turn it into the triangle
&lt;/h2&gt;

&lt;p&gt;Retention tables are usually shown as a triangle — cohorts down the side, month offsets across the top. &lt;code&gt;FILTER&lt;/code&gt; (or &lt;code&gt;CASE&lt;/code&gt;) pivots the long output into columns:&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;cohort&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="k"&gt;AS&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cohort_month&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;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="k"&gt;DISTINCT&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;occurred_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;activity_month&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;joined&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cohort_month&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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;YEAR&lt;/span&gt;  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_month&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;cohort_month&lt;/span&gt;&lt;span class="p"&gt;))&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="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;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_month&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;cohort_month&lt;/span&gt;&lt;span class="p"&gt;)))::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;month_number&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cohort&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activity_month&lt;/span&gt; &lt;span class="o"&gt;&amp;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;cohort_month&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;cohort_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="k"&gt;DISTINCT&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;AS&lt;/span&gt; &lt;span class="n"&gt;cohort_size&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="k"&gt;DISTINCT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;month_number&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;AS&lt;/span&gt; &lt;span class="n"&gt;m1&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="k"&gt;DISTINCT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;month_number&lt;/span&gt; &lt;span class="o"&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;m2&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="k"&gt;DISTINCT&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;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;month_number&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;AS&lt;/span&gt; &lt;span class="n"&gt;m3&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;joined&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;cohort_month&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;cohort_month&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Divide each &lt;code&gt;mN&lt;/code&gt; by &lt;code&gt;cohort_size&lt;/code&gt; and you get retention percentages. A real result looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Cohort&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Month 1&lt;/th&gt;
&lt;th&gt;Month 2&lt;/th&gt;
&lt;th&gt;Month 3&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;2026-01&lt;/td&gt;
&lt;td&gt;820&lt;/td&gt;
&lt;td&gt;44%&lt;/td&gt;
&lt;td&gt;31%&lt;/td&gt;
&lt;td&gt;27%&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-02&lt;/td&gt;
&lt;td&gt;910&lt;/td&gt;
&lt;td&gt;47%&lt;/td&gt;
&lt;td&gt;34%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-03&lt;/td&gt;
&lt;td&gt;1,050&lt;/td&gt;
&lt;td&gt;52%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read it two ways. &lt;strong&gt;Down a column&lt;/strong&gt; tells you whether newer cohorts retain better than older ones — here Month 1 climbs from 44% to 52%, so something you shipped is working. &lt;strong&gt;Across a row&lt;/strong&gt; tells you the shape of the decay: a curve that flattens (27% → 27% → 27%) means you've found a loyal core; a curve that keeps sliding toward zero means you have no floor, and that's an existential problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounded vs. rolling vs. classic retention
&lt;/h2&gt;

&lt;p&gt;People say "retention" and mean three different things. Getting this wrong makes two teams argue about numbers that were never measuring the same thing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;SQL condition&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Classic (period) retention&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Were they active &lt;em&gt;in&lt;/em&gt; month N?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;month_number = N&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bounded / Day-N retention&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Were they active on exactly day N?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;activity_date = cohort_date + N&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rolling retention&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Were they active on or after month N?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;month_number &amp;gt;= N&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The query above is classic period retention, which is the right default for most SaaS products. Bounded (&lt;code&gt;=&lt;/code&gt;) is stricter and used for habit-forming apps where daily use is the goal. Rolling (&lt;code&gt;&amp;gt;=&lt;/code&gt;) is the most forgiving — it counts a user as retained at Month 3 if they showed up &lt;em&gt;any&lt;/em&gt; time from Month 3 onward, which is useful for infrequent-but-valuable products like a tax tool or an annual-review app. There's no universally correct one; there's only the one that matches how your product is meant to be used. Just be sure everyone in the room is looking at the same definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that quietly ruin retention reports
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The denominator bug (the big one).&lt;/strong&gt; This is the mistake in the majority of hand-written retention queries, and it always inflates your numbers. If you use &lt;code&gt;INNER JOIN&lt;/code&gt; instead of &lt;code&gt;LEFT JOIN&lt;/code&gt;, or add a &lt;code&gt;WHERE&lt;/code&gt; clause on the activity, users who &lt;em&gt;never came back&lt;/em&gt; silently disappear from the cohort entirely. Now you're computing "of the users who returned, how many returned?" — which is close to 100% by construction. The denominator must always be the &lt;strong&gt;full cohort&lt;/strong&gt;, including the people who churned. Keep the &lt;code&gt;LEFT JOIN&lt;/code&gt;, and never filter the cohort CTE by activity.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sanity check it independently:&lt;/em&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="c1"&gt;-- cohort_size here must equal the size in your report&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;created_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cohort_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="k"&gt;DISTINCT&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;AS&lt;/span&gt; &lt;span class="n"&gt;cohort_size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those numbers don't match your report's &lt;code&gt;cohort_size&lt;/code&gt;, your join is dropping churned users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial cohorts.&lt;/strong&gt; This month's cohort hasn't lived a full month yet, so its later columns look artificially low. Don't panic over a 12% Month-1 for the current cohort — it's incomplete, not collapsing. Either exclude cohorts that haven't matured or grey them out in the dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timezone and week boundaries.&lt;/strong&gt; &lt;code&gt;date_trunc&lt;/code&gt; uses whatever timezone your timestamps are stored in. If &lt;code&gt;created_at&lt;/code&gt; is UTC but your users are in California, a signup at 9pm Pacific on Jan 31 lands in February's cohort. Normalize to a single timezone (&lt;code&gt;created_at AT TIME ZONE 'America/Los_Angeles'&lt;/code&gt;) before truncating, and pick one week-start convention if you cohort by week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choosing a vanity activity event.&lt;/strong&gt; Worth repeating: if your "activity" is anything a user can trigger without engaging (a background sync, an email open), your retention curve is measuring your cron jobs, not your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Cohort retention is the report that can't be faked by a good growth month. Group users by signup period, count distinct active users per later period, and always divide by the &lt;em&gt;full&lt;/em&gt; cohort — churned users included. Use &lt;code&gt;LEFT JOIN&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt; your activity, exclude immature cohorts, and agree on whether you mean classic, bounded, or rolling retention before anyone reads a number off the chart. Get those right and you'll have a table that tells you the truth about whether people actually stick around.&lt;/p&gt;

&lt;p&gt;The query itself is 30 lines. The hard part is running it regularly and putting it somewhere your whole team sees it — which is exactly the point where a raw SQL query needs to become a living dashboard. If you're already piping these cohorts into a chart or embedding them in your product, I'd love to hear how you've set it up.&lt;/p&gt;

&lt;p&gt;How do you define an "active" user for your retention math — and has changing that definition ever completely rewritten your curve? Drop your approach in the comments.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>analytics</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Text-to-SQL at Scale: Stop Feeding Your LLM the Whole Database</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:07:19 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/text-to-sql-at-scale-stop-feeding-your-llm-the-whole-database-1nme</link>
      <guid>https://dev.to/vivekdraxlr/text-to-sql-at-scale-stop-feeding-your-llm-the-whole-database-1nme</guid>
      <description>&lt;h1&gt;
  
  
  Text-to-SQL at Scale: Stop Feeding Your LLM the Whole Database
&lt;/h1&gt;

&lt;p&gt;The first text-to-SQL demo you build always works. You have five tables — &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;products&lt;/code&gt;, &lt;code&gt;subscriptions&lt;/code&gt;, &lt;code&gt;events&lt;/code&gt; — you paste the whole schema into the prompt, and the LLM writes flawless queries. Ship it.&lt;/p&gt;

&lt;p&gt;Then you point the same system at your real production database. Two hundred tables. Columns named &lt;code&gt;flg_actv_ind&lt;/code&gt; and &lt;code&gt;usr_ref_id_2&lt;/code&gt;. Three tables that all look like they hold "orders." Suddenly the model joins the wrong tables, invents a column that doesn't exist, or times out because the schema alone is 40,000 tokens before the user even asks a question.&lt;/p&gt;

&lt;p&gt;This is the wall every team hits when moving text-to-SQL from demo to production. And here's the surprising part: study after study finds that &lt;strong&gt;generating SQL is not the hard part — picking the right tables is.&lt;/strong&gt; Schema-linking errors (selecting the wrong tables or columns) account for roughly 20% of all text-to-SQL failures in major benchmarks. This post is about how to get that 20% back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem: schema linking
&lt;/h2&gt;

&lt;p&gt;"Schema linking" is the step where the system figures out &lt;em&gt;which&lt;/em&gt; tables and columns a question actually needs, before writing any SQL. Ask "How much revenue did we make from annual plans last month?" and the relevant subset might be just &lt;code&gt;subscriptions&lt;/code&gt;, &lt;code&gt;plans&lt;/code&gt;, and &lt;code&gt;invoices&lt;/code&gt; — three tables out of two hundred.&lt;/p&gt;

&lt;p&gt;Get this wrong and everything downstream fails. Miss a needed column and the query is incomplete. Include ten irrelevant tables and you drown the model in noise, so it joins &lt;code&gt;orders&lt;/code&gt; to the wrong &lt;code&gt;customers&lt;/code&gt; table. As one benchmark analysis put it, an incomplete or incorrect schema makes generating a correct query &lt;em&gt;nearly impossible&lt;/em&gt; — the LLM can't recover from bad context.&lt;/p&gt;

&lt;p&gt;So the naive approach — "just give the model everything" — fails for two reasons at once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Token cost / limits&lt;/td&gt;
&lt;td&gt;A 300-table schema can blow past the context window, or cost a fortune per query, before the user's question is even added.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Noise and distraction&lt;/td&gt;
&lt;td&gt;Even when it fits, irrelevant tables confuse the model. More schema means more chances to link the wrong `status` column or pick the wrong `orders` table.&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix is to stop treating the schema as a fixed prompt prefix and start treating it as something you &lt;em&gt;retrieve on demand&lt;/em&gt; — the same idea behind RAG (retrieval-augmented generation).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: Retrieve the relevant schema with embeddings
&lt;/h2&gt;

&lt;p&gt;Instead of dumping all tables into the prompt, index them and fetch only the ones that match the question.&lt;/p&gt;

&lt;p&gt;The setup: for each table (and often each column), write a short natural-language description and embed it into a vector store. At query time, embed the user's question and pull back the top-k most similar schema elements. Those — and only those — go into the prompt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question: "revenue from annual plans last month"
        │
        ▼  embed + vector search over table descriptions
Top matches:
   subscriptions  (0.89)  — customer plan enrollments, billing cycle
   plans          (0.86)  — plan tiers, price, interval (monthly/annual)
   invoices       (0.81)  — issued charges, amount, paid_at
        │
        ▼  only these 3 tables go into the LLM prompt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The descriptions matter enormously. A raw column name like &lt;code&gt;flg_actv_ind&lt;/code&gt; will never match "active users" semantically. But if you index the description &lt;em&gt;"boolean flag, whether the subscription is currently active,"&lt;/em&gt; it will. This is why teams that succeed at text-to-SQL invest in a data dictionary — the embeddings are only as good as the words you feed them.&lt;/p&gt;

&lt;p&gt;Your prompt goes from this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ 300 tables, 40,000 tokens of DDL ]
Question: revenue from annual plans last month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to 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;-- Retrieved schema (only what's relevant):&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&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;bigint&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;customer_id&lt;/span&gt;   &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;plan_id&lt;/span&gt;       &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- 'active','canceled','past_due'&lt;/span&gt;
  &lt;span class="n"&gt;started_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;plans&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;bigint&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;interval&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;-- 'month' | 'year'&lt;/span&gt;
  &lt;span class="n"&gt;price_cents&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&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;bigint&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;subscription_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt;    &lt;span class="nb"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;paid_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Question: revenue from annual plans last month&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the model has a clean, focused context and reliably produces:&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="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscription_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;plans&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;        &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&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;plan_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;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'year'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paid_at&lt;/span&gt; &lt;span class="o"&gt;&amp;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="k"&gt;CURRENT_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;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paid_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pattern 2: Bring the foreign keys along
&lt;/h2&gt;

&lt;p&gt;Here's a mistake that bites people the first week: they retrieve tables by semantic similarity but forget the &lt;em&gt;relationships&lt;/em&gt;. The question mentions "revenue" and "plans," so retrieval returns &lt;code&gt;invoices&lt;/code&gt; and &lt;code&gt;plans&lt;/code&gt; — but not &lt;code&gt;subscriptions&lt;/code&gt;, the join table that connects them. The model now has no path between the two tables and either hallucinates a join or fails.&lt;/p&gt;

&lt;p&gt;The fix is a graph-expansion step. After semantic retrieval, walk the foreign-key graph one hop out and pull in any bridging tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retrieved:  invoices, plans
FK graph:   invoices → subscriptions → plans
add:        subscriptions  (bridges the two)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always include the foreign-key definitions in the DDL you pass to the model. Relationships are the single most valuable piece of context for correct joins, and they're cheap to include.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: Add a few real values for tricky columns
&lt;/h2&gt;

&lt;p&gt;The model often has to guess what a &lt;code&gt;status&lt;/code&gt; column contains. Does "canceled" mean &lt;code&gt;status = 'canceled'&lt;/code&gt;, &lt;code&gt;'cancelled'&lt;/code&gt; (British spelling), or &lt;code&gt;0&lt;/code&gt;? It can't know from the schema alone.&lt;/p&gt;

&lt;p&gt;Feeding a handful of sample distinct values per low-cardinality column removes the guesswork:&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;-- Sample values:&lt;/span&gt;
&lt;span class="c1"&gt;--   subscriptions.status: 'active', 'canceled', 'past_due', 'trialing'&lt;/span&gt;
&lt;span class="c1"&gt;--   plans.interval: 'month', 'year'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one trick eliminates a whole class of "the query ran but returned zero rows" bugs, where the SQL is syntactically perfect but filters on a string that never appears in the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;th&gt;Why it hurts&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Indexing cryptic column names, not descriptions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;usr_ref_id_2&lt;/code&gt; matches nothing semantically&lt;/td&gt;
&lt;td&gt;Embed human-written descriptions from a data dictionary&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Top-k too small&lt;/td&gt;
&lt;td&gt;You drop a needed table and the query is impossible&lt;/td&gt;
&lt;td&gt;Tune k on real questions; err on including bridge tables&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Top-k too large&lt;/td&gt;
&lt;td&gt;Noise returns; the model links the wrong table&lt;/td&gt;
&lt;td&gt;Rerank, and cap at what the question realistically needs&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Ignoring foreign keys&lt;/td&gt;
&lt;td&gt;Model can't find a join path&lt;/td&gt;
&lt;td&gt;Expand one hop along the FK graph after retrieval&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;No sample values&lt;/td&gt;
&lt;td&gt;Correct SQL, zero rows (wrong filter literal)&lt;/td&gt;
&lt;td&gt;Include distinct values for low-cardinality columns&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Duplicate-looking tables&lt;/td&gt;
&lt;td&gt;Three tables named like "orders"; model picks the stale one&lt;/td&gt;
&lt;td&gt;Descriptions must disambiguate: "legacy, do not use" vs "current"&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One more subtlety worth knowing: the newest, strongest reasoning models are surprisingly good at handling large schemas directly, and some research now questions whether aggressive schema pruning is always necessary. But that's a bet on token budget and latency. For most teams shipping to production today — where every query costs money and users expect answers in under two seconds — retrieving a focused schema is still the pragmatic default. Retrieval also gives you a debuggable artifact: when a query is wrong, you can inspect &lt;em&gt;exactly which tables were fed in&lt;/em&gt; and see whether the failure was retrieval or generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;The hard part of text-to-SQL at scale isn't writing SQL — it's picking the right tables. Don't paste your whole schema into the prompt; it's expensive, it overflows context, and the noise makes the model worse. Instead, treat the schema as a retrievable resource: embed human-readable descriptions of your tables and columns, fetch the top matches for each question, expand along foreign keys so join paths stay intact, and sprinkle in sample values for ambiguous columns. Together these turn a flaky demo into something you can actually put in front of users.&lt;/p&gt;

&lt;p&gt;If you're building this, the unglamorous prerequisite is a good data dictionary. The LLM can only retrieve what you've described well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Are you building a natural-language query layer over a big database? What's been your biggest headache — wrong joins, hallucinated columns, or latency? And how are you handling schema selection: full dump, embeddings, or hand-curated table groups? Drop your approach in the comments — I'm collecting patterns that hold up in production.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Your AI Writes Correct SQL and Still Gets the Wrong Answer. Here's Why.</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Tue, 11 Aug 2026 04:34:37 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/your-ai-writes-correct-sql-and-still-gets-the-wrong-answer-heres-why-hjj</link>
      <guid>https://dev.to/vivekdraxlr/your-ai-writes-correct-sql-and-still-gets-the-wrong-answer-heres-why-hjj</guid>
      <description>&lt;p&gt;You wire up an AI SQL assistant, type "what was our revenue last month," and get back a clean, valid query. It runs. It returns a number. Everyone nods.&lt;/p&gt;

&lt;p&gt;Then someone in finance runs the same question through the accounting system and gets a different number. Now you're in a meeting arguing about which one is real.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth about text-to-SQL: &lt;strong&gt;the SQL is almost never the hard part.&lt;/strong&gt; Modern models write syntactically correct SQL the vast majority of the time. The failures that actually hurt you aren't syntax errors — they're queries that run perfectly and quietly return the wrong answer. And no amount of "use a better model" fixes them, because the model was never given the one thing it needed: your definitions.&lt;/p&gt;

&lt;p&gt;This post is about why that happens and how a semantic layer — a place where each metric is defined exactly once — turns an unreliable guessing machine into something you can actually trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: your business terms are ambiguous, and the database doesn't know it
&lt;/h2&gt;

&lt;p&gt;Ask three teams what an "active user" is and you'll get three answers. To product, it's a user with a login event in the last 30 days. To billing, it's a user with a live subscription. To growth, it's someone who hit a core action this week.&lt;/p&gt;

&lt;p&gt;Your warehouse doesn't encode any of that. It has an &lt;code&gt;events&lt;/code&gt; table, a &lt;code&gt;users&lt;/code&gt; table, and a &lt;code&gt;subscriptions&lt;/code&gt; table. When an LLM sees "active users," it picks &lt;em&gt;one&lt;/em&gt; plausible interpretation, writes valid SQL for it, and hands you a number. The number looks reasonable, so nobody questions it — until it disagrees with every other system in the company.&lt;/p&gt;

&lt;p&gt;This is what makes text-to-SQL dangerous rather than merely imperfect. As the team at Omni put it, the queries that fail don't throw an error — they return wrong data. A syntax error you catch immediately. A silently wrong &lt;code&gt;active_user&lt;/code&gt; count you ship to a board deck.&lt;/p&gt;

&lt;p&gt;Consider what the model actually has to guess:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Business term&lt;/th&gt;
&lt;th&gt;Possible SQL meaning A&lt;/th&gt;
&lt;th&gt;Possible SQL meaning B&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Active user&lt;/td&gt;
&lt;td&gt;login event in last 30 days&lt;/td&gt;
&lt;td&gt;subscription status = 'active'&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Revenue&lt;/td&gt;
&lt;td&gt;SUM(orders.amount)&lt;/td&gt;
&lt;td&gt;SUM(orders.amount) minus refunds&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Churn&lt;/td&gt;
&lt;td&gt;cancellations this month&lt;/td&gt;
&lt;td&gt;MRR lost / MRR at start of month&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;New customer&lt;/td&gt;
&lt;td&gt;first order this month&lt;/td&gt;
&lt;td&gt;first paid subscription this month&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The model isn't wrong for picking column A. It's wrong because &lt;em&gt;there is no way for it to know&lt;/em&gt; you meant column B. That knowledge lives in people's heads and in scattered SQL snippets, not in the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just prompt it better" only gets you so far
&lt;/h2&gt;

&lt;p&gt;The common first fix is to stuff definitions into the prompt: "active user = a user with at least one login event in the last 30 days." This genuinely helps — including business glossaries in context is one of the most effective mitigations you can apply today.&lt;/p&gt;

&lt;p&gt;But prompt-based definitions don't scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every question re-derives the metric from a fuzzy English sentence, so you get subtle drift between runs.&lt;/li&gt;
&lt;li&gt;Nobody updates the prompt when the definition changes, so the glossary rots.&lt;/li&gt;
&lt;li&gt;Complex metrics (net revenue retention, cohort churn) don't fit in a sentence — they need real SQL logic.&lt;/li&gt;
&lt;li&gt;You have no guarantee the model &lt;em&gt;used&lt;/em&gt; the definition rather than ignoring it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prompting treats the symptom. The disease is that your metric definitions aren't a first-class, queryable object anywhere in your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: define each metric exactly once
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;semantic layer&lt;/strong&gt; is a centralized catalog that sits between your warehouse and whatever queries it — a BI tool, a dashboard, or an AI assistant. Instead of every analyst (and every LLM prompt) re-deriving "monthly revenue," you define it one time, as explicit SQL, and everything references that definition.&lt;/p&gt;

&lt;p&gt;Here's the shift in concrete terms. Without a semantic layer, your AI generates the whole query from scratch:&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;-- AI free-writing against raw tables. Which interpretation did it pick?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&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;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'login'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&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;'30 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a semantic layer, the metric is pre-defined and the AI's job shrinks to picking the right &lt;em&gt;metric&lt;/em&gt; and &lt;em&gt;dimensions&lt;/em&gt; — not inventing the SQL. A definition might look like this (dbt/MetricFlow-style YAML):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;active_users&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Active&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Users"&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Distinct&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;login&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;days"&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;simple&lt;/span&gt;
    &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user_id&lt;/span&gt;
    &lt;span class="na"&gt;agg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;count_distinct&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'login'"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dateadd(day,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-30,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;current_date)"&lt;/span&gt;
    &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;plan&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;country&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;signup_month&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now when someone asks "active users by plan last quarter," the AI doesn't guess the join or the filter. It selects the &lt;code&gt;active_users&lt;/code&gt; metric and the &lt;code&gt;plan&lt;/code&gt; dimension, and the semantic layer compiles the guaranteed-correct SQL. As dbt Labs describes it: if the model picks the right metric and dimensions, the query is correct by construction — it &lt;em&gt;can't&lt;/em&gt; produce a bad aggregation or a wrong join.&lt;/p&gt;

&lt;p&gt;You can express richer metrics the same way. Net revenue as explicit, reviewed logic:&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;-- Definition behind the "net_revenue" metric, written once, reviewed by humans&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;o&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="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refund_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="n"&gt;net_revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every question about "revenue" now resolves to &lt;em&gt;this&lt;/em&gt;, whether it comes from a human, a dashboard, or an AI prompt. One source of truth, not one interpretation per query.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does to accuracy (the numbers are dramatic)
&lt;/h2&gt;

&lt;p&gt;This isn't a marginal polish. Recent benchmarking tells a stark story. On raw, unnormalized tables, text-to-SQL systems have historically landed around &lt;strong&gt;32.7%&lt;/strong&gt; end-to-end accuracy. Point the same models at a properly modeled semantic layer and accuracy jumps to roughly &lt;strong&gt;72–100%&lt;/strong&gt; depending on question type, with enterprise deployments commonly reporting &lt;strong&gt;85–95%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The other benefit is subtler but just as important: when a question can't be answered from the defined metrics, a semantic layer can &lt;em&gt;say so&lt;/em&gt; instead of fabricating a plausible query. "I don't have a metric for that" is infinitely safer than a confident wrong number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treating the semantic layer as documentation.&lt;/strong&gt; A glossary in Notion doesn't help the AI. The definition has to be a live, queryable object — SQL the engine actually compiles — not prose a human has to remember to copy into a prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Modeling everything on day one.&lt;/strong&gt; You don't need 300 metrics. Start with the 10–15 numbers that show up in exec meetings and cause arguments. Those are where silent wrongness costs you the most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting synonyms.&lt;/strong&gt; Users say "signups," "new accounts," "registrations," and "new users" for the same thing. Register synonyms so the AI maps natural language to the right metric instead of free-writing a fourth variant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No ownership.&lt;/strong&gt; A metric definition without an owner drifts. When finance changes how churn is calculated, someone has to update the one definition — and because it's centralized, that update propagates everywhere instead of leaving twelve stale copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping row-level security.&lt;/strong&gt; A semantic layer is a great place to enforce tenant isolation, but only if you wire it in. Don't assume defining a metric also scopes it to the right customer's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;With modern models, &lt;strong&gt;correct syntax is the easy part&lt;/strong&gt;. Correct &lt;em&gt;meaning&lt;/em&gt; is the hard part, and it's where text-to-SQL silently fails.&lt;/li&gt;
&lt;li&gt;Ambiguous business terms — active user, revenue, churn — have no single meaning in your schema, so the AI guesses, and the guess looks right.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;semantic layer defines each metric once&lt;/strong&gt; as explicit SQL, shrinking the AI's job from "write the whole query" to "pick the right metric and dimensions."&lt;/li&gt;
&lt;li&gt;This moves accuracy from roughly a third of questions to the 85–95% range in real deployments — and lets the system admit when it doesn't know.&lt;/li&gt;
&lt;li&gt;Start small, assign owners, register synonyms, and treat definitions as code, not documentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're bolting an AI assistant onto your database, the highest-leverage thing you can build isn't a better prompt — it's a place where "revenue" means exactly one thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How are you handling this?&lt;/strong&gt; Are you defining metrics in dbt, a dedicated semantic layer, or still shipping definitions inside prompts? Drop your approach in the comments — I'd love to hear what's actually holding up in production, and what tools you reach for to expose these metrics to non-technical teammates.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://docs.getdbt.com/blog/semantic-layer-vs-text-to-sql-2026" rel="noopener noreferrer"&gt;dbt: Semantic Layer vs. Text-to-SQL 2026 benchmark&lt;/a&gt;, &lt;a href="https://omni.co/blog/why-text-to-sql-fails" rel="noopener noreferrer"&gt;Omni: Why text-to-SQL fails&lt;/a&gt;, &lt;a href="https://medium.com/wrenai/why-the-semantic-layer-is-essential-for-reliable-text-to-sql-and-how-wren-ai-brings-it-to-life-c54cc0e6e4bc" rel="noopener noreferrer"&gt;Wren AI: Why the semantic layer is essential for reliable text-to-SQL&lt;/a&gt;, &lt;a href="https://www.getdbt.com/blog/how-the-dbt-semantic-layer-works" rel="noopener noreferrer"&gt;dbt Labs: How the dbt Semantic Layer works with MetricFlow&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Why Your Embedded Dashboards Are Slow (And the SQL Patterns That Fix Them)</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:43:01 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/why-your-embedded-dashboards-are-slow-and-the-sql-patterns-that-fix-them-486f</link>
      <guid>https://dev.to/vivekdraxlr/why-your-embedded-dashboards-are-slow-and-the-sql-patterns-that-fix-them-486f</guid>
      <description>&lt;p&gt;You shipped a customer-facing analytics page. It looked great in the demo with 500 rows of seed data. Then a real customer with 4 million &lt;code&gt;events&lt;/code&gt; logged in, opened the dashboard, and watched a spinner for 22 seconds before their browser tab quietly gave up.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth: when an embedded dashboard is slow, it is almost never the chart library, the network, or React. It's the SQL. You are asking your database to scan, join, and aggregate millions of rows &lt;em&gt;every single time&lt;/em&gt; someone opens a report — and most of those rows haven't changed since the last time you did it.&lt;/p&gt;

&lt;p&gt;This post walks through why that happens and the concrete SQL patterns that fix it, roughly in the order you should reach for them. We'll use plausible SaaS tables — &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;events&lt;/code&gt;, &lt;code&gt;subscriptions&lt;/code&gt; — and show the query, the problem, and the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem: you're recomputing everything, every time
&lt;/h2&gt;

&lt;p&gt;A typical dashboard tile runs 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="k"&gt;SELECT&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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&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;order_count&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_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'90 days'&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a customer with millions of orders, that means a full scan of the last 90 days of data on &lt;em&gt;every page load&lt;/em&gt;, for &lt;em&gt;every customer&lt;/em&gt;, refiring whenever someone changes a filter. The result — daily revenue for the last three months — barely changes minute to minute, but you pay the full computation cost again and again.&lt;/p&gt;

&lt;p&gt;The performance ladder below goes from cheapest fix to most involved. Most teams can stop after step 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Index for the filters your dashboards actually use
&lt;/h2&gt;

&lt;p&gt;Before anything fancy, make sure the database isn't scanning the whole table to answer a filtered query. Embedded dashboards almost always filter by tenant and a time range, so index for exactly that:&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_tenant_created&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the query above can jump straight to tenant 42's recent rows instead of reading everyone's history. Confirm it's working with &lt;code&gt;EXPLAIN ANALYZE&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;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'90 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;Seq Scan&lt;/code&gt; on a large table in the output, your index isn't being used. If you see an &lt;code&gt;Index Scan&lt;/code&gt; or &lt;code&gt;Bitmap Index Scan&lt;/code&gt;, you're on the right track. This one change alone often turns a multi-second query into a sub-100ms one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Pre-aggregate with a materialized view
&lt;/h2&gt;

&lt;p&gt;Indexing helps the database &lt;em&gt;find&lt;/em&gt; rows fast. But if the dashboard genuinely needs to aggregate millions of rows, the fastest possible aggregation is the one you did earlier and saved. That's a materialized view: a stored snapshot of a query's results that dashboards read directly.&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="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;daily_order_stats&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;tenant_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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&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;order_count&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_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue_cents&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&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;tenant_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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- required for concurrent refresh (more on that below)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_daily_order_stats&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;daily_order_stats&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your dashboard tile now reads from a table that already has one row per tenant per day:&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="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue_cents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;daily_order_stats&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'90 days'&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;day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the pattern behind the "28 seconds to 180 milliseconds" numbers people quote — you're reading a few hundred pre-summarized rows instead of scanning millions. The catch: the data is only as fresh as your last refresh.&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;-- rebuilds the snapshot; blocks reads unless you use CONCURRENTLY&lt;/span&gt;
&lt;span class="n"&gt;REFRESH&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;daily_order_stats&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;CONCURRENTLY&lt;/code&gt; lets dashboards keep reading during the refresh, but it &lt;em&gt;requires&lt;/em&gt; that unique index and is slower and more resource-hungry than a plain refresh. Schedule it with a cron job or &lt;code&gt;pg_cron&lt;/code&gt; every few minutes or hourly, depending on how fresh the numbers need to be. If your users can tolerate data being a few minutes behind — and for most analytics, they can — this is the single highest-leverage change you can make.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Roll up incrementally when refreshes get expensive
&lt;/h2&gt;

&lt;p&gt;Materialized views have a sharp edge: plain PostgreSQL rebuilds the &lt;em&gt;entire&lt;/em&gt; result on every refresh. There's no built-in incremental mode. If you have three years of history but only today's rows changed, you're recomputing three years of totals to capture one day of new data. As the table grows, the refresh itself becomes the bottleneck.&lt;/p&gt;

&lt;p&gt;The fix is a rollup table you update incrementally with an upsert, touching only the days that changed:&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;order_rollups&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt;   &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;day&lt;/span&gt;         &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;order_count&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;revenue_cents&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&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;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- run every N minutes; only recomputes recent days&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;order_rollups&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue_cents&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;tenant_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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&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;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;current_date&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;     &lt;span class="c1"&gt;-- yesterday + today only&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="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt;
  &lt;span class="n"&gt;order_count&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;revenue_cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;revenue_cents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the cost of keeping the dashboard fresh scales with &lt;em&gt;how much data changed&lt;/em&gt;, not with how much data you have. Rollup tables are more code than a materialized view, so reach for them only when refresh time becomes a real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Cache the final result for repeated views
&lt;/h2&gt;

&lt;p&gt;Even a fast query is wasteful if 200 users on the same team load the identical "last 30 days" tile within a minute. Put a cache in front of the query — Redis or Memcached — keyed by the query's inputs, with a short TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache_key = "dash:orders:tenant=42:range=30d"
value     = &amp;lt;serialized rows&amp;gt;, TTL = 300s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a cache hit you skip the database entirely. Invalidate on a time-to-live for simple cases, or on write events (a new order arrives) when you need tighter freshness. Caching is the cheapest layer conceptually, but put it &lt;em&gt;last&lt;/em&gt; — caching a query that's fundamentally doing a full-table scan just hides the problem until the cache misses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Do this instead&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Refreshing a materialized view without &lt;code&gt;CONCURRENTLY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Dashboards block and show errors during every refresh&lt;/td&gt;
&lt;td&gt;Add a unique index and use &lt;code&gt;REFRESH ... CONCURRENTLY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Refreshing far more often than the data changes&lt;/td&gt;
&lt;td&gt;Table and index bloat; refresh itself becomes the slow query&lt;/td&gt;
&lt;td&gt;Match refresh frequency to real freshness needs; vacuum after refreshes&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Full rebuilds on a huge history table&lt;/td&gt;
&lt;td&gt;Refresh time grows without bound&lt;/td&gt;
&lt;td&gt;Switch to an incremental rollup with &lt;code&gt;ON CONFLICT&lt;/code&gt; upserts&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Caching before indexing&lt;/td&gt;
&lt;td&gt;First load and every cache miss is still painfully slow&lt;/td&gt;
&lt;td&gt;Fix the query first, then cache&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Trying to serve truly real-time data from a materialized view&lt;/td&gt;
&lt;td&gt;Users see stale numbers and lose trust&lt;/td&gt;
&lt;td&gt;Use materialized views for near-real-time/historical; query live tables (well-indexed) for the few tiles that must be current&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The freshness trade-off deserves emphasis: materialized views and rollups are, by design, a little behind. That's perfect for "revenue over the last 90 days" and wrong for "orders in the last 60 seconds." Know which tiles need which, and don't pay for real-time where nobody needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Slow embedded dashboards are a data-access problem, not a front-end one. Work up the ladder: index for the tenant-plus-time-range filters your dashboards actually use; pre-aggregate with a materialized view so you read summarized rows instead of scanning raw ones; move to incremental rollup tables when full refreshes get expensive; and cache the finished result for repeated views. Most teams get the win they need from the first two steps, and each step buys you a little staleness in exchange for a lot of speed — a trade almost every analytics tile is happy to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;What's the slowest tile in your product right now, and which layer of this ladder haven't you tried yet? If you're building customer-facing analytics, tools like &lt;a href="https://www.draxlr.com" rel="noopener noreferrer"&gt;Draxlr&lt;/a&gt; let you build SQL dashboards, add pre-aggregated metrics, and embed them into your app without hand-rolling every one of these layers yourself. Drop a comment with the query pattern that finally fixed your slowest dashboard — I'd love to see the before-and-after numbers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://www.metabase.com/learn/grow-your-data-skills/data-landscape/sql-performance-tuning" rel="noopener noreferrer"&gt;Metabase — SQL performance tuning&lt;/a&gt;, &lt;a href="https://stormatics.tech/blogs/postgresql-materialized-views-when-caching-your-query-results-makes-sense" rel="noopener noreferrer"&gt;Stormatics — PostgreSQL Materialized Views&lt;/a&gt;, &lt;a href="https://www.citusdata.com/blog/2018/10/31/materialized-views-vs-rollup-tables/" rel="noopener noreferrer"&gt;Citus Data — Materialized views vs. Rollup tables in Postgres&lt;/a&gt;, &lt;a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html" rel="noopener noreferrer"&gt;PostgreSQL docs — REFRESH MATERIALIZED VIEW&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>postgres</category>
      <category>analytics</category>
    </item>
    <item>
      <title>How to Build a Self-Serve Reporting Tool Your Team Actually Uses</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Mon, 10 Aug 2026 06:40:11 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/how-to-build-a-self-serve-reporting-tool-your-team-actually-uses-12hc</link>
      <guid>https://dev.to/vivekdraxlr/how-to-build-a-self-serve-reporting-tool-your-team-actually-uses-12hc</guid>
      <description>&lt;p&gt;It usually starts with one Slack message: &lt;em&gt;"Hey, can you pull how many signups we got last week?"&lt;/em&gt; You write the query in thirty seconds, paste the number, and move on. Then it happens again. And again. Soon you're the human API for your own database, and every "quick pull" is a context switch that eats your afternoon.&lt;/p&gt;

&lt;p&gt;The fix isn't hiring an analyst or buying a six-figure BI suite. It's building a &lt;strong&gt;self-serve reporting tool&lt;/strong&gt;: a thin layer over your existing SQL database that lets non-engineers answer their own questions safely, without you in the loop and without anyone accidentally running a &lt;code&gt;DELETE&lt;/code&gt; on production.&lt;/p&gt;

&lt;p&gt;This post walks through how to actually build one — the read-only foundation, a schema for saved and parameterized queries, the SQL patterns that keep it fast and safe, and the governance mistakes that quietly erode trust in the numbers. By the end you'll have a blueprint you can ship in a sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "self-serve" really means
&lt;/h2&gt;

&lt;p&gt;Self-serve doesn't mean handing everyone a raw SQL console and hoping for the best. It means giving people a curated, parameterized set of reports they can run, filter, and schedule on their own — grounded in queries you've already vetted.&lt;/p&gt;

&lt;p&gt;There are three layers to get right:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Access&lt;/strong&gt; — a read-only path to the data that can never mutate or lock up production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A query catalog&lt;/strong&gt; — vetted, reusable queries with typed parameters (date ranges, plan tiers, customer IDs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Governance&lt;/strong&gt; — one agreed definition per metric, so "active user" means the same thing everywhere.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skip any one of these and the tool either becomes dangerous, useless, or — worst of all — &lt;em&gt;plausible but wrong&lt;/em&gt;. Let's build all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Give reporting its own read-only door
&lt;/h2&gt;

&lt;p&gt;The single most important decision: reporting traffic should never touch your primary write database directly. A heavy &lt;code&gt;GROUP BY&lt;/code&gt; over a year of events can lock rows and slow down real users. Point reporting at a &lt;strong&gt;read replica&lt;/strong&gt; instead, and connect with a &lt;strong&gt;read-only role&lt;/strong&gt; so a bad query can't do damage.&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;-- Create a role that can only read from the reporting schema&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;reporting_ro&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'change_me'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;CONNECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;reporting_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;reporting_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Read-only on everything that exists now...&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;reporting_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- ...and everything created later&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;reporting_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two extra guardrails worth adding on that role:&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;-- Kill runaway queries after 30 seconds&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;reporting_ro&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Cap how much work a single query can do before it bails&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;reporting_ro&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;work_mem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'64MB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the worst thing a self-serve user can do is run a slow query that gets killed automatically. Managed databases (Azure SQL, RDS, Cloud SQL) all support read replicas and read-only routing, so you rarely have to build this plumbing yourself — you just point your reporting connection string at the replica endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Model the report catalog
&lt;/h2&gt;

&lt;p&gt;The heart of a self-serve tool is a catalog of &lt;strong&gt;saved queries&lt;/strong&gt; with &lt;strong&gt;typed parameters&lt;/strong&gt;. Store them in their own small schema so the tool can list, render, and run them.&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;saved_reports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;slug&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- 'weekly-signups'&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;-- 'Weekly Signups by Plan'&lt;/span&gt;
  &lt;span class="n"&gt;description&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;sql_template&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;-- query with :placeholders&lt;/span&gt;
  &lt;span class="n"&gt;owner_email&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;report_params&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;report_id&lt;/span&gt;   &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;saved_reports&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;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&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="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;-- 'start_date'&lt;/span&gt;
  &lt;span class="n"&gt;data_type&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;-- 'date' | 'int' | 'text'&lt;/span&gt;
  &lt;span class="n"&gt;default_val&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A saved report is just a SQL template plus a typed parameter list. Here's what a stored template looks like:&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;-- sql_template for 'weekly-signups'&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;'week'&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;created_at&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;week&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;plan_tier&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;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&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;JOIN&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;start_date&lt;/span&gt;
  &lt;span class="k"&gt;AND&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;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;end_date&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;weekly-signups&lt;/code&gt; with &lt;code&gt;start_date = '2026-07-01'&lt;/code&gt; and &lt;code&gt;end_date = '2026-07-15'&lt;/code&gt; returns something like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;week&lt;/th&gt;
&lt;th&gt;plan_tier&lt;/th&gt;
&lt;th&gt;signups&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;2026-06-29&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;td&gt;412&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-06-29&lt;/td&gt;
&lt;td&gt;pro&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-06&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;td&gt;377&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2026-07-06&lt;/td&gt;
&lt;td&gt;pro&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your users never see the SQL. They see a title, a couple of dropdowns and date pickers, and a "Run" button. That's the whole magic of self-serve: the hard part is done once, by you, and reused forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Bind parameters safely — never concatenate strings
&lt;/h2&gt;

&lt;p&gt;This is where homegrown tools get dangerous. If you build the final query by gluing user input onto a string, you've just built a SQL injection vector into your own reporting layer. Always pass parameters through your driver's binding, never string interpolation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# GOOD — parameters are bound by the driver, not concatenated
&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql_template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:start_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%(start_date)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:end_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%(end_date)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_end&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# BAD — one comment character away from disaster
&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;... WHERE created_at &amp;gt;= &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_start&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;  &lt;span class="c1"&gt;# never do this
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the connection uses the &lt;code&gt;reporting_ro&lt;/code&gt; role, even a query that slips through can only &lt;em&gt;read&lt;/em&gt;. Defense in depth: safe binding &lt;em&gt;and&lt;/em&gt; a powerless role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Enforce one definition per metric
&lt;/h2&gt;

&lt;p&gt;Here's the mistake that kills trust faster than any bug: two reports that both claim to show "active users" and return different numbers. The moment a founder sees &lt;code&gt;1,204&lt;/code&gt; in one dashboard and &lt;code&gt;1,187&lt;/code&gt; in another, they stop believing all of them.&lt;/p&gt;

&lt;p&gt;The fix is to define each metric &lt;strong&gt;once&lt;/strong&gt;, in a database view, and have every report build on the view instead of re-deriving the logic.&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;VIEW&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&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;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;occurred_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'28 days'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;event_name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_open'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api_call'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now any report that needs "active users" joins to &lt;code&gt;active_users&lt;/code&gt;. Change the definition in one place and every report updates together. This is a lightweight version of what the analytics world calls a &lt;strong&gt;semantic layer&lt;/strong&gt; — the practice of mapping business terms to governed, single-source definitions. You don't need a fancy product to start; a handful of well-named views gets you 80% of the value.&lt;/p&gt;

&lt;p&gt;The industry consensus in 2026 is blunt about this: &lt;em&gt;a natural-language or AI layer does not fix inconsistent metrics — it amplifies them.&lt;/em&gt; If your underlying definitions disagree, adding "ask a question in English" on top just produces confident, wrong answers faster. Get the definitions right first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes that quietly break your tool
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Querying the primary DB&lt;/td&gt;
      &lt;td&gt;Reports lock rows and slow down real users&lt;/td&gt;
      &lt;td&gt;Point reporting at a read replica&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;String-concatenated params&lt;/td&gt;
      &lt;td&gt;SQL injection in your own tool&lt;/td&gt;
      &lt;td&gt;Bind parameters via the driver&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Duplicated metric logic&lt;/td&gt;
      &lt;td&gt;Two reports, two different "truths"&lt;/td&gt;
      &lt;td&gt;One view per metric, reused everywhere&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;No query timeout&lt;/td&gt;
      &lt;td&gt;One heavy report ties up the replica&lt;/td&gt;
      &lt;td&gt;Set &lt;code&gt;statement_timeout&lt;/code&gt; on the role&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Defining everything up front&lt;/td&gt;
      &lt;td&gt;Months of modeling, nobody uses it&lt;/td&gt;
      &lt;td&gt;Ship 5 reports people asked for, iterate&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last one deserves emphasis. The most common failure mode of self-serve projects isn't technical — it's over-engineering. Teams try to model every possible metric before launch, spend a quarter on it, and ship something no one asked for. Start with the five queries you're &lt;em&gt;already&lt;/em&gt; being pinged for on Slack. Turn those into saved reports. Add more only when someone asks twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add scheduling once the basics work
&lt;/h2&gt;

&lt;p&gt;Once your catalog exists, the highest-leverage feature to add next is &lt;strong&gt;scheduled delivery&lt;/strong&gt; — email the "Weekly Signups" report to the founder every Monday at 8am so they never have to ask. A tiny &lt;code&gt;report_schedules&lt;/code&gt; table plus a cron job that runs the query and sends the results covers the 90% case.&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;report_schedules&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;report_id&lt;/span&gt;    &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;saved_reports&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;cron&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- '0 8 * * 1'&lt;/span&gt;
  &lt;span class="n"&gt;recipients&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- ['founder@acme.io']&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;       &lt;span class="n"&gt;JSONB&lt;/span&gt;                   &lt;span class="c1"&gt;-- frozen parameter values&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the number finds &lt;em&gt;them&lt;/em&gt;, and the Slack pings stop for good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Building a self-serve reporting tool is far less work than it sounds, because the database does most of the heavy lifting. The blueprint: a read replica plus a read-only role for a safe foundation, a small catalog of saved queries with typed parameters so non-engineers can filter without writing SQL, bound parameters to stay injection-proof, and one view per metric so everyone shares the same numbers. Ship the five reports people already ask for, then let real usage tell you what to build next.&lt;/p&gt;

&lt;p&gt;Do that, and you go from being the human query API to shipping a tool that quietly answers the questions before anyone has to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;How does your team handle ad-hoc data requests today — a dedicated analyst, a shared query doc, a full BI platform, or something homegrown? And what's the one report your team asks for over and over? Drop it in the comments — I'm curious how many of us are secretly running the same "human API" job. If you've built something like this (or use a tool like Metabase, Lightdash, or Draxlr to skip the plumbing), share what worked and what you'd do differently.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>analytics</category>
      <category>productivity</category>
    </item>
    <item>
      <title>From Raw Tables to Business Insights: A SQL Workflow Every SaaS Team Can Use</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Sat, 08 Aug 2026 07:59:39 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/from-raw-tables-to-business-insights-a-sql-workflow-every-saas-team-can-use-3cae</link>
      <guid>https://dev.to/vivekdraxlr/from-raw-tables-to-business-insights-a-sql-workflow-every-saas-team-can-use-3cae</guid>
      <description>&lt;p&gt;You add a &lt;code&gt;subscriptions&lt;/code&gt; table, an &lt;code&gt;events&lt;/code&gt; table, a handful of &lt;code&gt;orders&lt;/code&gt;. Six months later someone on your team asks "what's our MRR trend?" and three people write three different queries against the raw tables. Each one joins slightly differently, filters test accounts differently (or not at all), and produces a different number. Now there are three "correct" answers in three Slack threads, and nobody trusts the dashboard anymore.&lt;/p&gt;

&lt;p&gt;This isn't a tooling problem. It's a workflow problem. Querying raw production tables directly for reporting works fine for one query, one time. It falls apart the moment more than one person needs the same answer, or the same question gets asked twice a week.&lt;/p&gt;

&lt;p&gt;The fix is a layered SQL workflow — raw data, cleaned staging views, and business-level marts — that data teams have used for years and that fits perfectly on top of a single Postgres or MySQL database, no data warehouse required. Here's how to build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why querying raw tables directly breaks down
&lt;/h2&gt;

&lt;p&gt;Production tables are shaped for your application, not for reporting. A few concrete problems show up almost immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grain confusion.&lt;/strong&gt; Does one row in &lt;code&gt;orders&lt;/code&gt; mean one order, or one line item? Aggregate the wrong way and your revenue numbers are silently wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent filtering.&lt;/strong&gt; Test accounts, soft-deleted rows, and internal team usage sneak into totals unless every single query remembers to exclude them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicated logic.&lt;/strong&gt; "Active subscription" gets defined five different ways across five dashboards, because the definition lives inside each query instead of in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fragile joins.&lt;/strong&gt; An &lt;code&gt;INNER JOIN&lt;/code&gt; between &lt;code&gt;events&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt; silently drops any event from a user missing profile data — nobody notices until the numbers don't add up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic problems. They're what happens when reporting logic has no home of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-layer workflow
&lt;/h2&gt;

&lt;p&gt;The pattern — often called medallion architecture in the data engineering world (bronze/silver/gold) — maps cleanly onto three layers you can build with nothing but SQL views:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Raw&lt;/td&gt;
&lt;td&gt;Your application tables, untouched&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;subscriptions&lt;/code&gt;, &lt;code&gt;events&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;td&gt;Cleaned, renamed, de-duplicated, type-cast&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stg_subscriptions&lt;/code&gt;, &lt;code&gt;stg_events&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Marts&lt;/td&gt;
&lt;td&gt;Business-level aggregates, one definition per metric&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mart_mrr_daily&lt;/code&gt;, &lt;code&gt;mart_dau&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You don't need dbt or a warehouse to do this — plain SQL views (or materialized views if refresh speed matters) are enough for most SaaS-scale databases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Raw layer — leave it alone
&lt;/h3&gt;

&lt;p&gt;Don't transform anything here. This is your source of truth exactly as your application wrote it. If a bug corrupts staging, you can always rebuild from raw.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Staging — clean once, reuse everywhere
&lt;/h3&gt;

&lt;p&gt;Staging views do the boring, repetitive cleanup so nobody has to do it twice:&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;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;stg_subscriptions&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subscription_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&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;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;amount_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;started_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;canceled_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;canceled_on&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;test_accounts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;stg_events&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event_id&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;event_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every dashboard and every teammate now queries &lt;code&gt;stg_subscriptions&lt;/code&gt;, not &lt;code&gt;subscriptions&lt;/code&gt;. Test accounts are already excluded. Amounts are already in dollars, not cents. Nobody re-derives this logic in five different places.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Marts — one definition per metric
&lt;/h3&gt;

&lt;p&gt;Marts answer specific business questions, built on top of staging:&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;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;mart_mrr_daily&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;day&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_usd&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;mrr&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started_on&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stg_subscriptions&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="k"&gt;current_date&lt;/span&gt;&lt;span class="p"&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;AS&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;stg_subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&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;started_on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;
  &lt;span class="k"&gt;AND&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;canceled_on&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;OR&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;canceled_on&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;day&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;mart_dau&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;event_date&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="k"&gt;DISTINCT&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;AS&lt;/span&gt; &lt;span class="n"&gt;dau&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stg_events&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;event_date&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;event_date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "MRR" and "DAU" have exactly one definition each, living in the database instead of scattered across BI tool configs and one-off scripts. Anyone — including an embedded dashboard or an AI query assistant — can hit &lt;code&gt;mart_mrr_daily&lt;/code&gt; and get the same number every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declare the grain before you write a single aggregate
&lt;/h2&gt;

&lt;p&gt;Before building any mart, write down what one row represents. "One row per order" and "one row per order line item" are both reasonable grains for an &lt;code&gt;orders&lt;/code&gt;-adjacent table, but summing revenue over the wrong one either doubles or under-counts it. A one-line comment above the view — &lt;code&gt;-- grain: one row per calendar day per active subscription&lt;/code&gt; — costs nothing and saves the next person (often future you) from re-deriving your logic by trial and error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automate the refresh
&lt;/h2&gt;

&lt;p&gt;Views recalculate on every query, which is fine until the underlying tables get large. Two common upgrades:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Materialized views&lt;/strong&gt; refreshed on a schedule (&lt;code&gt;REFRESH MATERIALIZED VIEW mart_mrr_daily&lt;/code&gt;) for marts that don't need to be second-fresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small nightly job&lt;/strong&gt; (cron, Airflow, or even a Postgres &lt;code&gt;pg_cron&lt;/code&gt; job) that rebuilds marts after the day's data has settled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either way, the mart layer becomes the stable interface everything downstream — dashboards, embedded analytics, scheduled reports — reads from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Building the dashboard before the model.&lt;/strong&gt; It's tempting to point a chart straight at raw tables to move fast. The dashboard becomes the source of truth, its logic invisible and untested, and untangling it later costs far more time than modeling up front would have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing grains in one table.&lt;/strong&gt; A &lt;code&gt;kpi_metrics&lt;/code&gt; table with daily rows, weekly rollups, and monthly summaries jammed together is a bug generator — always split by grain or make the grain an explicit column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent &lt;code&gt;INNER JOIN&lt;/code&gt; drops.&lt;/strong&gt; Defaulting to &lt;code&gt;LEFT JOIN&lt;/code&gt; in staging views keeps your source-of-truth counts complete; switch to &lt;code&gt;INNER JOIN&lt;/code&gt; deliberately, only when you actually want to filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping staging "to save time."&lt;/strong&gt; The cleanup work doesn't disappear — it just moves into every dashboard and script that touches raw data, duplicated N times instead of written once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No ownership of metric definitions.&lt;/strong&gt; If "active user" can mean three different things depending on who wrote the query, put the definition in a mart and make that the only place it's allowed to live.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Raw tables are for your application, not your reports. A thin staging layer removes repetitive cleanup, and a mart layer gives every metric exactly one definition that dashboards, embedded analytics, and even AI-generated queries can rely on. None of this requires a data warehouse or a new platform — just views, a bit of discipline about grain, and a refresh strategy that matches how fresh your numbers actually need to be.&lt;/p&gt;

&lt;p&gt;What does your reporting stack look like today — do you model before you dashboard, or query raw tables directly and clean up the mess later? Drop your approach (or your favorite gotcha) in the comments.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>analytics</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why AI Keeps Inventing Columns That Don't Exist (and How to Stop It)</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Fri, 31 Jul 2026 06:50:03 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/why-ai-keeps-inventing-columns-that-dont-exist-and-how-to-stop-it-2h9h</link>
      <guid>https://dev.to/vivekdraxlr/why-ai-keeps-inventing-columns-that-dont-exist-and-how-to-stop-it-2h9h</guid>
      <description>&lt;p&gt;You ask an AI assistant for "total revenue by customer last month," it hands back a clean-looking query, you run it, and Postgres throws:&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="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="nv"&gt;"c.total_revenue"&lt;/span&gt; &lt;span class="n"&gt;does&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;exist&lt;/span&gt;
&lt;span class="n"&gt;LINE&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;SUM&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;total_revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query was confident. It was syntactically perfect. It was also referencing a column that has never existed in your schema. Welcome to the single most common failure mode of AI-generated SQL: the &lt;strong&gt;schema hallucination&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you've spent any time using LLMs to write queries, you've hit this. The model invents a &lt;code&gt;total_revenue&lt;/code&gt; column, joins to an &lt;code&gt;accounts&lt;/code&gt; table you don't have, or assumes your &lt;code&gt;orders&lt;/code&gt; table has a &lt;code&gt;status&lt;/code&gt; column when yours calls it &lt;code&gt;state&lt;/code&gt;. It's frustrating precisely because everything &lt;em&gt;looks&lt;/em&gt; right. In this article I'll explain why this happens under the hood, and give you a concrete playbook to stop it — whether you're pasting schemas into ChatGPT or building a text-to-SQL feature into your own product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI hallucinates tables and columns
&lt;/h2&gt;

&lt;p&gt;Large language models don't "know" your database. They generate SQL the same way they generate prose: by predicting the most statistically likely next token based on patterns seen during training. When you ask for revenue, the model has seen thousands of tutorials with a &lt;code&gt;total_revenue&lt;/code&gt; column, so it confidently reaches for that name — even though your actual schema stores it as &lt;code&gt;amount_cents&lt;/code&gt; on an &lt;code&gt;invoices&lt;/code&gt; table.&lt;/p&gt;

&lt;p&gt;There are a few distinct root causes worth naming, because each has a different fix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;What it looks like&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;No schema grounding&lt;/td&gt;
&lt;td&gt;The model never saw your real tables, so it guesses common names&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Training-data over-generalization&lt;/td&gt;
&lt;td&gt;It reaches for "textbook" column names like &lt;code&gt;created_date&lt;/code&gt; instead of your &lt;code&gt;created_at&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Underspecified questions&lt;/td&gt;
&lt;td&gt;Vague prompts ("show me active users") invite the model to invent an &lt;code&gt;is_active&lt;/code&gt; flag&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Complex/ambiguous design&lt;/td&gt;
&lt;td&gt;Multiple tables with similar columns confuse which one the model should use&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important insight: a hallucinated column is &lt;em&gt;syntactically valid but semantically misaligned&lt;/em&gt; with your actual schema. The model isn't broken — it's doing exactly what it was designed to do with insufficient information. Which means the fix is almost always about &lt;strong&gt;feeding it better context&lt;/strong&gt;, not about finding a smarter model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #1: Ground the model in your real schema
&lt;/h2&gt;

&lt;p&gt;The number one cause of hallucinations is that the model is guessing at names it was never told. So tell it. Instead of asking "write me a query for revenue by customer," give it the actual DDL:&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;-- Paste this before your question&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&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;BIGINT&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;company_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;created_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&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;BIGINT&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;customer_id&lt;/span&gt;  &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;customers&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;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- 'paid', 'open', 'void'&lt;/span&gt;
  &lt;span class="n"&gt;issued_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in context, the model now produces:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;company_name&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue_dollars&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issued_at&lt;/span&gt; &lt;span class="o"&gt;&amp;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;now&lt;/span&gt;&lt;span class="p"&gt;())&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 month'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issued_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&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;company_name&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;revenue_dollars&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice it used &lt;code&gt;amount_cents&lt;/code&gt; and &lt;code&gt;issued_at&lt;/code&gt; — your real column names — because you removed the guesswork. If you're building this into an app, you can pull the schema programmatically:&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="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&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;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed that output into your prompt as structured context and hallucination rates drop dramatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #2: Add comments and metadata for ambiguous columns
&lt;/h2&gt;

&lt;p&gt;Column names are often cryptic, and a model can't tell that &lt;code&gt;state&lt;/code&gt; means order status versus a US state. Annotate them. Comments in the DDL you provide double as documentation the model reads:&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;orders&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;BIGINT&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="k"&gt;state&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- order lifecycle: 'cart','placed','shipped','delivered','refunded'&lt;/span&gt;
  &lt;span class="n"&gt;total&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="c1"&gt;-- order total in cents, NOT dollars&lt;/span&gt;
  &lt;span class="n"&gt;placed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-- order total in cents&lt;/code&gt; comment alone prevents the classic bug where the model forgets to divide by 100. For ambiguous business terms — what counts as an "active" customer, how "MRR" is calculated — spell out the definition. The model can't infer your business logic; it can only work with what you give it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #3: Use a semantic layer instead of raw tables
&lt;/h2&gt;

&lt;p&gt;For a production text-to-SQL feature, exposing raw tables to an LLM is asking for trouble. A better pattern is a &lt;strong&gt;semantic layer&lt;/strong&gt;: a curated set of views or defined metrics that map messy physical tables to clean business concepts. The model maps the user's intent onto this safe, well-named layer instead of navigating raw schema.&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;VIEW&lt;/span&gt; &lt;span class="n"&gt;revenue_by_customer&lt;/span&gt; &lt;span class="k"&gt;AS&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;company_name&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issued_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;month&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&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="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;Now a question like "revenue for Acme last month" maps to a trivially correct query against &lt;code&gt;revenue_by_customer&lt;/code&gt;. There are fewer tables, cleaner names, and no room to hallucinate a join. As a bonus, the view enforces your business rules (only &lt;code&gt;paid&lt;/code&gt; invoices count) so the AI can't accidentally include voided ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #4: Give the model examples (few-shot prompting)
&lt;/h2&gt;

&lt;p&gt;Models are far more accurate when shown a couple of correct question-to-SQL pairs from &lt;em&gt;your&lt;/em&gt; schema. This is called few-shot prompting, and research on approaches like DAIL-SQL shows curated examples meaningfully improve accuracy. Include two or three representative pairs in your prompt:&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="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;How&lt;/span&gt; &lt;span class="n"&gt;many&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;did&lt;/span&gt; &lt;span class="n"&gt;we&lt;/span&gt; &lt;span class="n"&gt;ship&lt;/span&gt; &lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;
     &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_date&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;Q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Top&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="n"&gt;A&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;company_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
   &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;revenue_by_customer&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="o"&gt;&amp;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;'year'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&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;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These examples teach the model your naming conventions, your preferred date handling, and which views to prefer — all without a single fine-tuning run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #5: Validate before you run
&lt;/h2&gt;

&lt;p&gt;Even with perfect grounding, treat generated SQL as untrusted input. The cheapest safety net is to validate column and table references against the real schema &lt;em&gt;before&lt;/em&gt; execution. In Postgres you can dry-run without touching data using &lt;code&gt;EXPLAIN&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;EXPLAIN&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;company_name&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- hallucinated column&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;company_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR: column i.total_revenue does not exist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;EXPLAIN&lt;/code&gt; fails, you've caught the hallucination without running anything. Feed the error message back to the model and let it self-correct — this validate-and-retry loop is one of the most effective mitigations in practice. Always run generated queries as a &lt;strong&gt;read-only role&lt;/strong&gt; so a hallucinated &lt;code&gt;DELETE&lt;/code&gt; or &lt;code&gt;DROP&lt;/code&gt; can never do damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;th&gt;How to avoid it&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Stale schema in the prompt&lt;/td&gt;
&lt;td&gt;Regenerate schema context on each run; don't hardcode it once&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Silent wrong answers&lt;/td&gt;
&lt;td&gt;A query can be valid SQL yet use the wrong column — spot-check results against known numbers&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Cents vs. dollars&lt;/td&gt;
&lt;td&gt;Document units in column comments; it's the most common "looks right, is wrong" bug&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Ambiguous joins&lt;/td&gt;
&lt;td&gt;Provide foreign keys in the DDL so the model doesn't invent join conditions&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Over-trusting big models&lt;/td&gt;
&lt;td&gt;A smarter model hallucinates less, but never zero — keep the validation layer&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trickiest failures aren't the ones that error out — those are easy. The dangerous ones are queries that run cleanly but quietly use the wrong table or forget a &lt;code&gt;WHERE&lt;/code&gt; filter, returning a plausible-but-wrong number that ends up in a dashboard. That's why validation and result spot-checking matter as much as grounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Schema hallucination happens because the model is pattern-matching against training data instead of your actual database. The fix is context, not a bigger model. Ground every prompt in your real DDL, annotate ambiguous columns with comments, expose a clean semantic layer of views rather than raw tables, show the model a few correct examples, and always validate generated SQL against the schema before running it as a read-only user. Do those five things and "column does not exist" mostly disappears from your life.&lt;/p&gt;

&lt;p&gt;Have you built text-to-SQL into a product, or are you still copy-pasting schemas into a chat window? What's tripped you up the most — hallucinated columns, wrong joins, or the silent-wrong-answer problem? Drop your war stories in the comments. And if you've found a grounding or validation trick that works, I'd love to hear it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://www.getwren.ai/post/reducing-hallucinations-in-text-to-sql-building-trust-and-accuracy-in-data-access" rel="noopener noreferrer"&gt;Reducing Hallucinations in Text-to-SQL (Wren AI)&lt;/a&gt;, &lt;a href="https://medium.com/@DataFocus_Cloud/deep-dive-into-text-to-sql-hallucinations-in-large-language-models-challenges-impact-and-c2fecd644c57" rel="noopener noreferrer"&gt;Deep Dive into Text-to-SQL Hallucinations (DataFocus)&lt;/a&gt;, &lt;a href="https://pub.towardsai.net/improving-text-to-sql-accuracy-with-schema-aware-reasoning-528eadfdc99b" rel="noopener noreferrer"&gt;Improving Text-to-SQL Accuracy with Schema-Aware Reasoning (Towards AI)&lt;/a&gt;, &lt;a href="https://arxiv.org/html/2405.15307v1" rel="noopener noreferrer"&gt;Before Generation, Align it! (arXiv)&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How to Safely Expose Database Insights to Non-Technical Users</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Thu, 30 Jul 2026 07:04:44 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/how-to-safely-expose-database-insights-to-non-technical-users-54gp</link>
      <guid>https://dev.to/vivekdraxlr/how-to-safely-expose-database-insights-to-non-technical-users-54gp</guid>
      <description>&lt;p&gt;Every growing SaaS company hits the same wall. Your ops lead wants to know how many trials converted last week. Your support manager wants a breakdown of tickets by plan. Your CEO wants MRR by cohort. And all of those questions land in the same place: the one or two engineers who actually know the schema.&lt;/p&gt;

&lt;p&gt;So you become a human query API. Someone Slacks you "can you pull X?", you context-switch, write a &lt;code&gt;SELECT&lt;/code&gt;, paste a screenshot, and go back to what you were doing. Multiply that by five teammates and you've lost half a day to being a reporting middleman.&lt;/p&gt;

&lt;p&gt;The obvious fix — "just give them database access" — is a trap. Non-technical users don't want a &lt;code&gt;psql&lt;/code&gt; prompt, and even if they did, handing out raw credentials to production is how you end up with a &lt;code&gt;DELETE&lt;/code&gt; that forgot its &lt;code&gt;WHERE&lt;/code&gt;, or a support rep who can suddenly read every customer's payment token. This article walks through how to give non-technical people real, self-serve access to insights &lt;strong&gt;without&lt;/strong&gt; giving them the keys to blow up your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a read-only role, always
&lt;/h2&gt;

&lt;p&gt;The single most important control is boring: a database role that can only read. Not "we trust them," not "they promised to be careful" — a role that is physically incapable of writing.&lt;/p&gt;

&lt;p&gt;In PostgreSQL, you build it up from nothing:&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;-- 1. Create a login role for analytics&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'use-a-real-secret'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Let it connect and see the schema, but nothing more&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;CONNECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;app_production&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Read-only on existing tables&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. And on tables you create in the future&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last step trips up almost everyone. &lt;code&gt;GRANT SELECT ON ALL TABLES&lt;/code&gt; only covers tables that exist &lt;em&gt;right now&lt;/em&gt;. Ship a new &lt;code&gt;invoices&lt;/code&gt; table next month and your analytics role can't see it. &lt;code&gt;ALTER DEFAULT PRIVILEGES&lt;/code&gt; fixes that going forward.&lt;/p&gt;

&lt;p&gt;A common gotcha worth calling out: PostgreSQL's permission layers stack, and a higher layer can quietly block a lower one. If &lt;code&gt;analytics_ro&lt;/code&gt; has &lt;code&gt;SELECT&lt;/code&gt; on a table but not &lt;code&gt;USAGE&lt;/code&gt; on the schema that contains it, every query fails with a confusing permission error. Grant schema &lt;code&gt;USAGE&lt;/code&gt; first, then table privileges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hide the columns that should never leave the database
&lt;/h2&gt;

&lt;p&gt;Read-only stops writes, but it doesn't stop a support rep from running &lt;code&gt;SELECT * FROM users&lt;/code&gt; and seeing password hashes, API keys, or PII they have no business seeing. Read-only is not the same as safe-to-read.&lt;/p&gt;

&lt;p&gt;PostgreSQL supports column-level grants, so you can be surgical:&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;-- Revoke the broad grant on the sensitive table&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Re-grant only the safe columns&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a query touching &lt;code&gt;password_hash&lt;/code&gt; or &lt;code&gt;stripe_customer_id&lt;/code&gt; fails outright, while the useful columns stay available. The catch is that &lt;code&gt;SELECT *&lt;/code&gt; will now error for this role — which is actually a feature, because it forces explicit column lists.&lt;/p&gt;

&lt;p&gt;For anything more complex than "hide a few columns," reach for a view. Views are the cleanest way to hand non-technical users a curated, friendly shape of the 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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_customers&lt;/span&gt; &lt;span class="k"&gt;AS&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;id&lt;/span&gt;            &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_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="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&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;status&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subscription_status&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;mrr_cents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mrr&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;created_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;signed_up_on&lt;/span&gt;
&lt;span class="k"&gt;FROM&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;JOIN&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;s&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_customers&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user queries &lt;code&gt;active_customers&lt;/code&gt; and never touches the underlying tables, the joins, or the sensitive columns. You've turned a messy schema into a clean, self-documenting reporting surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolate each customer's data with row-level security
&lt;/h2&gt;

&lt;p&gt;If your non-technical users are &lt;em&gt;your customers&lt;/em&gt; (embedded analytics), read-only and column grants aren't enough — you need to guarantee customer A can never see customer B's rows. That's what row-level security (RLS) is 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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application sets the tenant per session:&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;SET&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'42'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- From now on, every query on `orders` only returns tenant 42's rows,&lt;/span&gt;
&lt;span class="c1"&gt;-- even a bare SELECT * FROM orders.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The big win: isolation lives in the database, not in a &lt;code&gt;WHERE&lt;/code&gt; clause your app might forget. One missing filter in application code has leaked entire customer tables before. With RLS, the guarantee holds even if the query is careless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give them an interface, not a SQL prompt
&lt;/h2&gt;

&lt;p&gt;Locking down the data is half the job. The other half is that your ops lead is never going to write a window function. Non-technical users need a layer between them and SQL. There are three broad approaches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Prebuilt dashboards&lt;/td&gt;
&lt;td&gt;You write the queries once; they filter and view&lt;/td&gt;
&lt;td&gt;Recurring KPIs everyone watches&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Semantic layer&lt;/td&gt;
&lt;td&gt;You define metrics (MRR, churn) as reusable building blocks; users drag &amp;amp; drop&lt;/td&gt;
&lt;td&gt;Teams that ask varied but related questions&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;AI / text-to-SQL&lt;/td&gt;
&lt;td&gt;Users type "revenue by plan last quarter"; AI generates the SQL&lt;/td&gt;
&lt;td&gt;Open-ended, ad-hoc exploration&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Text-to-SQL is the newest and most exciting of the three, but it changes your security model rather than replacing it. When an AI writes queries on behalf of a user, the read-only role and column grants become &lt;em&gt;more&lt;/em&gt; important, not less — they're the guardrails that keep a hallucinated or over-broad query from doing damage. Point AI query tools at the restricted &lt;code&gt;analytics_ro&lt;/code&gt; role and the curated reporting views, never at a superuser connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick sanity checklist
&lt;/h2&gt;

&lt;p&gt;Before you hand any non-technical user (or AI agent) access to your data, run through this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Connection is read-only&lt;/td&gt;
&lt;td&gt;No writes, ever — physically enforced by the role&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Sensitive columns revoked or hidden behind views&lt;/td&gt;
&lt;td&gt;PII, secrets, and hashes never surface in a query&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Multi-tenant tables have RLS enabled&lt;/td&gt;
&lt;td&gt;Customers can't see each other's rows&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Query timeout set (e.g. &lt;code&gt;statement_timeout&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;A runaway &lt;code&gt;JOIN&lt;/code&gt; can't melt production&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Access points at a replica, not primary&lt;/td&gt;
&lt;td&gt;Heavy reporting queries don't slow down your app&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That fourth row deserves emphasis. A non-technical user doesn't know that a cross join over two big tables is expensive. Set a &lt;code&gt;statement_timeout&lt;/code&gt; on the analytics role so a bad query cancels itself instead of pinning your CPU:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;analytics_ro&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you can, run reporting against a read replica. That way even a perfectly legitimate but heavy dashboard refresh never competes with the queries your customers depend on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes to avoid
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Giving out the app's database user.&lt;/strong&gt; It's tempting to reuse the credentials your application already has. Don't — that role can write, and it can usually read everything. Always mint a dedicated, minimal role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relying on the frontend to hide data.&lt;/strong&gt; If sensitive columns are filtered out only in your dashboard UI, anyone who inspects a network request or exports raw data can still reach them. Enforce restrictions at the database layer where they can't be bypassed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting &lt;code&gt;ALTER DEFAULT PRIVILEGES&lt;/code&gt;.&lt;/strong&gt; New tables silently become invisible to your read-only role, and you'll waste an afternoon debugging "why can't they see the new report?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping the timeout.&lt;/strong&gt; The first time a non-technical user accidentally runs an unbounded query, you'll wish you'd set one. Do it upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;Safely exposing insights isn't about trust — it's about building a data access path where mistakes are structurally impossible. A dedicated read-only role stops writes. Column grants and reporting views hide what should stay hidden. Row-level security isolates tenants. A statement timeout and a read replica protect performance. And an interface — dashboards, a semantic layer, or AI text-to-SQL — meets non-technical users where they are.&lt;/p&gt;

&lt;p&gt;Get those layers right and you stop being a human query API. Your teammates answer their own questions, your data stays protected, and you get your afternoons back.&lt;/p&gt;

&lt;p&gt;How do you hand out data access at your company — read-only roles, a BI tool, an embedded dashboard, or something homegrown? Drop your setup (and your horror stories) in the comments. I'd love to hear what's worked and what's bitten you.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>analytics</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Build vs. Buy Embedded Analytics: A Decision Guide for SaaS Teams</title>
      <dc:creator>Vivek Kumar</dc:creator>
      <pubDate>Tue, 28 Jul 2026 04:08:33 +0000</pubDate>
      <link>https://dev.to/vivekdraxlr/build-vs-buy-embedded-analytics-a-decision-guide-for-saas-teams-2i52</link>
      <guid>https://dev.to/vivekdraxlr/build-vs-buy-embedded-analytics-a-decision-guide-for-saas-teams-2i52</guid>
      <description>&lt;p&gt;Every SaaS product eventually hits the same request: &lt;em&gt;"Can we get a dashboard for our data?"&lt;/em&gt; Your customers want to see their own orders, usage, revenue, and trends — inside your app, not exported to a spreadsheet.&lt;/p&gt;

&lt;p&gt;So you open a ticket, and the debate starts. Do we build the analytics ourselves with the database we already have, or do we buy an embedded analytics tool and drop it in? It sounds like a simple procurement question. It isn't. Pick wrong and you either sink two engineers into a reporting side-project for a year, or you pay a monthly bill for a tool that still needs three sprints of glue code before it's safe to ship.&lt;/p&gt;

&lt;p&gt;This post gives you a decision framework grounded in real costs, real SQL, and the multi-tenant gotchas that turn a "quick dashboard feature" into a security incident. By the end you'll know which path fits your product — and why the honest answer for most teams is somewhere in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, be honest about what you're building
&lt;/h2&gt;

&lt;p&gt;The word "dashboard" hides a lot of complexity. Before you can decide build vs. buy, you need to know what your customers actually need. There's a big difference between these three things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Static reporting&lt;/td&gt;
&lt;td&gt;A few fixed charts: revenue this month, active users, top products&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Interactive dashboards&lt;/td&gt;
&lt;td&gt;Filters, date ranges, drill-downs, per-customer views&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Self-serve analytics&lt;/td&gt;
&lt;td&gt;Customers build their own queries, charts, and saved reports&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most teams &lt;em&gt;say&lt;/em&gt; they want self-serve analytics and actually need interactive dashboards. Nail down the real requirement first, because it changes the math completely. Three fixed charts is a weekend of work on top of SQL you already know. A self-serve query builder is a product inside your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core question: is analytics your differentiator?
&lt;/h2&gt;

&lt;p&gt;Here's the filter that cuts through most of the debate:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Build what differentiates you. Buy everything else.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If analytics &lt;em&gt;is&lt;/em&gt; your product — you're a monitoring tool, a financial platform, a data-heavy vertical SaaS where the charts are the reason people pay — then dashboards are core IP and you should probably build. Your competitive edge lives in how you model and present data, and you don't want that outsourced.&lt;/p&gt;

&lt;p&gt;If analytics is a &lt;em&gt;supporting feature&lt;/em&gt; — reporting that makes your main workflow stickier — buying almost always wins. You'll ship in weeks instead of quarters, and your engineers stay focused on the thing customers actually pay for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cost comparison
&lt;/h2&gt;

&lt;p&gt;Teams wildly underestimate the build side because they price the first version and forget the next three years. Here's the honest picture that industry cost breakdowns converge on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Build in-house&lt;/th&gt;
&lt;th&gt;Buy a platform&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Time to first dashboard&lt;/td&gt;
&lt;td&gt;6–12 months&lt;/td&gt;
&lt;td&gt;4–8 weeks&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Year 1 cost&lt;/td&gt;
&lt;td&gt;~$180k–$310k (engineering)&lt;/td&gt;
&lt;td&gt;~$6k–$24k/yr + integration&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;3-year cost&lt;/td&gt;
&lt;td&gt;~$370k–$630k&lt;/td&gt;
&lt;td&gt;~$150k–$360k&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Ongoing maintenance&lt;/td&gt;
&lt;td&gt;15–25% of build cost / yr&lt;/td&gt;
&lt;td&gt;Included in subscription&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The number that surprises people is maintenance. A dashboard feature isn't "done" when it ships. Customers ask for new chart types, new filters, faster load times, CSV export, scheduled emails. Every one of those is a ticket that competes with your roadmap forever.&lt;/p&gt;

&lt;p&gt;That said — buying isn't free of engineering either. A "cheap" $500/month tool that requires three sprints to wire up proper tenant isolation isn't actually cheap. Which brings us to the part that bites everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that turns a feature into an incident: multi-tenancy
&lt;/h2&gt;

&lt;p&gt;If you build, this is where you'll spend your real time — not on charts, on making sure customer A never sees customer B's data. Most SaaS apps use a shared-schema model: one big table with a &lt;code&gt;tenant_id&lt;/code&gt; column.&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;-- Every analytics table carries the tenant boundary&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&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;orders&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_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt;   &lt;span class="c1"&gt;-- this filter is load-bearing&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'30 days'&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="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;WHERE tenant_id = $current_tenant&lt;/code&gt; clause looks trivial. It is the single most important line in your entire analytics feature. Miss it on one query — one forgotten join, one aggregate that rolls up across tenants — and you've leaked data.&lt;/p&gt;

&lt;p&gt;The dangerous version of this mistake is enforcing the boundary only in the UI. Filtering results in your frontend is &lt;strong&gt;not&lt;/strong&gt; security. If the query returned other tenants' rows, they already crossed the wire. Enforce isolation server-side, in the query, every time.&lt;/p&gt;

&lt;p&gt;The more robust pattern is Postgres Row-Level Security, so the database itself refuses to return the wrong rows even if a query forgets the filter:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Your app sets this per request, before running any query:&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'4172'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a query that forgets &lt;code&gt;WHERE tenant_id&lt;/code&gt; still can't leak — the policy scopes it automatically. This is exactly the kind of infrastructure a good embedded tool handles for you, and exactly what a "cheap" tool makes you build yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance: the second thing you'll discover in month six
&lt;/h2&gt;

&lt;p&gt;Tenant isolation and performance collide. The moment you add RLS or per-tenant filters, your query planner needs help. The fix is boring but non-negotiable: index the tenant boundary.&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;-- Composite index: tenant first, then the column you filter/sort by&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_tenant_created&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place, a per-tenant time-range query reads only that tenant's recent rows instead of scanning the whole table. Teams that skip this ship fine on day one with 10 customers, then watch dashboards crawl at 10,000. Verify it with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; and make sure you see an index scan, not a sequential scan:&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;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4172&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&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;'90 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Look for: "Index Scan using idx_orders_tenant_created"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For heavy aggregations that customers hit constantly, pre-compute with a materialized view and refresh on a schedule rather than recomputing on every page load:&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="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;tenant_daily_revenue&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tenant_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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&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_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&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;orders&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&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;tenant_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;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Refresh nightly (or hourly) instead of on every dashboard load&lt;/span&gt;
&lt;span class="n"&gt;REFRESH&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;tenant_daily_revenue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The hybrid path (why most teams end up here)
&lt;/h2&gt;

&lt;p&gt;The build-vs-buy debate is usually a false binary. The pattern that increasingly wins in 2025–2026 is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buy&lt;/strong&gt; the parts that are undifferentiated and dangerous to get wrong: the dashboard rendering, chart library, filter UI, and tenant-isolation plumbing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt; the parts that are your edge: the SQL that models &lt;em&gt;your&lt;/em&gt; domain, the metrics definitions, and any bespoke visualization your competitors don't have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice that looks like keeping your carefully-tuned SQL and semantic layer in-house, and letting an embedded analytics tool handle the rendering and the multi-tenant security boundary. You get to ship in weeks, keep control of your metrics, and not become a dashboard-maintenance company.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buying on demo polish.&lt;/strong&gt; The prettiest demo often hides weak multi-tenancy. Ask &lt;em&gt;how&lt;/em&gt; the tool isolates tenant data before you fall in love with the charts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforcing security in the UI.&lt;/strong&gt; Frontend filtering is not access control. If the query returned the row, it's already leaked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing only the first version.&lt;/strong&gt; Build costs are dominated by years two and three, not the launch. Budget for maintenance or don't build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the &lt;code&gt;tenant_id&lt;/code&gt; index.&lt;/strong&gt; It works at 10 customers and dies at 10,000. Index the boundary from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confusing "we could build it" with "we should build it."&lt;/strong&gt; You can build almost anything. The question is whether it's the best use of the only engineers you have.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;p&gt;The build-vs-buy decision comes down to one honest question: is analytics your differentiator, or a supporting feature? If it's core IP, build it and own it. If it's a feature that makes your product stickier, buy the rendering and isolation, and spend your engineering budget on the SQL and metrics that are actually yours. And whichever path you pick, treat multi-tenant isolation as a database-level guarantee — not a UI filter — because that's the line between a nice feature and a headline you don't want.&lt;/p&gt;

&lt;p&gt;What did your team choose, and what surprised you six months in? Did you build, buy, or land in the hybrid middle? Drop your experience — and the tools you're using — in the comments. I'd love to hear what held up under real load.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>webdev</category>
      <category>analytics</category>
    </item>
  </channel>
</rss>
