◀ Course contents Part 4 · Module 4-05

Capstone: Your First Week

A real analytics job, in fifteen queries

No lesson in this module and no new syntax — you already have all of it. What you have not had is the job: a database nobody documented, people who ask for numbers in their own words rather than in columns, and a deadline that does not care which clause it needs. Fourteen requests land in your inbox across the week. Friday morning, the board pack goes out with your name on it.

The situation

You have joined Northwind Analytics

Read this once before you start. It is the whole of the handover you are going to get, which is also true of the real thing.

Northwind Analytics

A small B2B SaaS product. Twelve customers, a handful of plans, a catalogue of seats, add-ons and services. Small enough that every number can be checked by hand — which is exactly why a wrong one is unforgivable here.

Data Analyst — first week

The first one they have hired. Nobody before you wrote a query against this database, so there is no library of trusted SQL to copy from and no colleague to sanity-check a total against.

Board meeting, Friday 09:00

Everything below feeds it. The requests arrive in the order they were sent, and the last one is the pack itself.

Who is asking

Five people, and none of them will describe what they want in SQL. That translation is the job — and so is noticing what a request does not say. "Revenue" does not mention refunds. "Our customers" does not say whether the churned ones count. Both decisions are yours, and both are wrong in one direction if you do not make them deliberately.

RN

Rachel Nkomo · CEO

Asks short questions with large consequences. Wants the board pack, and hates a blank cell.

PR

Priya Raman · Head of Finance

Every number she gets is checked against the bank. If a total is wrong she will find it.

MB

Marcus Bell · Growth

Signups, funnels, referrals. Comfortable with percentages, which is its own hazard.

DO

Dani Okafor · Customer Success

Wants lists of actual people she can email, not aggregates.

SI

Sam Iqbal · Product

Reviewing the catalogue. Interested mainly in the things that are not selling.

What the last engineer left you

Five tables and no documentation, which is the normal amount. Open the schema browser below the queue to see the columns; these are the things you would only find out by getting a number wrong first.

Handover notes

Not every order is money. orders.status holds paid, refunded and pending. Only one of those is in the bank, and the difference is 60.00 across three rows — small enough to slip through, large enough for Priya to notice.

Blanks are real. users.company is empty for individuals and subscriptions.cancelled_on is empty while a subscription is still running. In both cases the absence is the information, and = NULL will never find it.

People repeat. events has one row per thing that happened, so the same person appears many times. Counting rows where you meant to count people is the most common way a funnel ends up over 100%.

Quiet periods leave no trace. A month in which nothing was bought produces no rows at all, so it cannot appear in a GROUP BY — and a chart drawn from that quietly closes the gap. Priya has been burned by this before; she raises it on Thursday.

The one habit worth carrying into every ticket

Almost nothing that goes wrong here raises an error. A join that matches twice inflates a total; a filter in the wrong clause deletes rows; a missing DISTINCT turns one buyer into three. All of them return a plausible number and none of them complain.

So say what the answer should be before you run the query, even roughly. There are 24 orders and 21 of them are paid. There are 12 users. Paid revenue is 1891.00 and every split of it has to add back up to that. If a result disagrees with a number you already know, the result is wrong, not the number.

0 of 14 completed

Loading the tables…

01

To do

Dani Okafor · Customer Success

"Morning, and welcome. Can you send me the account list? Everyone who's still with us — name, country, what plan they're on and when they joined. Oldest first, I read it top down."

The gentlest ticket you will get all week, and it still contains a decision: "still with us" is not a column. Look at users and work out which one Dani means.

Deliver: name, country, plan and signup_date for every active user, oldest signup first.

query.sql
PostgreSQL
Hint

is_active is a real BOOLEAN, so WHERE is_active is enough — no = true needed. Order by signup_date ascending.

