Report queries: sales per month and top customers
50 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)The manager asks: "How much did we earn each month, and who are our best customers?" One answer must be a small, correct table of totals. How do you build it from raw order rows?
Revenue per month
A report query answers a business question with a small table of numbers, such as revenue per month. It is a GROUP BY with the right filters, aggregates and order, plus a check that the numbers add up.
ShopLite Express keeps one flat table of orders: customer, city, date, amount and status. Only DELIVERED orders count as revenue, so the query removes cancelled ones in WHERE and groups by month.
Example 1 · Monthly revenue for ShopLite Express
DROP TABLE IF EXISTS shop_orders;CREATE TABLE shop_orders ( order_id INT PRIMARY KEY, customer VARCHAR(20) NOT NULL, city VARCHAR(20) NOT NULL, order_date DATE NOT NULL, amount DECIMAL(8,2) NOT NULL, status VARCHAR(10) NOT NULL);
INSERT INTO shop_orders VALUES (1, 'Asha', 'Bengaluru', '2025-01-04', 240.00, 'DELIVERED'), (2, 'Ravi', 'Chennai', '2025-01-06', 180.00, 'DELIVERED'), (3, 'Kabir', 'Delhi', '2025-01-20', 410.00, 'DELIVERED'), (4, 'Meena', 'Chennai', '2025-01-25', 150.00, 'CANCELLED'), (5, 'Asha', 'Bengaluru', '2025-02-08', 290.00, 'DELIVERED'), (6, 'Nikhil', 'Delhi', '2025-02-12', 520.00, 'DELIVERED'), (7, 'Ravi', 'Chennai', '2025-02-19', 260.00, 'DELIVERED'), (8, 'Sana', 'Bengaluru', '2025-02-25', 640.00, 'DELIVERED'), (9, 'Asha', 'Bengaluru', '2025-03-03', 330.00, 'DELIVERED'), (10, 'Kabir', 'Delhi', '2025-03-07', 450.00, 'DELIVERED'), (11, 'Nikhil', 'Delhi', '2025-03-10', 610.00, 'DELIVERED'), (12, 'Meena', 'Chennai', '2025-03-18', 240.00, 'DELIVERED'), (13, 'Sana', 'Bengaluru', '2025-03-22', 330.00, 'DELIVERED'), (14, 'Nikhil', 'Delhi', '2025-03-29', 700.00, 'CANCELLED');
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, COUNT(*) AS orders, SUM(amount) AS revenue, ROUND(AVG(amount), 2) AS avg_orderFROM shop_ordersWHERE status = 'DELIVERED'GROUP BY monthORDER BY month;+---------+--------+---------+-----------+ | month | orders | revenue | avg_order | +---------+--------+---------+-----------+ | 2025-01 | 3 | 830.00 | 276.67 | | 2025-02 | 4 | 1710.00 | 427.50 | | 2025-03 | 5 | 1960.00 | 392.00 | +---------+--------+---------+-----------+
What this code does
WHEREremoves the 2 cancelled orders before grouping, so they are in neither the count nor the revenue.avg_orderis the average order value, the revenue divided by the number of delivered orders in that month, rounded to paise.- The
'%Y-%m'month text sorts in date order, soORDER BY monthlists January to March.
A month with no sales does not appear at all, because GROUP BY only makes groups from rows that exist; showing it as 0 needs the Joins module. Every example builds the same table first, so each one runs on its own.
Top customers by spending
A top-N report groups by something such as the customer, adds up a number, sorts from high to low and keeps the first N rows with LIMIT. It answers questions like "Who are our 3 best customers?".
Who are our 3 best customers? The first example groups by customer, adds up what each one spent, sorts and cuts with LIMIT 3, and then looks at the tie at the cut-off.
Example 2 · Top 3 customers, and the tie at the cut-off
DROP TABLE IF EXISTS shop_orders;CREATE TABLE shop_orders ( order_id INT PRIMARY KEY, customer VARCHAR(20) NOT NULL, city VARCHAR(20) NOT NULL, order_date DATE NOT NULL, amount DECIMAL(8,2) NOT NULL, status VARCHAR(10) NOT NULL);
INSERT INTO shop_orders VALUES (1, 'Asha', 'Bengaluru', '2025-01-04', 240.00, 'DELIVERED'), (2, 'Ravi', 'Chennai', '2025-01-06', 180.00, 'DELIVERED'), (3, 'Kabir', 'Delhi', '2025-01-20', 410.00, 'DELIVERED'), (4, 'Meena', 'Chennai', '2025-01-25', 150.00, 'CANCELLED'), (5, 'Asha', 'Bengaluru', '2025-02-08', 290.00, 'DELIVERED'), (6, 'Nikhil', 'Delhi', '2025-02-12', 520.00, 'DELIVERED'), (7, 'Ravi', 'Chennai', '2025-02-19', 260.00, 'DELIVERED'), (8, 'Sana', 'Bengaluru', '2025-02-25', 640.00, 'DELIVERED'), (9, 'Asha', 'Bengaluru', '2025-03-03', 330.00, 'DELIVERED'), (10, 'Kabir', 'Delhi', '2025-03-07', 450.00, 'DELIVERED'), (11, 'Nikhil', 'Delhi', '2025-03-10', 610.00, 'DELIVERED'), (12, 'Meena', 'Chennai', '2025-03-18', 240.00, 'DELIVERED'), (13, 'Sana', 'Bengaluru', '2025-03-22', 330.00, 'DELIVERED'), (14, 'Nikhil', 'Delhi', '2025-03-29', 700.00, 'CANCELLED');
-- All customers, so you can see the 3rd and 4thSELECT customer, COUNT(*) AS orders, SUM(amount) AS spentFROM shop_ordersWHERE status = 'DELIVERED'GROUP BY customerORDER BY spent DESC, customer;
-- Top 3SELECT customer, COUNT(*) AS orders, SUM(amount) AS spentFROM shop_ordersWHERE status = 'DELIVERED'GROUP BY customerORDER BY spent DESC, customerLIMIT 3;+----------+--------+---------+ | customer | orders | spent | +----------+--------+---------+ | Nikhil | 2 | 1130.00 | | Sana | 2 | 970.00 | | Asha | 3 | 860.00 | | Kabir | 2 | 860.00 | | Ravi | 2 | 440.00 | | Meena | 1 | 240.00 | +----------+--------+---------+ +----------+--------+---------+ | customer | orders | spent | +----------+--------+---------+ | Nikhil | 2 | 1130.00 | | Sana | 2 | 970.00 | | Asha | 3 | 860.00 | +----------+--------+---------+
What this code does
- Asha and Kabir both spent Rs 860.
ORDER BY spent DESC, customerputs Asha first by name, soLIMIT 3keeps her and drops Kabir. - The tie-breaker makes the result the same on every run, but the report should say how ties are handled, or list both.
Example 3 · ShopLite: the 3 biggest orders
SELECT order_id, SUM(quantity) AS units, SUM(quantity * unit_price) AS order_valueFROM order_itemsGROUP BY order_idORDER BY order_value DESC, order_idLIMIT 3;+----------+-------+-------------+ | order_id | units | order_value | +----------+-------+-------------+ | 4 | 1 | 69900.00 | | 8 | 1 | 64990.00 | | 3 | 2 | 43839.00 | +----------+-------+-------------+
What this code does
- The same top-N pattern on the ShopLite practice tables: group the order lines by
order_id, add up their value, sort and keep 3. - Order 4 is the biggest, but it was cancelled.
order_itemshas no status column; filtering by status needs theorderstable too, which the Joins module adds.
If the 3rd and 4th customers spent the same amount, LIMIT 3 keeps one and silently drops the other. A tie-breaker in ORDER BY makes the choice fixed, but it is still a choice; the window functions lesson shows DENSE_RANK, which gives both rank 3.
Predict the output
Which order wins?
Read the code and pick what it prints.
Round 1 of 2
Order values are quantity times unit price, per order.
Subtotals and grand totals with WITH ROLLUP
WITH ROLLUP after GROUP BY adds extra summary rows: a subtotal for each value of the first grouping column and a grand total at the end. In a summary row, the rolled-up columns show NULL.
A city report with a subtotal per city and a grand total would need three queries glued together. WITH ROLLUP gives all three levels in one result; the second example labels the summary rows with GROUPING().
Example 4 · WITH ROLLUP: subtotals per city and a grand total
DROP TABLE IF EXISTS shop_orders;CREATE TABLE shop_orders ( order_id INT PRIMARY KEY, customer VARCHAR(20) NOT NULL, city VARCHAR(20) NOT NULL, order_date DATE NOT NULL, amount DECIMAL(8,2) NOT NULL, status VARCHAR(10) NOT NULL);
INSERT INTO shop_orders VALUES (1, 'Asha', 'Bengaluru', '2025-01-04', 240.00, 'DELIVERED'), (2, 'Ravi', 'Chennai', '2025-01-06', 180.00, 'DELIVERED'), (3, 'Kabir', 'Delhi', '2025-01-20', 410.00, 'DELIVERED'), (4, 'Meena', 'Chennai', '2025-01-25', 150.00, 'CANCELLED'), (5, 'Asha', 'Bengaluru', '2025-02-08', 290.00, 'DELIVERED'), (6, 'Nikhil', 'Delhi', '2025-02-12', 520.00, 'DELIVERED'), (7, 'Ravi', 'Chennai', '2025-02-19', 260.00, 'DELIVERED'), (8, 'Sana', 'Bengaluru', '2025-02-25', 640.00, 'DELIVERED'), (9, 'Asha', 'Bengaluru', '2025-03-03', 330.00, 'DELIVERED'), (10, 'Kabir', 'Delhi', '2025-03-07', 450.00, 'DELIVERED'), (11, 'Nikhil', 'Delhi', '2025-03-10', 610.00, 'DELIVERED'), (12, 'Meena', 'Chennai', '2025-03-18', 240.00, 'DELIVERED'), (13, 'Sana', 'Bengaluru', '2025-03-22', 330.00, 'DELIVERED'), (14, 'Nikhil', 'Delhi', '2025-03-29', 700.00, 'CANCELLED');
SELECT city, DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(amount) AS revenueFROM shop_ordersWHERE status = 'DELIVERED'GROUP BY city, month WITH ROLLUPORDER BY GROUPING(city), city, GROUPING(month), month;+-----------+---------+---------+ | city | month | revenue | +-----------+---------+---------+ | Bengaluru | 2025-01 | 240.00 | | Bengaluru | 2025-02 | 930.00 | | Bengaluru | 2025-03 | 660.00 | | Bengaluru | NULL | 1830.00 | | Chennai | 2025-01 | 180.00 | | Chennai | 2025-02 | 260.00 | | Chennai | 2025-03 | 240.00 | | Chennai | NULL | 680.00 | | Delhi | 2025-01 | 410.00 | | Delhi | 2025-02 | 520.00 | | Delhi | 2025-03 | 1060.00 | | Delhi | NULL | 1990.00 | | NULL | NULL | 4500.00 | +-----------+---------+---------+
What this code does
- Each city has its 3 month rows, then a row with
month=NULL: that city's subtotal. The last row, with both columnsNULL, is the grand total. ORDER BY GROUPING(city), city, GROUPING(month), monthputs the detail rows (0) before the summary rows (1) inside each level. Sorting bycity, monthalone would move theNULLrows to the top.
Example 5 · Readable labels with GROUPING()
DROP TABLE IF EXISTS shop_orders;CREATE TABLE shop_orders ( order_id INT PRIMARY KEY, customer VARCHAR(20) NOT NULL, city VARCHAR(20) NOT NULL, order_date DATE NOT NULL, amount DECIMAL(8,2) NOT NULL, status VARCHAR(10) NOT NULL);
INSERT INTO shop_orders VALUES (1, 'Asha', 'Bengaluru', '2025-01-04', 240.00, 'DELIVERED'), (2, 'Ravi', 'Chennai', '2025-01-06', 180.00, 'DELIVERED'), (3, 'Kabir', 'Delhi', '2025-01-20', 410.00, 'DELIVERED'), (4, 'Meena', 'Chennai', '2025-01-25', 150.00, 'CANCELLED'), (5, 'Asha', 'Bengaluru', '2025-02-08', 290.00, 'DELIVERED'), (6, 'Nikhil', 'Delhi', '2025-02-12', 520.00, 'DELIVERED'), (7, 'Ravi', 'Chennai', '2025-02-19', 260.00, 'DELIVERED'), (8, 'Sana', 'Bengaluru', '2025-02-25', 640.00, 'DELIVERED'), (9, 'Asha', 'Bengaluru', '2025-03-03', 330.00, 'DELIVERED'), (10, 'Kabir', 'Delhi', '2025-03-07', 450.00, 'DELIVERED'), (11, 'Nikhil', 'Delhi', '2025-03-10', 610.00, 'DELIVERED'), (12, 'Meena', 'Chennai', '2025-03-18', 240.00, 'DELIVERED'), (13, 'Sana', 'Bengaluru', '2025-03-22', 330.00, 'DELIVERED'), (14, 'Nikhil', 'Delhi', '2025-03-29', 700.00, 'CANCELLED');
SELECT IF(GROUPING(city), 'All cities', city) AS city, COUNT(*) AS orders, SUM(amount) AS revenueFROM shop_ordersWHERE status = 'DELIVERED'GROUP BY city WITH ROLLUPORDER BY GROUPING(city), revenue DESC;+------------+--------+---------+ | city | orders | revenue | +------------+--------+---------+ | Delhi | 4 | 1990.00 | | Bengaluru | 5 | 1830.00 | | Chennai | 3 | 680.00 | | All cities | 12 | 4500.00 | +------------+--------+---------+
What this code does
GROUPING(city)is 1 only in the rollup row, soIFshowsAll citiesthere and the real city everywhere else.- Ordering by
GROUPING(city)first keeps the total at the bottom;revenue DESCsorts the cities inside the detail rows.
GROUPING(column) returns 1 in a summary row where that column was rolled up, and 0 otherwise. It needs MySQL 8.0, and ORDER BY together with ROLLUP needs 8.0.12 or later. PostgreSQL writes it as GROUP BY ROLLUP (city, month).
COALESCE(city, 'All cities'). If some real rows have a NULL city, they would be labelled as a total too. GROUPING() tells the two apart.Turning a business question into GROUP BY
Most report requests fit the same six steps. The example answers one request clause by clause, with a comment for each step, and then checks the total against the raw data.
- One row of the answer is one what? A city, a month, a customer → that goes in
GROUP BY. - Which numbers does each row need? Revenue, orders, customers →
SUM,COUNT,COUNT(DISTINCT ...). - Which rows count? Delivered only, this quarter only →
WHERE. - Which groups should be shown? Above Rs 1,000, at least 2 orders →
HAVING. - In what order, and how many? →
ORDER BYwith a tie-breaker, thenLIMIT. - Check the result against the raw data: a single
SUMover the same rows must equal the report's total.
Example 6 · From question to query, then check it
DROP TABLE IF EXISTS shop_orders;CREATE TABLE shop_orders ( order_id INT PRIMARY KEY, customer VARCHAR(20) NOT NULL, city VARCHAR(20) NOT NULL, order_date DATE NOT NULL, amount DECIMAL(8,2) NOT NULL, status VARCHAR(10) NOT NULL);
INSERT INTO shop_orders VALUES (1, 'Asha', 'Bengaluru', '2025-01-04', 240.00, 'DELIVERED'), (2, 'Ravi', 'Chennai', '2025-01-06', 180.00, 'DELIVERED'), (3, 'Kabir', 'Delhi', '2025-01-20', 410.00, 'DELIVERED'), (4, 'Meena', 'Chennai', '2025-01-25', 150.00, 'CANCELLED'), (5, 'Asha', 'Bengaluru', '2025-02-08', 290.00, 'DELIVERED'), (6, 'Nikhil', 'Delhi', '2025-02-12', 520.00, 'DELIVERED'), (7, 'Ravi', 'Chennai', '2025-02-19', 260.00, 'DELIVERED'), (8, 'Sana', 'Bengaluru', '2025-02-25', 640.00, 'DELIVERED'), (9, 'Asha', 'Bengaluru', '2025-03-03', 330.00, 'DELIVERED'), (10, 'Kabir', 'Delhi', '2025-03-07', 450.00, 'DELIVERED'), (11, 'Nikhil', 'Delhi', '2025-03-10', 610.00, 'DELIVERED'), (12, 'Meena', 'Chennai', '2025-03-18', 240.00, 'DELIVERED'), (13, 'Sana', 'Bengaluru', '2025-03-22', 330.00, 'DELIVERED'), (14, 'Nikhil', 'Delhi', '2025-03-29', 700.00, 'CANCELLED');
-- Question: which cities brought more than Rs 1,000 of delivered revenue-- in Q1 2025, from how many customers? Biggest city first.SELECT city, -- one row per city COUNT(DISTINCT customer) AS customers, -- numbers per row SUM(amount) AS revenueFROM shop_ordersWHERE status = 'DELIVERED' -- which rows count AND order_date BETWEEN '2025-01-01' AND '2025-03-31'GROUP BY cityHAVING SUM(amount) > 1000 -- which groups to showORDER BY revenue DESC, city; -- order
-- Check: the same rows, no grouping. Must equal all cities added up.SELECT COUNT(*) AS delivered_orders, SUM(amount) AS delivered_revenueFROM shop_ordersWHERE status = 'DELIVERED' AND order_date BETWEEN '2025-01-01' AND '2025-03-31';+-----------+-----------+---------+ | city | customers | revenue | +-----------+-----------+---------+ | Delhi | 2 | 1990.00 | | Bengaluru | 2 | 1830.00 | +-----------+-----------+---------+ +------------------+-------------------+ | delivered_orders | delivered_revenue | +------------------+-------------------+ | 12 | 4500.00 | +------------------+-------------------+
What this code does
- Each comment matches one of the six steps. Chennai (Rs 680) is dropped by
HAVING, so the report shows 2 cities. - The check query uses the same
WHEREand noGROUP BY. Delhi 1,990 + Bengaluru 1,830 + Chennai 680 = 4,500, which matchesdelivered_revenue. If it did not, a filter or a group would be wrong.
Next step
Check your understanding
11 quick questions