Analytics · Episode 19

Revenue

revenue analysis
The story

Finance emails Harry directly for the first time. Not “filter me these rows” — a real number. “How much revenue did we make last quarter?”

Harry

Easy. SELECT SUM(amount) FROM orders. One number, done.

Hermione

Run it. But before you hit send on whatever comes back — look at the orders table again. Every row in there is money NOVA actually kept?

Harry's first answer
query.sql
SELECT SUM(amount) AS total_revenue FROM orders;
Result1 row
total_revenue
1489.83

$1,489.83. Harry’s about to paste that into an email when Hermione points at the status column he skipped right past.

The concept

Not every row in orders is a sale. Some got cancelled before they shipped. Some were refunded after the fact. Some are still pending — NOVA hasn’t even collected that money yet. Summing amount across all of them doesn’t answer “how much did we make.” It answers “how much did anyone ever type into an order form,” which is a very different, much less useful number.

Revenue is the money a completed sale actually earned — not every dollar that ever touched an order row.

Build the query

Split it out by status first, so you can see exactly where the gap comes from:

query.sql
SELECT status, SUM(amount) AS amount, COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY amount DESC;
Result4 rows
statusamountorders
completed1299.8518
pending123.982
cancelled391
refunded271

$189.98 of Harry’s original number was orders that never actually turned into revenue. The fix is one WHERE clause:

query.sql
SELECT SUM(amount) AS total_revenue
FROM orders
WHERE status = 'completed';
Result1 row
total_revenue
1299.85

$1,299.85. That’s the number that goes to Finance. Same table, same column — the only thing that changed is asking which rows actually count.

Revenue over time

Finance’s next question is always “okay, but broken down by month.” placed_at is a full date, so grouping by it directly would give you one bucket per day. You need just the year and month.

Here’s the portable way to do that: cast the date to text, then grab the first 7 characters — '2023-05-22' becomes '2023-05'. This works the same whether NOVA is running on SQLite, DuckDB, or Postgres underneath, which is not true of every date trick you’ll see online.

query.sql
SELECT SUBSTR(CAST(placed_at AS TEXT), 1, 7) AS month,
       SUM(amount) AS revenue,
       COUNT(*) AS orders
FROM orders
WHERE status = 'completed'
GROUP BY month
ORDER BY month;
Result9 rows
monthrevenueorders
2022-12199.991
2023-0134.51
2023-02129.982
2023-03274.963
2023-0429.991
2023-05228.464
2023-07641
2023-08298.974
2023-09391

Notice there’s no row for June 2023 at all — not a row showing $0, just… nothing. NOVA had orders that month, but zero of them completed. A month with zero matching rows doesn’t show up as zero; it just vanishes. Keep that in the back of your mind — it matters more than it sounds like it should, and we’ll come back to exactly why later in this chapter.

Run it
query.sql

Running…

Try it: What happens if you GROUP BY placed_at directly instead of the truncated month?
experiment.sql

Run your query to see results here.

You get eighteen rows — almost one per completed order, because placed_at is a specific calendar day. Every day is its own group, so nothing gets aggregated into anything useful. Truncating to 'YYYY-MM' first is what actually buckets orders into months instead of leaving each one alone.

Your turn

Finance has a follow-up: “Which product category is actually making us money?” Using completed orders only, show each category’s total revenue, joined from products, sorted highest revenue first.

Opens in a new window, full width — come back here once you’re done.
Hint 1

You'll need a JOIN between orders and products — category lives on products, not orders.

Hint 2

Same WHERE status = 'completed' filter as before, then GROUP BY the category column.

Hint 3

SELECT p.category, SUM(o.amount) AS revenue FROM orders o JOIN products p ON p.id = o.product_id WHERE o.status = 'completed' GROUP BY p.category ORDER BY revenue DESC;

Solution
solution.sql

Running…

Debrief

Every “revenue” question is really two questions stacked together: which rows count, and how do I want them grouped. Get the first one wrong — forget the status filter — and every number built on top of it, by month, by category, by anything, is quietly wrong too.

One thing to remember

Revenue means completed transactions, not every row in the orders table — check status before you SUM. And to bucket dates into months portably, cast to text and take the first 7 characters: SUBSTR(CAST(date_col AS TEXT), 1, 7).