Output

      
    02

    To do

    Priya Raman · Head of Finance

    "Three numbers for the top of the board pack: how many orders we've taken, what they came to, and the average order value. All time is fine. I need it to match the bank, so be careful."

    "Match the bank" is the whole ticket. Twenty-four rows sit in orders and not all of them are money in the account — look at what status holds before you total anything. Money that was refunded left again; money that is pending never arrived.

    Deliver: one row — orders, revenue, aov, the last rounded to 2 decimal places.

    query.sql
    PostgreSQL
    Hint

    COUNT(*) AS orders, SUM(amount) AS revenue, ROUND(AVG(amount), 2) AS aov, all filtered to WHERE status = 'paid'.

    Output
    
          
      03

      To do

      Marcus Bell · Growth

      "Need our 2023 signup curve for the board deck — how many people joined each month. Just 2023, chronological."

      Signup dates are real DATE values, so a month is date_trunc away. Cast the result to ::date rather than leaving it a timestamp — a chart axis reading 2023-03-01 00:00:00 looks like something went wrong even when nothing did.

      Deliver: mon and signups for each month of 2023 that had any, ordered by month.

      Eleven rows come back, not twelve. Hold that thought — Priya's Thursday ticket is about exactly the month that is missing.

      query.sql
      PostgreSQL
      Hint

      date_trunc('month', signup_date)::date AS mon and COUNT(*) AS signups, grouped by the same date_trunc expression.

      Output
      
            
        04

        To do

        Dani Okafor · Customer Success

        "Following up on yesterday — some of those accounts have no company against them, so I can't tell whether they're a business or someone trying it out. Can you pull the ones with nothing in the company field? I'll chase them."

        company is nullable and five rows use that. The obvious spelling — WHERE company = NULL — returns no rows, no error and no warning, which is the most expensive silent failure in the language. NULL is not a value to compare against; it is the absence of one.

        Deliver: user_id, name, country and plan for every user with no company recorded, by user_id.

        query.sql
        PostgreSQL
        Hint

        WHERE company IS NULL. = NULL is never true, not even for a NULL.

        Output
        
              
          05

          To do

          Priya Raman · Head of Finance

          "The board will ask where the money comes from. Split the revenue by product category — licences, add-ons, services — with the order count next to it. Biggest first."

          The category lives on products and the money lives on orders, so this is the first ticket that needs a join. The paid-only rule from Monday still applies and will apply to every revenue number this week — it does not stop being true because the query got harder.

          Deliver: category, orders, revenue, highest revenue first.

          query.sql
          PostgreSQL
          Hint

          JOIN products p ON p.product_id = o.product_id, WHERE o.status = 'paid', then COUNT(*) AS orders and SUM(o.amount) AS revenue grouped by p.category.

          Output
          
                
            06

            To do

            Rachel Nkomo · CEO

            "Who are our five biggest customers by spend? I want their name, country and company for the board. If we don't have a company on file just put Individual — don't send me blanks, it looks like the data's broken."

            Two things worth noticing. The ranking is over money that actually arrived, so the paid filter belongs here too. And Rachel's last sentence is a real requirement: an empty cell in a board pack starts an argument about the data instead of about the customers, so the NULL gets a label rather than a blank.

            Deliver: name, country, company, orders and revenue for the top five spenders, biggest first, ties broken by name.

            query.sql
            PostgreSQL
            Hint

            COALESCE(u.company, 'Individual') AS company. Join with ON o.user_id = u.user_id AND o.status = 'paid' (or filter in a WHERE — an inner join makes no difference here), then COUNT(*) AS orders and SUM(o.amount) AS revenue.

            Output
            
                  
              07

              To do

              Sam Iqbal · Product

              "I'm reviewing the catalogue for next quarter. Can I get every product with what it's earned us? Every product, please — the ones earning nothing are the entire reason I'm asking."

              Sam has anticipated the mistake, and it is the one worth internalising: an inner join answers "products that sold" and silently drops the rest, so the product with no orders — the one the review exists to find — never appears. That is not a wrong number, it is a missing row, and nothing in the output can tell you it is gone.

              A product with no matching order gets NULLs from the join, and SUM over no rows is NULL rather than zero, so that needs turning into the number it represents.

              Deliver: name, category, units and revenue for all eight products, highest revenue first, ties broken by name.

              query.sql
              PostgreSQL
              Hint

              LEFT JOIN orders o ON o.product_id = p.product_id AND o.status = 'paid' — both conditions in the ON, or the WHERE deletes the unsold product again. Then COALESCE(SUM(o.quantity), 0) AS units and COALESCE(SUM(o.amount), 0) AS revenue.

              Output
              
                    
                08

                To do

                Marcus Bell · Growth

                "We're thinking about paying for referrals. Before I put a number on it — who has actually referred anyone, and how many? Most first."

                users.referred_by points at another row in users, so the referrer and the referred person are both in the same table. Join it to itself under two different aliases and the self-reference stops being confusing: one alias is the person who joined, the other is whoever sent them.

                Deliver: referrer (their name) and referred (how many people they brought in), most first, ties broken by name.

                query.sql
                PostgreSQL
                Hint

                JOIN users r ON r.user_id = u.referred_by — u is the person who joined, r is their referrer. COUNT(*) AS referred. The inner join drops anyone who referred nobody, which is what "who has actually referred anyone" asks for.

                Output
                
                      
                  09

                  To do

                  Priya Raman · Head of Finance

                  "One more before you go. MRR by plan — how many subscriptions we have on each plan and what they're worth per month. Live ones only, obviously. Cancelled subscriptions don't pay us."

                  "Live" is again not a column. subscriptions.cancelled_on holds a date when someone cancelled and nothing at all while they are still paying, so the absence of a date is the status — the same NULL logic as Dani's Tuesday ticket, doing real work.

                  Deliver: plan, subs and mrr for live subscriptions only, biggest MRR first.

                  query.sql
                  PostgreSQL
                  Hint

                  WHERE cancelled_on IS NULL, then COUNT(*) AS subs and SUM(mrr) AS mrr grouped by plan.

                  Output
                  
                        
                    10

                    To do

                    Marcus Bell · Growth

                    "Rachel's going to ask how many people who sign up actually get value out of the product. Three steps: they sign up, they activate, they create a report. How many reach each one, and what percentage of signups is that?"

                    events holds one row per thing that happened, and a person can do the same thing more than once — Ada created two reports. Count rows and she is two people. Count distinct users and she is one, which is what a funnel means.

                    The denominator is the first step's user count, and it is the same number for all three rows.

                    Deliver: step, users and pct_of_signups to one decimal place, for the three steps only, widest step first.

                    Worth knowing before you report it: "75% activate" describes an average of nine people, and none of them is 75% activated. It is a fine headline and a bad basis for a decision at this sample size.

                    query.sql
                    PostgreSQL
                    Hint

                    COUNT(DISTINCT e.user_id) AS users. For the percentage, divide it by a scalar subquery: ROUND(100.0 * COUNT(DISTINCT e.user_id) / (SELECT COUNT(DISTINCT user_id) FROM events WHERE event_name = 'signup'), 1).

                    Output
                    
                          
                      11

                      To do

                      Priya Raman · Head of Finance

                      "Monthly revenue, first order to last, for the trend chart. And I mean every month in that window — last year someone sent me a chart with the quiet months quietly missing and I had to correct myself in the meeting. Zero is a number. Show me the zeros."

                      The obvious query is one line of work and it is what Priya is warning you about:

                      SELECT date_trunc('month', ordered_at)::date AS mon, SUM(amount)
                      FROM orders WHERE status = 'paid'
                      GROUP BY 1;

                      Sixteen rows, every figure in them correct. But the first paid order is January 2023 and the last is September 2024 — a span of 21 months. Five contain no paid order at all, so GROUP BY has nothing to build a row from and they simply do not exist. Plot that and the line runs straight from October to December as though November were unremarkable rather than zero.

                      A GROUP BY can only ever return groups that already contain a row, so the missing months have to come from somewhere other than the data: generate the series, take the bounds from the data so the report cannot go stale, and hang the revenue off it with an outer join.

                      Where the paid filter goes

                      Both join conditions belong in the ON. Move o.status = 'paid' into a WHERE and it runs after the padding — NULL is not 'paid', so every month you just went to the trouble of generating is deleted again and you are back to sixteen rows.

                      Deliver: mon and revenue for all 21 months, 0 where there was none, ordered by month.

                      query.sql
                      PostgreSQL
                      Hint

                      ON date_trunc('month', o.ordered_at)::date = m.mon AND o.status = 'paid', then COALESCE(SUM(o.amount), 0) AS revenue.

                      Output
                      
                            
                        12

                        To do

                        Rachel Nkomo · CEO

                        "Priya showed me the monthly line. What I actually want next to it is revenue to date — the cumulative number, so I can see where we stand rather than how one month went."

                        A running total over the months you just built. The monthly figures have to exist as rows before they can accumulate, so the aggregate goes in a CTE and the window runs over its output.

                        SUM(revenue) OVER (ORDER BY mon ROWS UNBOUNDED PRECEDING)

                        Write the frame out rather than leaving it to the default. There are no ties in a month column so RANGE would agree here, which makes this a free place to build the habit for a column where it would not.

                        Deliver: mon, revenue and running for all 21 months, ordered by month.

                        query.sql
                        PostgreSQL
                        Hint

                        SUM(revenue) OVER (ORDER BY mon ROWS UNBOUNDED PRECEDING) AS running.

                        Output
                        
                              
                          13

                          To do

                          Priya Raman · Head of Finance

                          "Last thing today: put month-on-month change on the revenue line, one decimal. And whatever you do, don't print 0% for a month we can't calculate — I'd rather see a blank I can explain than a number I can't."

                          ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY mon))
                          / NULLIF(LAG(revenue) OVER (ORDER BY mon), 0), 1)

                          Priya's warning is the interesting half of this ticket. Filling the gaps put five zeros in the column, and a zero has two different consequences depending on which side of the division it lands:

                          −100

                          A month that falls to zero

                          Correct, and exactly what happened. Three months do this.

                          A month that follows a zero

                          The denominator is zero. Growth from nothing is not a percentage, so the honest answer is no answer.

                          Without NULLIF this does not return a wrong number

                          It returns nothing. Division by zero aborts the whole statement in Postgres, so one empty month means the entire report fails. This is the only mistake in the week that announces itself.

                          Deliver: mon, revenue and pct_change to one decimal place, all 21 months, ordered by month.

                          query.sql
                          PostgreSQL
                          Hint

                          ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY mon)) / NULLIF(LAG(revenue) OVER (ORDER BY mon), 0), 1) AS pct_change.

                          Output
                          
                                
                            14

                            To do

                            Rachel Nkomo · CEO

                            "Board's in an hour. Someone will point at a spike and ask what drove it — I don't want to be guessing. Best-selling product in each month, with what it made."

                            Top-one-per-group, in two steps: aggregate to a product per month, then number the products within each month and keep number one. The numbering has to restart every month, which is what PARTITION BY does — without it you get the best product overall, once.

                            Group by the written-out expression rather than GROUP BY 1, 2: a positional GROUP BY does not match the expression the window's PARTITION BY refers to, and Postgres rejects it with "column must appear in the GROUP BY clause".

                            Deliver: for each month that has paid revenue, mon, the name of its highest-earning product and that product's rev. Ties broken by name, ordered by month.

                            Sixteen rows here, not 21 — an empty month has no best product, and inventing one for it would be worse than the gap.

                            query.sql
                            PostgreSQL
                            Hint

                            per is SELECT date_trunc('month', o.ordered_at)::date AS mon, p.name, SUM(o.amount) AS rev FROM orders o JOIN products p ON p.product_id = o.product_id WHERE o.status = 'paid' GROUP BY date_trunc('month', o.ordered_at), p.name. Then ROW_NUMBER() OVER (PARTITION BY mon ORDER BY rev DESC, name) AS rn, and WHERE rn = 1.

                            Output
                            
                                  

                              Fri 09:00 — the board pack

                              To do

                              Rachel Nkomo · CEO

                              "Everything in one table, please — I'm not flipping between five exports in front of the board. Every month since we started, what we made, how many orders, how many actual customers, where we stand cumulatively, and the month-on-month move. If a month was quiet I still want the row."

                              The week, assembled. Every column of this is a ticket you have already done, and the only new work is putting them in one query without letting any of them break the others.

                              Deliver: one row for every month from the first month containing a paid order to the last — including the quiet ones — with:

                              • mon — the first day of the month, as a DATE
                              • orders — paid orders that month, 0 if none
                              • buyers — distinct users who bought that month, 0 if none
                              • revenue — paid revenue that month, 0 rather than NULL if none
                              • running — cumulative revenue including this month
                              • pct_change — change against the previous month as a percentage, to one decimal place, NULL where it cannot be computed

                              Order by mon.

                              Reconcile it before you send it

                              Nearly every failure this week was silent — a join that duplicated, a filter that deleted, a count that counted the wrong thing. None of them raises an error and all of them move a total. So say what the numbers should be before you look: 21 months of span, 21 paid orders, 1891.00 of revenue, and a final running total equal to that. Three numbers you can hold in your head, and any one of them being wrong points at something specific.

                              Four ways to get this wrong, all of them seen this week. The paid filter belongs in the ON, or the quiet months you generated are deleted again. Count a column rather than the rows, or those months report one order each. Count buyers with DISTINCT, or somebody who ordered twice is two customers. And guard the percentage's denominator — three months fall to zero, and dividing by one of them fails the whole query rather than one row.

                              query.sql
                              PostgreSQL
                              Hint

                              bounds takes MIN and MAX of date_trunc('month', ordered_at) over paid orders. months is generate_series(bounds.a, bounds.b, INTERVAL '1 month'). monthly LEFT JOINs orders to months with the paid condition in the ON, using COUNT(o.order_id), COUNT(DISTINCT o.user_id) and COALESCE(SUM(o.amount), 0). Then SUM(revenue) OVER (ORDER BY mon ROWS UNBOUNDED PRECEDING) and the NULLIF-guarded percentage.

                              Output
                              
                                    

                                Notification