Hook: Interviewers love this one because it checks whether you can turn raw sales rows into a report finance can trust — not just write a SUM.
Question: How would you write a monthly revenue report in SQL?
Answer: I would group transactions by month, sum the revenue, and make sure the report uses the right business rule for revenue, such as excluding canceled orders and deciding how to treat refunds. If the report should show months with no sales, I would generate the month list first and LEFT JOIN the totals so empty months appear as zero.
Interview-Ready Answer: I would first normalize each transaction date to the first day of its month, then aggregate with SUM by that month key. In a real report, I would also be careful about the business rule: for example, exclude canceled orders and either subtract refunds or store them as negative amounts. If the dashboard needs every month even when there were no sales, I would build a month calendar and LEFT JOIN the revenue totals so missing months show as zero.
Detailed Explanation: A monthly revenue report is just a grouped SUM, but the important part is deciding what counts as revenue and how to bucket rows into calendar months.
2024-03-01. This is better than grouping by raw text because a date is easy to sort and join.CASE expression. The key is to make the rule explicit, not implied.SUM(amount) with GROUP BY month_start. If you need distinct order revenue instead of transaction revenue, dedupe first with a subquery.LEFT JOIN the totals.YYYY-MM in the final select. That avoids bad sorting and joining bugs.Different SQL engines have different date functions. The goal is the same: produce one stable month key per row.
| Method | Best use | Pros | Watch out |
|---|---|---|---|
DATE_TRUNC('month', ...) | PostgreSQL | Simple, sort-friendly | Database-specific |
YEAR + MONTH | Portable grouping | Works in many engines | Needs recombining to sort/join cleanly |
TO_CHAR or format string | Display only | Pretty output | Text can sort wrong if not padded |
Use this pattern whenever a business user wants a calendar report: finance, subscriptions, marketplace sales, ad revenue, or plan usage. It is especially important when the report must be stable across empty periods, because dashboards often expect January, February, March even if March had zero sales.
The aggregation itself is conceptually O(n) over the filtered rows, because every matching transaction must be read at least once. The memory cost is usually small, about O(m) for the number of months, not the number of orders; 36 months means 36 grouped rows, which is tiny. The bigger cost is the table scan or index scan on the orders table.
For large tables, use a date range filter such as order_date >= '2024-01-01' and order_date < '2025-01-01' so the predicate is sargable, meaning the database can use an index efficiently. A common mistake is wrapping the column in a function in the WHERE clause, which can block index use and force more work.
Realistic numbers: a 50 million row order table can still serve a monthly report quickly if the date filter is selective and the table has an index on order_date or is partitioned by month. Without that, the report may still be correct, but it can become slow during peak business hours.
order_date is a timestamp, define which time zone decides the month. A sale at 11:30 PM UTC on March 31 may be April 1 in local time.Real-World Example: Imagine a subscription checkout service for an online learning platform. Finance wants a monthly revenue dashboard for the CFO, and the data comes from an orders table plus a refunds table. The correct report must use the payment date, exclude canceled orders, and show months with zero revenue so the line chart does not break.
One team once wrote the report by grouping on DATE_FORMAT(created_at, '%Y-%m') and counting every paid-looking row. It seemed fine until payments that were created at the end of the month but captured the next day landed in the wrong month, and refunds were not subtracted at all. The dashboard was off by 6-9%, which is enough to confuse budgeting and forecasting.
The symptoms were easy to spot: finance saw a strange spike on the first day of each month, support tickets mentioned mismatched invoices, and logs showed orders marked paid but later reversed. The fix was to group by the true accounting date, represent refunds as negative amounts, and generate a complete month series so empty months displayed as 0 instead of disappearing.
-- Monthly Revenue Report in PostgreSQL
-- This example is fully self-contained: it builds sample data,
-- fills missing months with zeros, and handles a refund as a negative amount.
WITH orders(order_id, order_date, status, amount) AS (
VALUES
(1, DATE '2024-01-05', 'paid', 120.00::numeric),
(2, DATE '2024-01-20', 'paid', 80.00::numeric),
(3, DATE '2024-02-02', 'paid', 50.00::numeric),
(4, DATE '2024-02-18', 'canceled', 200.00::numeric),
(5, DATE '2024-03-10', 'paid', 90.00::numeric),
(6, DATE '2024-03-25', 'refunded', -30.00::numeric),
(7, DATE '2024-05-01', 'paid', 300.00::numeric)
),
-- Build the month range from the data so the report includes empty months.
-- This is the trick that turns a simple aggregation into a real dashboard report.
bounds AS (
SELECT
date_trunc('month', MIN(order_date))::date AS start_month,
date_trunc('month', MAX(order_date))::date AS end_month
FROM orders
),
months AS (
SELECT generate_series(start_month::timestamp, end_month::timestamp, interval '1 month')::date AS month_start
FROM bounds
),
monthly_revenue AS (
SELECT
date_trunc('month', order_date)::date AS month_start,
SUM(amount) AS revenue
FROM orders
WHERE status IN ('paid', 'refunded')
-- Canceled orders are excluded because they should not affect revenue.
-- Refunds are kept because the negative amount lowers net revenue.
GROUP BY 1
)
SELECT
to_char(m.month_start, 'YYYY-MM') AS month,
COALESCE(r.revenue, 0)::numeric(10,2) AS monthly_revenue
FROM months m
LEFT JOIN monthly_revenue r
ON r.month_start = m.month_start
ORDER BY m.month_start;
-- Expected shape of the result:
-- month | monthly_revenue
-- ---------+-----------------
-- 2024-01 | 200.00
-- 2024-02 | 50.00
-- 2024-03 | 60.00
-- 2024-04 | 0.00 <-- edge case: no revenue rows, but the month still appears
-- 2024-05 | 300.00Follow-up & Tricky Questions:
LEFT JOIN the totals and use COALESCE to replace null with zero. A plain GROUP BY cannot create missing months by itself.CASE expression. Then the monthly total is net revenue, not just gross sales.DATE_FORMAT or DATE_SUB, while SQL Server often uses DATEFROMPARTS. Keep the month key as a date, not a display string, until the final select.YYYY-MM text? You can for display, but it is safer to group by a true date first. Text keys are easier to sort incorrectly and harder to join to a calendar table.< next_month instead of BETWEEN? The half-open range avoids off-by-one errors with timestamps. BETWEEN is inclusive on both ends, which can accidentally double-count boundary values.GROUP BY return April with zero? No. It returns only months that exist in the filtered rows, which is why the calendar table or generated series is needed.DATE_TRUNC('month', timestamp) always safe? It is safe for month grouping, but not always for business meaning. If the company reports in a local time zone, truncate after converting to that zone so the date matches finance rules.UNION ALL into one transaction stream is often simpler for revenue math. A join can accidentally multiply rows if the relationship is not one-to-one.Common Mistakes:
YYYY-MM is fine for display, but keep a real date key for sorting and joining.LEFT JOIN.Memory Hook: Think of monthly revenue like coins sorted into calendar boxes: first put each payment into the right month box, then total each box, and leave empty boxes as zero.
Cheat Sheet:
DATE_TRUNC('month', ...).LEFT JOIN for zero months.Practice Tasks:
region column and produce monthly revenue per region.