<?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: Malek Damiri</title>
    <description>The latest articles on DEV Community by Malek Damiri (@malek_damiri_68c6fecee5b7).</description>
    <link>https://dev.to/malek_damiri_68c6fecee5b7</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%2F4169722%2Ffc761bc6-38cf-45da-ad64-b510fb047331.png</url>
      <title>DEV Community: Malek Damiri</title>
      <link>https://dev.to/malek_damiri_68c6fecee5b7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/malek_damiri_68c6fecee5b7"/>
    <language>en</language>
    <item>
      <title>Teaching yourself SQL when your job is ops</title>
      <dc:creator>Malek Damiri</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:00:33 +0000</pubDate>
      <link>https://dev.to/malek_damiri_68c6fecee5b7/teaching-yourself-sql-when-your-job-is-ops-bml</link>
      <guid>https://dev.to/malek_damiri_68c6fecee5b7/teaching-yourself-sql-when-your-job-is-ops-bml</guid>
      <description>&lt;p&gt;I taught myself SQL. No bootcamp and no data degree. I took an online course, did a lot of practice problems, and then started answering real questions at work. If you’re in sales ops, RevOps, or finance and thinking about learning it, here is what I’d pass on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with questions, not syntax
&lt;/h2&gt;

&lt;p&gt;Pick three questions you get asked every month. Which reps are creating pipeline? Where do deals stall? Which accounts have gone quiet? Learn only what you need to answer those. &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;WHERE&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, and one &lt;code&gt;JOIN&lt;/code&gt; cover more ground than you’d expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do the practice problems anyway
&lt;/h2&gt;

&lt;p&gt;LeetCode-style problems feel artificial, and they are. They still build the habit of breaking a question into steps, and they put window functions and CTEs in front of you long before your day job would. Twenty minutes a day beats a weekend binge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learn window functions earlier than feels reasonable
&lt;/h2&gt;

&lt;p&gt;Most operations questions are really “compared to what?” This month against last month. This rep against the team. First touch against latest touch. &lt;code&gt;ROW_NUMBER&lt;/code&gt;, &lt;code&gt;LAG&lt;/code&gt;, and a running &lt;code&gt;SUM&lt;/code&gt; answer a large share of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distrust your first answer
&lt;/h2&gt;

&lt;p&gt;A query that runs is not a query that’s right. Joins quietly duplicate rows, and filters quietly drop them. Count rows before and after every join, and reconcile one number against a source you trust before you send anything to anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write it so the next person can read it
&lt;/h2&gt;

&lt;p&gt;The next person is usually you, three months from now. Use CTEs with plain names, and leave a comment on anything that isn’t obvious.&lt;/p&gt;

&lt;p&gt;The payoff is speed. The gap between having a question and having an answer shrinks from days to minutes, and that changes which questions you bother to ask.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://malekdamiri.com/writing/teaching-yourself-sql-in-ops/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;. More from Malek Damiri on revenue operations at &lt;a href="https://malekdamiri.com/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>career</category>
      <category>beginners</category>
      <category>productivity</category>
    </item>
    <item>
      <title>What revenue operations should own, and what it should hand off</title>
      <dc:creator>Malek Damiri</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:42:04 +0000</pubDate>
      <link>https://dev.to/malek_damiri_68c6fecee5b7/what-revenue-operations-should-own-and-what-it-should-hand-off-e4i</link>
      <guid>https://dev.to/malek_damiri_68c6fecee5b7/what-revenue-operations-should-own-and-what-it-should-hand-off-e4i</guid>
      <description>&lt;p&gt;Revenue operations collects work. Anything that touches revenue data and has no obvious owner lands there: a report request, a broken field, a territory dispute, a question about a commission check. Each one is reasonable on its own. Together they turn the team into a help desk for the whole go-to-market org, and the work only RevOps can do gets done last.&lt;/p&gt;

