DATE_FORMAT(date, pattern) turns a date into text. The pattern '%Y-%m' gives 2026-02, which sorts correctly as text and makes a perfect reporting month. Other useful codes: %d day, %b short month name (Feb), %W weekday name, %Y-%u year-week.
11. Dates and window functions
Step 1 of 4 · Lesson
Lesson 11 of 21
MySQL date reporting and window functions: ROW_NUMBER, RANK, LAG, running totals
Report by month with DATE_FORMAT, then use window functions — ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD and SUM OVER — on the shop and on a 4,000-order dataset.
In short
What is a window function in MySQL?
A window function calculates across a set of related rows but, unlike GROUP BY, keeps every row in the result. You write it with OVER: SUM(revenue) OVER (ORDER BY month) gives a running total, and ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) numbers each customer's orders. MySQL supports them from 8.0.
By the end of this lesson you can
- Group sales by month with DATE_FORMAT and filter periods with half-open ranges
- Explain how a window function differs from GROUP BY
- Number, rank and pick the top N rows per group with ROW_NUMBER, RANK and DENSE_RANK
- Compare each row with the previous one using LAG and LEAD
- Build running totals with SUM() OVER and a ROWS frame
Monthly reporting with DATE_FORMAT
SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month,
COUNT(DISTINCT o.order_id) AS orders,
SUM(i.quantity * i.unit_price) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY month
ORDER BY month;| month | orders | revenue |
|---|---|---|
| 2026-01 | 2 | 1600.00 |
| 2026-02 | 2 | 1950.00 |
| 2026-03 | 2 | 2600.00 |
1600 + 1950 + 2600 = 6150 — the same control total as every earlier lesson.
Months with no sales disappear
GROUP BY only creates groups for values that exist. If a month had no completed orders, it would simply be missing — and a chart would draw a straight line over the gap. Real reports join to a calendar of all months. A recursive CTE can generate one:
WITH RECURSIVE months AS (
SELECT DATE('2026-01-01') AS month_start
UNION ALL
SELECT DATE_ADD(month_start, INTERVAL 1 MONTH) FROM months WHERE month_start < '2026-04-01'
)
SELECT DATE_FORMAT(m.month_start, '%Y-%m') AS month,
COUNT(o.order_id) AS orders
FROM months m
LEFT JOIN orders o
ON o.order_date >= m.month_start
AND o.order_date < DATE_ADD(m.month_start, INTERVAL 1 MONTH)
GROUP BY m.month_start
ORDER BY m.month_start;| month | orders |
|---|---|
| 2026-01 | 2 |
| 2026-02 | 3 |
| 2026-03 | 3 |
| 2026-04 | 0 |
April 2026 now shows 0 orders instead of vanishing. You do not need to master recursive CTEs yet — just remember that missing months must be added on purpose.
Window functions keep every row
GROUP BY collapses rows into one per group. A window function looks at a set of related rows — the window — but returns a value for every row. You recognise it by OVER (…):
function() OVER (PARTITION BY … ORDER BY … ROWS BETWEEN …)
- PARTITION BY — split rows into independent groups (like GROUP BY, without collapsing). Leave it out to use all rows.
- ORDER BY — the order inside each partition (needed for numbering, ranking, LAG and running totals).
- Frame (
ROWS BETWEEN …) — which rows around the current one to include.
SELECT order_id, customer_id, order_date,
COUNT(*) OVER (PARTITION BY customer_id) AS orders_by_customer,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS nth_order
FROM orders
ORDER BY customer_id, order_date;| order_id | customer_id | order_date | orders_by_customer | nth_order |
|---|---|---|---|---|
| 101 | 1 | 2026-01-05 | 2 | 1 |
| 103 | 1 | 2026-02-03 | 2 | 2 |
| 102 | 2 | 2026-01-08 | 2 | 1 |
| 106 | 2 | 2026-03-02 | 2 | 2 |
| 104 | 3 | 2026-02-10 | 1 | 1 |
| 105 | 4 | 2026-02-15 | 2 | 1 |
| 108 | 4 | 2026-03-11 | 2 | 2 |
| 107 | 5 | 2026-03-04 | 1 | 1 |
Every order is still there, yet each row also knows how many orders its customer placed and whether it was their 1st or 2nd.
ROW_NUMBER, RANK and DENSE_RANK
All three number rows by an order, but treat ties differently. With completed order totals 1400, 1300, 1300, 1300, 550, 300:
| Function | Ties | Values |
|---|---|---|
ROW_NUMBER() |
always unique | 1, 2, 3, 4, 5, 6 |
RANK() |
same rank, then a gap | 1, 2, 2, 2, 5, 6 |
DENSE_RANK() |
same rank, no gap | 1, 2, 2, 2, 3, 4 |
WITH totals AS (
SELECT o.order_id, SUM(i.quantity * i.unit_price) AS total
FROM orders o JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY o.order_id
)
SELECT order_id, total,
ROW_NUMBER() OVER (ORDER BY total DESC, order_id) AS row_num,
RANK() OVER (ORDER BY total DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY total DESC) AS dense_rnk
FROM totals
ORDER BY total DESC, order_id;| order_id | total | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| 103 | 1400.00 | 1 | 1 | 1 |
| 102 | 1300.00 | 2 | 2 | 2 |
| 106 | 1300.00 | 3 | 2 | 2 |
| 108 | 1300.00 | 4 | 2 | 2 |
| 105 | 550.00 | 5 | 5 | 3 |
| 101 | 300.00 | 6 | 6 | 4 |
Top N per group
The best-selling product in each category, each customer's latest order — these need ROW_NUMBER inside a CTE, then a filter, because window results cannot be used in WHERE directly. Here on the large dataset (4,000 orders, 40 products):
WITH product_revenue AS (
SELECT p.category, p.product_name,
SUM(i.quantity * i.unit_price) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
JOIN products p ON p.product_id = i.product_id
WHERE o.status = 'completed'
GROUP BY p.category, p.product_id, p.product_name
),
ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM product_revenue
)
SELECT category, product_name, revenue
FROM ranked
WHERE rn = 1
ORDER BY revenue DESC;| category | product_name | revenue |
|---|---|---|
| Electronics | Webcam | 1976520.00 |
| Sports | Badminton set | 1042720.00 |
| Home | Wall clock | 511830.00 |
| Books | SQL handbook | 405665.00 |
| Stationery | Planner | 188310.00 |
Change rn = 1 to rn <= 3 for the top three per category. Use DENSE_RANK instead of ROW_NUMBER if ties should all be included.
LAG and LEAD: compare with the previous row
LAG(column) reads the value from the previous row in the window; LEAD(column) from the next. They make change vs last month and days since the previous order easy:
SELECT order_id, order_date,
LAG(order_date) OVER (ORDER BY order_date) AS previous_order,
DATEDIFF(order_date, LAG(order_date) OVER (ORDER BY order_date)) AS gap_days
FROM orders
ORDER BY order_date;| order_id | order_date | previous_order | gap_days |
|---|---|---|---|
| 101 | 2026-01-05 | NULL | NULL |
| 102 | 2026-01-08 | 2026-01-05 | 3 |
| 103 | 2026-02-03 | 2026-01-08 | 26 |
| 104 | 2026-02-10 | 2026-02-03 | 7 |
| 105 | 2026-02-15 | 2026-02-10 | 5 |
| 106 | 2026-03-02 | 2026-02-15 | 15 |
| 107 | 2026-03-04 | 2026-03-02 | 2 |
| 108 | 2026-03-11 | 2026-03-04 | 7 |
The first row has no previous row, so LAG returns NULL. LAG(x, 1, 0) would return 0 instead.
Running totals and the window frame
SUM(x) OVER (ORDER BY …) adds up everything from the first row to the current one. State the frame explicitly — ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — because the default frame (RANGE) treats tied ORDER BY values as one step, which surprises people.
- 2026-011600
- 2026-021950
- 2026-032600
- 2026-041200
- 2026-052100
- 2026-061800
WITH monthly AS (
SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month,
SUM(i.quantity * i.unit_price) AS revenue
FROM orders o JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'completed' AND o.order_date >= '2026-01-01'
GROUP BY month
)
SELECT month, revenue,
SUM(revenue) OVER (ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS year_to_date,
ROUND(AVG(revenue) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_3m
FROM monthly
ORDER BY month;| month | revenue | year_to_date | moving_avg_3m |
|---|---|---|---|
| 2026-01 | 1168080.00 | 1168080.00 | 1168080.00 |
| 2026-02 | 1035760.00 | 2203840.00 | 1101920.00 |
| 2026-03 | 861770.00 | 3065610.00 | 1021870.00 |
| 2026-04 | 864950.00 | 3930560.00 | 920826.67 |
| 2026-05 | 980960.00 | 4911520.00 | 902560.00 |
| 2026-06 | 656000.00 | 5567520.00 | 833970.00 |
On the large dataset this gives year-to-date revenue for January–June 2026 and a 3-month moving average that smooths out noisy months.
Common mistakes
Wrong
GROUP BY MONTH(order_date)Fix
GROUP BY DATE_FORMAT(order_date, '%Y-%m') (or YEAR and MONTH together).Why: Merges the same month from different years.
Wrong
WHERE ROW_NUMBER() OVER (…) = 1Fix
Compute it in a CTE, then filter in the outer query.
Why: Window functions are calculated after WHERE (error 3593).
Wrong
SUM(x) OVER (ORDER BY day) with repeated daysFix
Add ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (and a unique tiebreaker).
Why: The default RANGE frame adds all rows with the same day at once.
Wrong
Using RANK when you need exactly N rows
Fix
ROW_NUMBER with a tiebreaker for exactly N; DENSE_RANK to include ties on purpose.Why: Ties can give more than N rows with rank ≤ N.
Check yourself
- What is the difference between GROUP BY customer_id and PARTITION BY customer_id?
- Totals 1400, 1300, 1300, 550: what do RANK and DENSE_RANK return?
- Why must a top-N-per-group query use a CTE?
Answer out loud or in your notebook. If one is hard, reread that section before practising.
Frequently asked questions
Which MySQL version supports window functions?
MySQL 8.0 and newer. MySQL 5.7 has no window functions or CTEs.
What is the difference between RANK and DENSE_RANK?
Both give tied rows the same rank. RANK then skips numbers (1, 2, 2, 4); DENSE_RANK does not (1, 2, 2, 3).
How do I get the latest order per customer in MySQL?
Number each customer's orders with ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) in a CTE, then keep rows where the number is 1.
Quick quiz — 4 questions
Quick Check: Dates and window functions
Meet Learning Shop
One small fictional shop powers the whole course: 6 customers, 6 products, 8 orders and 14 order lines, small enough to check every answer by eye. The practice editor on this page already contains it. To practise in MySQL Workbench, import it once.
customers.customer_id → orders.customer_id · orders.order_id → order_items.order_id · products.product_id → order_items.product_id
Import in MySQL Workbench
- Connect to your local MySQL server (8.0.16 or newer; 8.4 recommended).
- Choose File → Open SQL Script and open the downloaded file.
- Click the lightning bolt to execute the whole script. The last result shows 6, 6, 8 and 14 rows.
- Refresh the Schemas panel, then select the database:
USE portfolio_retail_lab;
SHOW TABLES;Command line instead: mysql -u root -p < mysql-retail-lab.sql
In the practice editor, the Reset database button does the same as the reset script — instantly and only in your browser.
Let’s do one together
Build a finance-ready monthly report with a running total.
- 1Run the query: monthly completed revenue and the cumulative total.
- 2Check the last cumulative value: 6150.00, the course control total.
- 3Add LAG(revenue) OVER (ORDER BY month) AS previous_month and a column revenue - LAG(revenue) OVER (ORDER BY month) AS change.
- 4Switch the editor to the Large dataset and run it again — 18 months from 2025-01 to 2026-06.
WITH m AS (
SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month,
SUM(i.quantity * i.unit_price) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY month
)
SELECT month, revenue,
SUM(revenue) OVER (ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_revenue
FROM m
ORDER BY month;What you should see
Three rows: 2026-01 1600 / 1600, 2026-02 1950 / 3550, 2026-03 2600 / 6150.
| month | revenue | cumulative_revenue |
|---|---|---|
| 2026-01 | 1600.00 | 1600.00 |
| 2026-02 | 1950.00 | 3550.00 |
| 2026-03 | 2600.00 | 6150.00 |
Practice problems
20 problems from Easy to Professional · 0 solved · J / K to move
List February orders using a half-open date range.
Target: 103,104,105; cancelled orders remain because no status filter was requested.
Database already selected: Learning Shop (small)(portfolio_retail_lab)— write the query directly, no USE needed.
I’m stuck — show a hint
Use >= February 1 and < March 1.
Reference answer
SELECT order_id FROM orders WHERE order_date >= '2026-02-01' AND order_date < '2026-03-01' ORDER BY order_id;| order_id |
|---|
| 103 |
| 104 |
| 105 |
More than one query can be correct. If your result matches the target, your query is right.
Label products as premium if price is at least 800.
Target: Desk lamp and Keyboard premium; four standard.
I’m stuck — show a hint
CASE WHEN ... THEN ... ELSE ... END.
Reference answer
SELECT product_name,CASE WHEN price>=800 THEN 'premium' ELSE 'standard' END AS tier FROM products ORDER BY product_id;| product_name | tier |
|---|---|
| Notebook | standard |
| Pen | standard |
| Desk lamp | premium |
| Mug | standard |
| Keyboard | premium |
| Planner | standard |
More than one query can be correct. If your result matches the target, your query is right.
Rank completed orders by their total, preserving ties.
Target: 103 rank 1; 102,106,108 rank 2; 105 rank 3; 101 rank 4.
I’m stuck — show a hint
First aggregate to order grain, then DENSE_RANK by total DESC.
Reference answer
WITH t AS (SELECT o.order_id,SUM(i.quantity*i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed' GROUP BY o.order_id) SELECT *,DENSE_RANK() OVER(ORDER BY total DESC) AS revenue_rank FROM t ORDER BY total DESC,order_id;| order_id | total | revenue_rank |
|---|---|---|
| 103 | 1400.00 | 1 |
| 102 | 1300.00 | 2 |
| 106 | 1300.00 | 2 |
| 108 | 1300.00 | 2 |
| 105 | 550.00 | 3 |
| 101 | 300.00 | 4 |
More than one query can be correct. If your result matches the target, your query is right.
Create monthly and cumulative revenue for a finance handoff.
Target: Monthly 1600,1950,2600; cumulative 1600,3550,6150. State that absent months need a calendar table in a larger dataset.
I’m stuck — show a hint
Use a monthly CTE followed by ordered SUM OVER.
Reference answer
WITH m AS (SELECT DATE_FORMAT(o.order_date,'%Y-%m') AS month,SUM(i.quantity*i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed' GROUP BY month) SELECT month,revenue,SUM(revenue) OVER(ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_revenue FROM m ORDER BY month;| month | revenue | cumulative_revenue |
|---|---|---|
| 2026-01 | 1600.00 | 1600.00 |
| 2026-02 | 1950.00 | 3550.00 |
| 2026-03 | 2600.00 | 6150.00 |
More than one query can be correct. If your result matches the target, your query is right.
Show each order with its year and month number.
Target: 8 rows; first (101, 2026, 1), last (108, 2026, 3)
I’m stuck — show a hint
YEAR() and MONTH().
Reference answer
SELECT order_id, YEAR(order_date) AS yr, MONTH(order_date) AS mon FROM orders ORDER BY order_id;| order_id | yr | mon |
|---|---|---|
| 101 | 2026 | 1 |
| 102 | 2026 | 1 |
| 103 | 2026 | 2 |
| 104 | 2026 | 2 |
| 105 | 2026 | 2 |
| 106 | 2026 | 3 |
| 107 | 2026 | 3 |
| 108 | 2026 | 3 |
More than one query can be correct. If your result matches the target, your query is right.
Show each order with the weekday name of its date.
Target: 8 rows; 101 Monday … 108 Wednesday
I’m stuck — show a hint
DAYNAME().
Reference answer
SELECT order_id, order_date, DAYNAME(order_date) AS weekday FROM orders ORDER BY order_id;| order_id | order_date | weekday |
|---|---|---|
| 101 | 2026-01-05 | Monday |
| 102 | 2026-01-08 | Thursday |
| 103 | 2026-02-03 | Tuesday |
| 104 | 2026-02-10 | Tuesday |
| 105 | 2026-02-15 | Sunday |
| 106 | 2026-03-02 | Monday |
| 107 | 2026-03-04 | Wednesday |
| 108 | 2026-03-11 | Wednesday |
More than one query can be correct. If your result matches the target, your query is right.
List orders placed in January 2026 using a half-open range.
Target: 2 rows: 101, 102
I’m stuck — show a hint
>= '2026-01-01' AND < '2026-02-01'.
Reference answer
SELECT order_id, order_date FROM orders WHERE order_date >= '2026-01-01' AND order_date < '2026-02-01' ORDER BY order_id;| order_id | order_date |
|---|---|
| 101 | 2026-01-05 |
| 102 | 2026-01-08 |
More than one query can be correct. If your result matches the target, your query is right.
Label orders as 'revenue' if completed, else 'not revenue'.
Target: 8 rows; 104 and 107 are 'not revenue'
I’m stuck — show a hint
CASE WHEN status = 'completed'.
Reference answer
SELECT order_id, CASE WHEN status = 'completed' THEN 'revenue' ELSE 'not revenue' END AS label FROM orders ORDER BY order_id;| order_id | label |
|---|---|
| 101 | revenue |
| 102 | revenue |
| 103 | revenue |
| 104 | not revenue |
| 105 | revenue |
| 106 | revenue |
| 107 | not revenue |
| 108 | revenue |
More than one query can be correct. If your result matches the target, your query is right.
Show days between each order and 2026-03-31.
Target: 8 rows; first (101, 85), last (108, 20)
I’m stuck — show a hint
DATEDIFF(later, earlier).
Reference answer
SELECT order_id, DATEDIFF('2026-03-31', order_date) AS days_ago FROM orders ORDER BY order_id;| order_id | days_ago |
|---|---|
| 101 | 85 |
| 102 | 82 |
| 103 | 56 |
| 104 | 49 |
| 105 | 44 |
| 106 | 29 |
| 107 | 27 |
| 108 | 20 |
More than one query can be correct. If your result matches the target, your query is right.
Number all orders in date order.
Target: 8 rows; 101 is 1, 108 is 8
I’m stuck — show a hint
ROW_NUMBER() OVER (ORDER BY order_date).
Reference answer
SELECT order_id, order_date, ROW_NUMBER() OVER (ORDER BY order_date) AS seq FROM orders ORDER BY seq;| order_id | order_date | seq |
|---|---|---|
| 101 | 2026-01-05 | 1 |
| 102 | 2026-01-08 | 2 |
| 103 | 2026-02-03 | 3 |
| 104 | 2026-02-10 | 4 |
| 105 | 2026-02-15 | 5 |
| 106 | 2026-03-02 | 6 |
| 107 | 2026-03-04 | 7 |
| 108 | 2026-03-11 | 8 |
More than one query can be correct. If your result matches the target, your query is right.
Number each customer's orders 1, 2, ... in date order.
Target: 8 rows; Asha 101 → 1, 103 → 2
I’m stuck — show a hint
PARTITION BY customer_id.
Reference answer
SELECT customer_id, order_id, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS nth_order FROM orders ORDER BY customer_id, nth_order;| customer_id | order_id | nth_order |
|---|---|---|
| 1 | 101 | 1 |
| 1 | 103 | 2 |
| 2 | 102 | 1 |
| 2 | 106 | 2 |
| 3 | 104 | 1 |
| 4 | 105 | 1 |
| 4 | 108 | 2 |
| 5 | 107 | 1 |
More than one query can be correct. If your result matches the target, your query is right.
Rank products by price, highest first, using RANK.
Target: 6 rows: Keyboard 1, Desk lamp 2, Planner 3, Mug 4, Notebook 5, Pen 6
I’m stuck — show a hint
RANK() OVER (ORDER BY price DESC).
Reference answer
SELECT product_name, price, RANK() OVER (ORDER BY price DESC) AS price_rank FROM products ORDER BY price_rank, product_id;| product_name | price | price_rank |
|---|---|---|
| Keyboard | 1200.00 | 1 |
| Desk lamp | 800.00 | 2 |
| Planner | 300.00 | 3 |
| Mug | 250.00 | 4 |
| Notebook | 100.00 | 5 |
| Pen | 20.00 | 6 |
More than one query can be correct. If your result matches the target, your query is right.
Count completed vs other orders per month in one row per month.
Target: 3 rows: (2026-01, 2, 0); (2026-02, 2, 1); (2026-03, 2, 1)
I’m stuck — show a hint
SUM(CASE WHEN ... THEN 1 ELSE 0 END).
Reference answer
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status <> 'completed' THEN 1 ELSE 0 END) AS other FROM orders GROUP BY month ORDER BY month;| month | completed | other |
|---|---|---|
| 2026-01 | 2 | 0 |
| 2026-02 | 2 | 1 |
| 2026-03 | 2 | 1 |
More than one query can be correct. If your result matches the target, your query is right.
Show each customer's first order date.
Target: 5 rows: (1, 01-05); (2, 01-08); (3, 02-10); (4, 02-15); (5, 03-04), all 2026
I’m stuck — show a hint
MIN(order_date) per customer, or ROW_NUMBER = 1.
Reference answer
SELECT customer_id, MIN(order_date) AS first_order FROM orders GROUP BY customer_id ORDER BY customer_id;| customer_id | first_order |
|---|---|
| 1 | 2026-01-05 |
| 2 | 2026-01-08 |
| 3 | 2026-02-10 |
| 4 | 2026-02-15 |
| 5 | 2026-03-04 |
More than one query can be correct. If your result matches the target, your query is right.
Show each order with the previous order date and days since it (all orders, by date).
Target: 8 rows; first gap NULL, last (108, prev 2026-03-04, 7 days)
I’m stuck — show a hint
LAG(order_date).
Reference answer
SELECT order_id, order_date, LAG(order_date) OVER (ORDER BY order_date) AS prev_date, DATEDIFF(order_date, LAG(order_date) OVER (ORDER BY order_date)) AS gap_days FROM orders ORDER BY order_date;| order_id | order_date | prev_date | gap_days |
|---|---|---|---|
| 101 | 2026-01-05 | NULL | NULL |
| 102 | 2026-01-08 | 2026-01-05 | 3 |
| 103 | 2026-02-03 | 2026-01-08 | 26 |
| 104 | 2026-02-10 | 2026-02-03 | 7 |
| 105 | 2026-02-15 | 2026-02-10 | 5 |
| 106 | 2026-03-02 | 2026-02-15 | 15 |
| 107 | 2026-03-04 | 2026-03-02 | 2 |
| 108 | 2026-03-11 | 2026-03-04 | 7 |
More than one query can be correct. If your result matches the target, your query is right.
Show monthly completed revenue with the change from the previous month.
Target: 3 rows: (2026-01, 1600, NULL); (2026-02, 1950, 350); (2026-03, 2600, 650)
I’m stuck — show a hint
Monthly CTE, then revenue - LAG(revenue).
Reference answer
WITH m AS (SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY month) SELECT month, revenue, revenue - LAG(revenue) OVER (ORDER BY month) AS change_vs_prev FROM m ORDER BY month;| month | revenue | change_vs_prev |
|---|---|---|
| 2026-01 | 1600.00 | NULL |
| 2026-02 | 1950.00 | 350.00 |
| 2026-03 | 2600.00 | 650.00 |
More than one query can be correct. If your result matches the target, your query is right.
Find each customer's most recent order (all statuses).
Target: 5 rows: (1, 103); (2, 106); (3, 104); (4, 108); (5, 107)
I’m stuck — show a hint
ROW_NUMBER DESC inside a CTE, keep 1.
Reference answer
WITH r AS (SELECT customer_id, order_id, order_date, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn FROM orders) SELECT customer_id, order_id, order_date FROM r WHERE rn = 1 ORDER BY customer_id;| customer_id | order_id | order_date |
|---|---|---|
| 1 | 103 | 2026-02-03 |
| 2 | 106 | 2026-03-02 |
| 3 | 104 | 2026-02-10 |
| 4 | 108 | 2026-03-11 |
| 5 | 107 | 2026-03-04 |
More than one query can be correct. If your result matches the target, your query is right.
Customer running total: completed order totals in date order and cumulative spend per customer.
Target: 6 rows; running ends at Asha 1700, Bilal 2600, Dev 1850
I’m stuck — show a hint
Order-total CTE, then SUM OVER (PARTITION BY customer ORDER BY date).
Reference answer
WITH t AS (SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date) SELECT customer_id, order_id, total, SUM(total) OVER (PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_spend FROM t ORDER BY customer_id, order_date;| customer_id | order_id | total | running_spend |
|---|---|---|---|
| 1 | 101 | 300.00 | 300.00 |
| 1 | 103 | 1400.00 | 1700.00 |
| 2 | 102 | 1300.00 | 1300.00 |
| 2 | 106 | 1300.00 | 2600.00 |
| 4 | 105 | 550.00 | 550.00 |
| 4 | 108 | 1300.00 | 1850.00 |
More than one query can be correct. If your result matches the target, your query is right.
Top product per category by completed revenue (ties kept).
Target: 3 rows: Electronics Keyboard 2400; Home Mug 1250; Stationery Notebook 600
I’m stuck — show a hint
Revenue per product CTE, DENSE_RANK PARTITION BY category, keep 1.
Reference answer
WITH r AS (SELECT p.category, p.product_name, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' GROUP BY p.category, p.product_id, p.product_name), k AS (SELECT *, DENSE_RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rk FROM r) SELECT category, product_name, revenue FROM k WHERE rk = 1 ORDER BY category;| category | product_name | revenue |
|---|---|---|
| Electronics | Keyboard | 2400.00 |
| Home | Mug | 1250.00 |
| Stationery | Notebook | 600.00 |
More than one query can be correct. If your result matches the target, your query is right.
On the large dataset, show completed revenue per month for 2025 with its rank (highest month = 1).
Target: 12 rows, one per month of 2025; exactly one month has rank 1. Check the 12 values sum to the 2025 total.
I’m stuck — show a hint
Monthly CTE filtered with order_date >= '2025-01-01' AND < '2026-01-01', then RANK() OVER (ORDER BY revenue DESC).
Reference answer
WITH m AS (SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01' GROUP BY month) SELECT month, revenue, RANK() OVER (ORDER BY revenue DESC) AS revenue_rank FROM m ORDER BY month;| month | revenue | revenue_rank |
|---|---|---|
| 2025-01 | 888816.00 | 3 |
| 2025-02 | 750398.00 | 12 |
| 2025-03 | 875971.00 | 5 |
| 2025-04 | 751172.00 | 11 |
| 2025-05 | 871190.00 | 6 |
| 2025-06 | 879753.00 | 4 |
| 2025-07 | 831170.00 | 9 |
| 2025-08 | 961400.00 | 1 |
| 2025-09 | 855950.00 | 7 |
| 2025-10 | 853500.00 | 8 |
| 2025-11 | 789680.00 | 10 |
| 2025-12 | 945170.00 | 2 |
More than one query can be correct. If your result matches the target, your query is right.
Practice mode runs in your browser on a SQLite engine with a MySQL compatibility layer, so you can practise without installing anything. Your result is compared with the reference answer on a fresh copy of the same rows, so any correct query is accepted. A few MySQL-only features (EXPLAIN columns, WITH ROLLUP, user variables) behave differently here — run those in MySQL Workbench.
Finished? Save your queries in a .sql file and mark the lesson complete.
Answers — Dates and window functions
Easy. List February orders using a half-open date range. Target: 103,104,105; cancelled orders remain because no status filter was requested.
SELECT order_id FROM orders WHERE order_date >= '2026-02-01' AND order_date < '2026-03-01' ORDER BY order_id;
Medium. Label products as premium if price is at least 800. Target: Desk lamp and Keyboard premium; four standard.
SELECT product_name,CASE WHEN price>=800 THEN 'premium' ELSE 'standard' END AS tier FROM products ORDER BY product_id;
Hard. Rank completed orders by their total, preserving ties. Target: 103 rank 1; 102,106,108 rank 2; 105 rank 3; 101 rank 4.
WITH t AS (SELECT o.order_id,SUM(i.quantity*i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed' GROUP BY o.order_id) SELECT *,DENSE_RANK() OVER(ORDER BY total DESC) AS revenue_rank FROM t ORDER BY total DESC,order_id;
Professional. Create monthly and cumulative revenue for a finance handoff. Target: Monthly 1600,1950,2600; cumulative 1600,3550,6150. State that absent months need a calendar table in a larger dataset.
WITH m AS (SELECT DATE_FORMAT(o.order_date,'%Y-%m') AS month,SUM(i.quantity*i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id=o.order_id WHERE o.status='completed' GROUP BY month) SELECT month,revenue,SUM(revenue) OVER(ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_revenue FROM m ORDER BY month;
Easy. Show each order with its year and month number. Target: 8 rows; first (101, 2026, 1), last (108, 2026, 3)
SELECT order_id, YEAR(order_date) AS yr, MONTH(order_date) AS mon FROM orders ORDER BY order_id;
Easy. Show each order with the weekday name of its date. Target: 8 rows; 101 Monday … 108 Wednesday
SELECT order_id, order_date, DAYNAME(order_date) AS weekday FROM orders ORDER BY order_id;
Easy. List orders placed in January 2026 using a half-open range. Target: 2 rows: 101, 102
SELECT order_id, order_date FROM orders WHERE order_date >= '2026-01-01' AND order_date < '2026-02-01' ORDER BY order_id;
Easy. Label orders as 'revenue' if completed, else 'not revenue'. Target: 8 rows; 104 and 107 are 'not revenue'
SELECT order_id, CASE WHEN status = 'completed' THEN 'revenue' ELSE 'not revenue' END AS label FROM orders ORDER BY order_id;
Medium. Show days between each order and 2026-03-31. Target: 8 rows; first (101, 85), last (108, 20)
SELECT order_id, DATEDIFF('2026-03-31', order_date) AS days_ago FROM orders ORDER BY order_id;Medium. Number all orders in date order. Target: 8 rows; 101 is 1, 108 is 8
SELECT order_id, order_date, ROW_NUMBER() OVER (ORDER BY order_date) AS seq FROM orders ORDER BY seq;
Medium. Number each customer's orders 1, 2, ... in date order. Target: 8 rows; Asha 101 → 1, 103 → 2
SELECT customer_id, order_id, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS nth_order FROM orders ORDER BY customer_id, nth_order;
Medium. Rank products by price, highest first, using RANK. Target: 6 rows: Keyboard 1, Desk lamp 2, Planner 3, Mug 4, Notebook 5, Pen 6
SELECT product_name, price, RANK() OVER (ORDER BY price DESC) AS price_rank FROM products ORDER BY price_rank, product_id;
Medium. Count completed vs other orders per month in one row per month. Target: 3 rows: (2026-01, 2, 0); (2026-02, 2, 1); (2026-03, 2, 1)
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status <> 'completed' THEN 1 ELSE 0 END) AS other FROM orders GROUP BY month ORDER BY month;
Hard. Show each customer's first order date. Target: 5 rows: (1, 01-05); (2, 01-08); (3, 02-10); (4, 02-15); (5, 03-04), all 2026
SELECT customer_id, MIN(order_date) AS first_order FROM orders GROUP BY customer_id ORDER BY customer_id;
Hard. Show each order with the previous order date and days since it (all orders, by date). Target: 8 rows; first gap NULL, last (108, prev 2026-03-04, 7 days)
SELECT order_id, order_date, LAG(order_date) OVER (ORDER BY order_date) AS prev_date, DATEDIFF(order_date, LAG(order_date) OVER (ORDER BY order_date)) AS gap_days FROM orders ORDER BY order_date;
Hard. Show monthly completed revenue with the change from the previous month. Target: 3 rows: (2026-01, 1600, NULL); (2026-02, 1950, 350); (2026-03, 2600, 650)
WITH m AS (SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY month) SELECT month, revenue, revenue - LAG(revenue) OVER (ORDER BY month) AS change_vs_prev FROM m ORDER BY month;
Hard. Find each customer's most recent order (all statuses). Target: 5 rows: (1, 103); (2, 106); (3, 104); (4, 108); (5, 107)
WITH r AS (SELECT customer_id, order_id, order_date, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn FROM orders) SELECT customer_id, order_id, order_date FROM r WHERE rn = 1 ORDER BY customer_id;
Professional. Customer running total: completed order totals in date order and cumulative spend per customer. Target: 6 rows; running ends at Asha 1700, Bilal 2600, Dev 1850
WITH t AS (SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date) SELECT customer_id, order_id, total, SUM(total) OVER (PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_spend FROM t ORDER BY customer_id, order_date;
Professional. Top product per category by completed revenue (ties kept). Target: 3 rows: Electronics Keyboard 2400; Home Mug 1250; Stationery Notebook 600
WITH r AS (SELECT p.category, p.product_name, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id JOIN products p ON p.product_id = i.product_id WHERE o.status = 'completed' GROUP BY p.category, p.product_id, p.product_name), k AS (SELECT *, DENSE_RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rk FROM r) SELECT category, product_name, revenue FROM k WHERE rk = 1 ORDER BY category;
Professional. On the large dataset, show completed revenue per month for 2025 with its rank (highest month = 1). Target: 12 rows, one per month of 2025; exactly one month has rank 1. Check the 12 values sum to the 2025 total.
WITH m AS (SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01' GROUP BY month) SELECT month, revenue, RANK() OVER (ORDER BY revenue DESC) AS revenue_rank FROM m ORDER BY month;
Up next · Lesson 12
Create tables: types and constraints
70 min · 17 practice problems