&lt;p&gt;The fix is to decide on purpose what the team owns, and to hand the rest to a named owner on the other side. Here is where I’d draw the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Own the definitions
&lt;/h2&gt;

&lt;p&gt;Someone has to decide what counts as a qualified opportunity, a booking, an active customer, and churn, and where each stage starts and ends. Those definitions need one owner and one written home, and every report should read from them.&lt;/p&gt;

&lt;p&gt;If marketing, sales, and finance each calculate pipeline their own way, the company doesn’t have a pipeline number. It has three opinions. This is the one job RevOps can’t hand off, because no other team sits across all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Own the handoffs between teams
&lt;/h2&gt;

&lt;p&gt;Each team owns its own stage of the funnel. Nobody owns the gap between two stages unless someone is assigned to it. That gap is where leads sit unworked, closed deals reach onboarding without context, and renewals surprise the account manager.&lt;/p&gt;

&lt;p&gt;Routing rules, response-time targets, and the fields that carry context from one team to the next belong to RevOps. So does measuring them. A handoff nobody measures slows down quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Own the system design, not every request
&lt;/h2&gt;

&lt;p&gt;The CRM’s structure needs one owner: objects, stages, required fields, validation rules, and who can change them. Routine admin work can be shared. Design decisions can’t, because a field added for one team changes the reports of every other team.&lt;/p&gt;

&lt;p&gt;Report requests are different. A team that builds every one-off pull never gets to the analysis that changes a decision. Build a small set of dashboards people trust, make self-serve the default, and spend the time saved on questions nobody else can answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand off the decisions
&lt;/h2&gt;

&lt;p&gt;Quotas, territories, comp plans, and pricing belong to the leaders accountable for the results, usually with finance in the room. RevOps should build the models behind them: capacity, coverage, funnel math, and the trade-offs between options.&lt;/p&gt;

&lt;p&gt;When RevOps makes the call itself, it inherits every argument about it, and its analysis stops looking neutral. Commissions work the same way. RevOps can supply the attainment data, but the payout and the controls around it belong with finance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the logic, share the plumbing
&lt;/h2&gt;

&lt;p&gt;Where a data engineering team exists, ingestion, the warehouse, and job scheduling belong there. RevOps keeps the business logic: how a booking is defined in SQL, how accounts map across systems, and what counts as a match in a reconciliation. In a smaller company RevOps often runs both, which works as long as the logic is written down well enough to move later.&lt;/p&gt;

&lt;p&gt;A simple test helps with each new request: would it break if every team ran its own version? If yes, it’s RevOps work. If it has a natural home somewhere else, hand it over cleanly, with the data and the documentation, and send the next request to its new owner.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://malekdamiri.com/writing/what-revenue-operations-should-own/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;. More from Malek Damiri on revenue operations at &lt;a href="https://malekdamiri.com/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sales</category>
      <category>management</category>
      <category>productivity</category>
      <category>career</category>
    </item>
    <item>
      <title>Find the funnel constraint before you automate</title>
      <dc:creator>Malek Damiri</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:51:56 +0000</pubDate>
      <link>https://dev.to/malek_damiri_68c6fecee5b7/find-the-funnel-constraint-before-you-automate-1e41</link>
      <guid>https://dev.to/malek_damiri_68c6fecee5b7/find-the-funnel-constraint-before-you-automate-1e41</guid>
      <description>&lt;p&gt;When a team is behind its number, the first instinct is to do more of everything: more outreach, more leads, more tooling. Before any of that, I like to ask a plainer question. What would it take to hit the number, step by step?&lt;/p&gt;

&lt;h2&gt;
  
  
  Work backwards
&lt;/h2&gt;

&lt;p&gt;Start with the target and divide. Deals needed, at your real win rate, means this many qualified opportunities. At your real conversion, that means this many first meetings, which means this many conversations started. Use measured rates, not hoped-for ones. The arithmetic takes ten minutes and it’s usually sobering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then look for the ceiling
&lt;/h2&gt;

&lt;p&gt;Every funnel has a step that can’t go faster no matter how much you pour in above it. It might be the size of the market you can credibly reach. It might be how many deals a rep can carry, or how many new customers onboarding can process in a month. That step sets your output. Everything upstream of it is inventory.&lt;/p&gt;

&lt;p&gt;This is where automation goes wrong. Automating the top of the funnel feels productive because the counts go up immediately. If the constraint sits downstream, you’ve built a faster way to create a backlog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rank fixes by payoff
&lt;/h2&gt;

&lt;p&gt;Once the constraint is named, the list of projects sorts itself. The cheap fixes that widen the narrow step come first. Volume at the top comes last, and only with a way to switch it off if quality drops.&lt;/p&gt;

&lt;p&gt;It’s also worth separating what sales can influence from what it can’t. Some lines in a plan are met by self-serve demand, some by partners, and some by the sales team. Only the last one is a sales problem. Treating all three as one target is how a team ends up working hard on the wrong line.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://malekdamiri.com/writing/find-the-constraint-before-you-automate/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;. More from Malek Damiri on revenue operations at &lt;a href="https://malekdamiri.com/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>sales</category>
      <category>automation</category>
      <category>career</category>
    </item>
    <item>
      <title>Your average customer doesn't exist</title>
      <dc:creator>Malek Damiri</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:51:56 +0000</pubDate>
      <link>https://dev.to/malek_damiri_68c6fecee5b7/your-average-customer-doesnt-exist-4iho</link>
      <guid>https://dev.to/malek_damiri_68c6fecee5b7/your-average-customer-doesnt-exist-4iho</guid>
      <description>&lt;p&gt;Most revenue plans contain a line like this: new accounts times average revenue per account. It looks harmless. It’s usually the weakest number in the model.&lt;/p&gt;

&lt;p&gt;Revenue per customer is almost never bell-shaped. A handful of accounts produce most of it and a long tail produces very little. In a distribution like that, the average describes nobody. It sits far above the typical account and far below the big ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan on the median and size the tail separately
&lt;/h2&gt;

&lt;p&gt;If you want to know what the next ordinary customer is worth, look at the median. In skewed data it can be a small fraction of the mean. A plan that assumes the mean is quietly assuming you’ll land more very large accounts, without saying so.&lt;/p&gt;

&lt;p&gt;So split it. Model the typical account on the median, and treat large accounts as their own line, with their own count, their own sales cycle, and their own odds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look at concentration before anything else
&lt;/h2&gt;

&lt;p&gt;Before trusting any total, sort the accounts and ask what share comes from the top ten. If it’s most of it, your “trend” is really a few customers’ behavior, and a single departure can pass for a market shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Report the spread
&lt;/h2&gt;

&lt;p&gt;One number invites one conclusion. I’d rather show the median, the 90th percentile, and the count behind them. It takes one more line on a slide, and it prevents the worst meetings, the ones where everyone argues about an average none of them could find in the data.&lt;/p&gt;

&lt;p&gt;The habit is cheap. Every time you’re handed an average, ask to see the distribution it came from. If nobody has looked, you’ve found the first thing to do.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://malekdamiri.com/writing/your-average-customer-does-not-exist/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;. I'm Malek Damiri, Head of Revenue Operations in Atlanta; more of my writing is at &lt;a href="https://malekdamiri.com" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>datascience</category>
      <category>statistics</category>
      <category>sales</category>
    </item>
    <item>
      <title>How to audit a revenue dashboard before executives use it</title>
      <dc:creator>Malek Damiri</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:50:10 +0000</pubDate>
      <link>https://dev.to/malek_damiri_68c6fecee5b7/how-to-audit-a-revenue-dashboard-before-executives-use-it-2kl7</link>
      <guid>https://dev.to/malek_damiri_68c6fecee5b7/how-to-audit-a-revenue-dashboard-before-executives-use-it-2kl7</guid>
      <description>&lt;p&gt;A revenue dashboard gets one first impression. If an executive finds a number in the first week that doesn’t match the board deck, every number after that is suspect, and the trust takes months to earn back. So before anything goes in front of leadership, I run the same audit. Here is the checklist, with the reasoning behind each item, because the reasoning is what tells you how hard to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tie it out to the ledger
&lt;/h2&gt;

&lt;p&gt;Start with the total. Whatever the dashboard calls revenue should tie to what finance recognized for the same period, or you should be able to explain every dollar of the gap. Pull the period total from the dashboard’s own query, pull the same period from the ledger, and reconcile them line by line. Timing differences, refunds, and credits are acceptable reasons to differ. “We’re not sure” is not.&lt;/p&gt;

&lt;p&gt;Do this for more than one period. A single month can tie by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the date boundaries and time zones
&lt;/h2&gt;

&lt;p&gt;A large share of revenue disputes come down to what day something happened. The source system stamps events in UTC, the warehouse truncates to a date, the BI tool applies the viewer’s local time zone, and the month closes at a different moment in each layer. Pick one reporting time zone, write it down, and make sure every layer agrees. Then test the edges: the last hour of the month, the first hour of the next, and the weekend daylight saving changes.&lt;/p&gt;

&lt;p&gt;While you’re there, confirm which date the dashboard keys on. Order date, invoice date, payment date, and recognition date are all defensible. Mixing them on one page is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow the currency and the fees
&lt;/h2&gt;

&lt;p&gt;If revenue arrives in more than one currency, find out where the conversion happens, which rate it uses, and whether that rate matches what finance uses to close. Then find out whether the dashboard shows gross or net. Processor fees, discounts, taxes, and refunds each move the number, and each has a camp that considers its version the real one. The dashboard has to pick one, label it, and use it everywhere. A tile that says “Revenue” with no qualifier is a question waiting to be asked in a meeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look for duplicates and late arrivals
&lt;/h2&gt;

&lt;p&gt;Count distinct identifiers against raw row counts at every step of the pipeline. Joins duplicate rows quietly, and a retry in the source system can land the same record twice. Then look in the other direction: records that arrive after the period they belong to. A dashboard that shows last month as final on the first and then revises it for a week teaches readers to wait before believing anything. Decide how long a period stays open, show that status on the tile, and build the dedupe into the query instead of trusting the source to be clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the filters that silently exclude
&lt;/h2&gt;

&lt;p&gt;Every dashboard carries filters that someone added for a good reason and then forgot. A status filter that drops a new order type. A date filter hard-coded to a fiscal year that has since ended. A segment filter meant for one tab that leaked into the shared dataset. Read every &lt;code&gt;WHERE&lt;/code&gt; clause and every filter the BI tool applies, and for each one ask what it excludes and whether the readers know. The dangerous ones make the number smaller in a way that still looks plausible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freshness, and a name on the sign-off
&lt;/h2&gt;

&lt;p&gt;Put a “data as of” timestamp on the page, computed from the data itself rather than from when the page loaded. Decide how stale is too stale, and make the dashboard say so when it crosses that line.&lt;/p&gt;

&lt;p&gt;Finally, someone has to sign off, and it shouldn’t be the person who built it. The owner of the number, usually in finance or the business, reviews the reconciliation, the definitions, and the list of known gaps, and agrees in writing that this is the version executives see. Signing off doesn’t mean the dashboard is perfect. It means someone besides the builder has looked and is willing to defend it.&lt;/p&gt;

&lt;p&gt;An audit like this always takes longer than anyone wants. It is still cheaper than relaunching a dashboard that was wrong on day one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://malekdamiri.com/writing/how-to-audit-a-revenue-dashboard/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;. I'm Malek Damiri, Head of Revenue Operations. Drafted with AI assistance. More of my writing is at &lt;a href="https://malekdamiri.com" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>management</category>
      <category>saas</category>
    </item>
    <item>
      <title>Snowflake SQL patterns for revenue reporting</title>
      <dc:creator>Malek Damiri</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:48:44 +0000</pubDate>
      <link>https://dev.to/malek_damiri_68c6fecee5b7/snowflake-sql-patterns-for-revenue-reporting-573l</link>
      <guid>https://dev.to/malek_damiri_68c6fecee5b7/snowflake-sql-patterns-for-revenue-reporting-573l</guid>
      <description>&lt;p&gt;Most revenue reporting problems are not hard SQL. They’re the same handful of problems showing up in different tables: gaps in the calendar, days cut at the wrong midnight, duplicate records, totals that need a comparison, data that quietly stopped arriving, and two systems that disagree about the same month. I’ve settled on a small set of Snowflake patterns for them. Here they are, with generic table names. Swap in your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start from a date spine
&lt;/h2&gt;

&lt;p&gt;If a day had no orders, a plain &lt;code&gt;GROUP BY&lt;/code&gt; leaves it out and the chart skips the day as if it never happened. Generate the calendar first and left join the facts onto it, so a quiet day shows up as zero instead of vanishing. If you already have a &lt;code&gt;dim_date&lt;/code&gt; table, join to that instead of the generator. &lt;code&gt;SEQ4&lt;/code&gt; can have gaps, so use &lt;code&gt;ROW_NUMBER&lt;/code&gt; for the offset.&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;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;dateadd&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;row_number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;seq4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2025-01-01'&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;as&lt;/span&gt; &lt;span class="n"&gt;report_date&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;report_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&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="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;revenue_usd&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;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
  &lt;span class="k"&gt;on&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="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;report_date&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;report_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;current_date&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="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;h2&gt;
  
  
  Cut days in the reporting time zone
&lt;/h2&gt;

&lt;p&gt;Timestamps usually arrive in UTC, and a month that closes at midnight UTC is a different month from one that closes at midnight where the business operates. Pick one reporting time zone, convert before you truncate, and do it in one shared view rather than in every dashboard. If &lt;code&gt;created_at&lt;/code&gt; is a &lt;code&gt;TIMESTAMP_NTZ&lt;/code&gt; holding UTC, the three-argument form of &lt;code&gt;CONVERT_TIMEZONE&lt;/code&gt; does the conversion.&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;convert_timezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'America/New_York'&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;order_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_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;revenue_usd&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="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;h2&gt;
  
  
  Dedupe to the latest record with QUALIFY
&lt;/h2&gt;

&lt;p&gt;Systems that sync by upsert or replay often land several versions of the same invoice, and a &lt;code&gt;SUM&lt;/code&gt; over all of them overstates revenue by exactly the amount nobody can explain. Keep the latest version per key and drop the rest. &lt;code&gt;QUALIFY&lt;/code&gt; filters on the window function directly, with no wrapping subquery, which keeps the intent visible to the next reader.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
&lt;span class="n"&gt;qualify&lt;/span&gt; &lt;span class="n"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;
  &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loaded_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Running totals and period-over-period
&lt;/h2&gt;

&lt;p&gt;Most executive questions are “compared to what?” This month against last month. This year to date against the same point last year. Aggregate to the grain you report at first, then let window functions do the comparing. One caveat: &lt;code&gt;LAG&lt;/code&gt; counts rows, not months. If a month can be missing, run this on top of the spine above or the comparison will shift by a row.&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;monthly&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;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trade_date&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;report_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;fee_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;fees_usd&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;trades&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;report_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;fees_usd&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;fees_usd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;partition&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;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;report_month&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;report_month&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;fees_ytd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fees_usd&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="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;report_month&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;prior_month_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fees_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;report_month&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;prior_year_usd&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;monthly&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;report_month&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A freshness check the dashboard can show
&lt;/h2&gt;

&lt;p&gt;A dashboard that shows yesterday’s numbers with full confidence is worse than one that admits it’s stale. Compute freshness from the data itself, not from when the page loaded, and put the result where readers see it. Watch the time zones here too. &lt;code&gt;CURRENT_TIMESTAMP&lt;/code&gt; follows the session time zone, so if &lt;code&gt;loaded_at&lt;/code&gt; is stored as UTC, compare against &lt;code&gt;SYSDATE&lt;/code&gt; instead, or convert one side before the subtraction.&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;latest&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loaded_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;last_loaded_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;datediff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loaded_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;current_timestamp&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;hours_since_load&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;last_loaded_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;hours_since_load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;iff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours_since_load&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'stale'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ok'&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;status&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reconcile two systems with a FULL OUTER JOIN
&lt;/h2&gt;

&lt;p&gt;When the warehouse and the ledger disagree, you want every period from both sides, including the months that exist in only one of them. An inner join hides exactly the rows you’re looking for. Use a &lt;code&gt;FULL OUTER JOIN&lt;/code&gt;, &lt;code&gt;COALESCE&lt;/code&gt; the keys and the amounts, and add a tolerance so you’re not chasing rounding. Anything outside the tolerance gets a written explanation.&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;warehouse&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;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invoice_date&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;report_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;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;warehouse_usd&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices&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;ledger&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;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;posting_date&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;report_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;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;ledger_usd&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ledger_entries&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;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&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;report_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;report_month&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;report_month&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;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;warehouse_usd&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;warehouse_usd&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ledger_usd&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;ledger_usd&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;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;warehouse_usd&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="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="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ledger_usd&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;variance_usd&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;warehouse&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;full&lt;/span&gt; &lt;span class="k"&gt;outer&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="n"&gt;l&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;report_month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;report_month&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;abs&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;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;warehouse_usd&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="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="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ledger_usd&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="c1"&gt;-- tolerance in dollars&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;None of these are clever, and that’s the point. The queries behind an executive dashboard should be boring enough that the next person can read them in one sitting. Put them in version control, name the CTEs plainly, and reuse the same few shapes until people stop asking how a number was built.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://malekdamiri.com/writing/snowflake-sql-patterns-for-revenue-reporting/" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;. I'm Malek Damiri, Head of Revenue Operations. More of my writing is at &lt;a href="https://malekdamiri.com" rel="noopener noreferrer"&gt;malekdamiri.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>data</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>Cohort Retention in pandas: Three Mistakes That Make Your Curve Lie</title>
      <dc:creator>Malek Damiri</dc:creator>
      <pubDate>Wed, 07 Oct 2026 19:22:11 +0000</pubDate>
      <link>https://dev.to/malek_damiri_68c6fecee5b7/cohort-retention-in-pandas-three-mistakes-that-make-your-curve-lie-335k</link>
      <guid>https://dev.to/malek_damiri_68c6fecee5b7/cohort-retention-in-pandas-three-mistakes-that-make-your-curve-lie-335k</guid>
      <description>&lt;p&gt;Cohort retention tables look simple: group customers by the month they started, count how many are still around each month after, divide. Then the curve on the slide says retention falls off a cliff at month three, and it's not true.&lt;/p&gt;

&lt;p&gt;Most retention charts that lie do it in one of three ways. Here is a small pandas version that avoids all three, with the numbers each mistake would have produced instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;One row per customer per month in which they paid. Synthetic, small enough to check by hand:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&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;a&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;a&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;a&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;a&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;b&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;b&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;c&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;c&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;c&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;d&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;e&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;e&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;e&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;f&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;f&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;g&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;paid_month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_datetime&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2025-01-01&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;2025-02-01&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;2025-03-01&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;2025-04-01&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;2025-01-01&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;2025-03-01&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;2025-01-01&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;2025-02-01&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;2025-04-01&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;2025-02-01&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;2025-02-01&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;2025-03-01&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;2025-04-01&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;2025-03-01&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;2025-04-01&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;2025-04-01&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="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;last_complete_month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2025-04-01&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;&lt;code&gt;last_complete_month&lt;/code&gt; matters more than it looks. It's the last month you have full data for, and it decides which cells in the table are real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the table
&lt;/h2&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;months_between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;later&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;earlier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;later&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;earlier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;later&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;earlier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="c1"&gt;# 1. Cohort = first paid month.
&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cohort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&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;paid_month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Period 0 is the cohort month itself.
&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;period&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;months_between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paid_month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cohort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Distinct paying customers per cohort and period.
&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drop_duplicates&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&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;paid_month&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;pivot_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cohort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;period&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggfunc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nunique&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;fillna&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="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 4. Divide by cohort size.
&lt;/span&gt;&lt;span class="n"&gt;cohort_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;counts&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="n"&gt;retention&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cohort_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&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="c1"&gt;# 5. A cohort that hasn't reached a period yet is unknown, not zero.
&lt;/span&gt;&lt;span class="n"&gt;months_observed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;months_between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_complete_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;observable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;months_observed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;retention&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 6. Weighted curve: retained customers over the cohorts that can be observed.
&lt;/span&gt;&lt;span class="n"&gt;curve&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cohort_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&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="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
         &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;observable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cohort_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&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="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retention table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;period        0     1     2     3
cohort
2025-01-01  1.0  0.67  0.67  0.67
2025-02-01  1.0  0.50  0.50   NaN
2025-03-01  1.0  1.00   NaN   NaN
2025-04-01  1.0   NaN   NaN   NaN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the curve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;period
0    1.00
1    0.67
2    0.60
3    0.67
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mistake 1: starting the count at 1
&lt;/h2&gt;

&lt;p&gt;If the cohort month is "month 1", every column shifts by one, and the first column is always 100% by construction. Readers compare your month 1 to someone else's month 1 and draw the wrong conclusion. Pick period 0 for the starting month, and say so in the chart title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2: treating cells you can't see yet as zero
&lt;/h2&gt;

&lt;p&gt;The April cohort started in the last month of data. It has no month 1 yet, not a month 1 of zero. If those cells are zeros, the curve collapses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;zeros for unreached cells: [1.0, 0.54, 0.29, 0.17]
correct weighted curve:    [1.0, 0.67, 0.60, 0.67]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the "retention falls off a cliff" chart. It's an artifact of when you ran the query. Step 5 fixes it by marking those cells &lt;code&gt;NaN&lt;/code&gt; and leaving them out of the curve.&lt;/p&gt;

&lt;p&gt;The same bug has a quieter cousin: including the current, partial month as if it were complete. That's why the cutoff is &lt;code&gt;last_complete_month&lt;/code&gt; and not "today".&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 3: averaging the percentages
&lt;/h2&gt;

&lt;p&gt;A plain mean gives every cohort the same weight, so a one-customer cohort counts as much as a three-customer cohort:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain mean of percentages: [1.0, 0.72, 0.58, 0.67]
correct weighted curve:    [1.0, 0.67, 0.60, 0.67]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the difference is small. With real data, a tiny early cohort of friendly design partners can pull the whole curve up for a year. Step 6 sums retained customers and divides by the customers who could have been retained, which is what "retention at month n" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to decide before you trust the chart
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What counts as retained.&lt;/strong&gt; Paid in the month, logged in, or still under contract? Each gives a different, defensible curve. Write the choice under the chart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reactivations.&lt;/strong&gt; Customer &lt;code&gt;b&lt;/code&gt; paid in January and March but not February. In this table they count in month 2 and not month 1, which is usually what you want for activity. For revenue retention you may want different rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer identity.&lt;/strong&gt; If one company can have two customer IDs, merge them before step 1 or one customer becomes a churn and a new signup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The table itself is ten lines of pandas. The part that takes judgment is deciding which cells are real.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pandas</category>
      <category>datascience</category>
      <category>analytics</category>
    </item>
  </channel>
</rss>
