Project: analyzing a shop
Apart from DATE_FORMAT in "Orders per month", which takes the year and month out of a date, there's no new syntax in this lesson. Like the shop's data analyst, we ask a series of questions and answer each with one query, from easy to hard.
First, two rules that every query below follows:
- Sold means in an order that wasn't cancelled:
status <> 'cancelled'. - An item's amount is
quantity * unit_price(the price when it was ordered, not the product's price today).
Try writing each one yourself before reading ours. There's more than one way to write them; a right result is what counts.
Warm-up: stock
Which products are running low? Label them with CASE:
1SELECT name, stock,2 CASE3 WHEN stock = 0 THEN 'sold out'4 WHEN stock < 20 THEN 'running low'5 ELSE 'enough'6 END AS level7FROM products8WHERE stock < 309ORDER BY stock;
| name | stock | level |
|---|---|---|
| Desk lamp | 0 | sold out |
| Headphones | 10 | running low |
| Backpack | 15 | running low |
| Tote bag | 25 | enough |
Total sales
What state are the orders in?
1SELECT status, COUNT(*) AS orders2FROM orders3GROUP BY status4ORDER BY orders DESC;
| status | orders |
|---|---|
| shipped | 8 |
| pending | 3 |
| cancelled | 1 |
How much has the shop sold in all? Join the orders to their items, and leave out the cancelled ones:
1SELECT SUM(oi.quantity * oi.unit_price) AS revenue2FROM orders AS o3JOIN order_items AS oi ON oi.order_id = o.id4WHERE o.status <> 'cancelled';
| revenue |
|---|
| 418.70 |
Best sellers
How many of each product sold, and for how much? Rank them by the number sold and take the top three:
1WITH sales AS (2 SELECT p.id, p.name,3 SUM(oi.quantity) AS sold,4 SUM(oi.quantity * oi.unit_price) AS revenue5 FROM products AS p6 JOIN order_items AS oi ON oi.product_id = p.id7 JOIN orders AS o ON o.id = oi.order_id8 WHERE o.status <> 'cancelled'9 GROUP BY p.id, p.name10),11ranked AS (12 SELECT id, name, sold, revenue,13 RANK() OVER (ORDER BY sold DESC) AS place14 FROM sales15)16SELECT place, name, sold, revenue17FROM ranked18WHERE place <= 319ORDER BY place, id;
| place | name | sold | revenue |
|---|---|---|---|
| 1 | Notebook | 10 | 45.00 |
| 2 | Coffee mug | 6 | 59.40 |
| 3 | Pens (10) | 3 | 18.00 |
| 3 | Water bottle | 3 | 36.00 |
| 3 | USB cable | 3 | 25.50 |
The "top three" turned out to be 5 products: three of them sold 3 each and share 3rd place. With ORDER BY sold DESC LIMIT 3, two of those three would be dropped at random; RANK() keeps all the ties.
The other way round: which products never sold at all? After a LEFT JOIN, they're the ones with no match:
1SELECT p.id, p.name2FROM products AS p3LEFT JOIN order_items AS oi ON oi.product_id = p.id4WHERE oi.product_id IS NULL5ORDER BY p.id;
| id | name |
|---|---|
| 6 | Desk lamp |
Revenue per city
Customers → orders → items: join the three tables and group by city:
1SELECT c.city, SUM(oi.quantity * oi.unit_price) AS revenue2FROM customers AS c3JOIN orders AS o ON o.customer_id = c.id4JOIN order_items AS oi ON oi.order_id = o.id5WHERE o.status <> 'cancelled'6GROUP BY c.city7ORDER BY revenue DESC;
| city | revenue |
|---|---|
| London | 162.50 |
| Paris | 101.80 |
| Tokyo | 98.60 |
| Berlin | 55.80 |
The customers come from 5 cities (this query gives 5 rows), but the revenue per city above has only 4 rows. What happened to the missing city?
1SELECT DISTINCT city FROM customers; Sydney's only customer is Julia Costa, and she has never ordered. At JOIN orders she matches no order, so her row is gone. To show Sydney with 0, use LEFT JOIN, then COALESCE to turn the NULL into 0.
Returning customers
A customer who bought two or more times is a returning customer. Count each customer's orders that weren't cancelled, and keep those with at least 2:
1SELECT c.id, c.name, COUNT(*) AS orders2FROM customers AS c3JOIN orders AS o ON o.customer_id = c.id4WHERE o.status <> 'cancelled'5GROUP BY c.id, c.name6HAVING COUNT(*) >= 27ORDER BY orders DESC, c.id;
| id | name | orders |
|---|---|---|
| 1 | Ana Silva | 3 |
| 2 | Ben Okafor | 2 |
| 5 | Emma Novak | 2 |
David Kim (customer 4) did place two orders, but one was cancelled, so he doesn't count. WHERE removes the cancelled orders first, then HAVING removes the customers without enough orders: the two together, just as in lesson 9.
Orders per month
To group by month, first take the "year-month" out of the date. MySQL does it with DATE_FORMAT(order_date, '%Y-%m'), which turns 2025-10-15 into 2025-10 (other databases write it differently: TO_CHAR(order_date, 'YYYY-MM') in PostgreSQL, strftime('%Y-%m', order_date) in SQLite). Work out each order's amount first, then sum up by month:
1WITH totals AS (2 SELECT o.id, o.order_date, SUM(oi.quantity * oi.unit_price) AS total3 FROM orders AS o4 JOIN order_items AS oi ON oi.order_id = o.id5 WHERE o.status <> 'cancelled'6 GROUP BY o.id, o.order_date7)8SELECT DATE_FORMAT(order_date, '%Y-%m') AS month,9 COUNT(*) AS orders,10 SUM(total) AS revenue,11 ROUND(AVG(total), 2) AS avg_order12FROM totals13GROUP BY DATE_FORMAT(order_date, '%Y-%m')14ORDER BY month;
| month | orders | revenue | avg_order |
|---|---|---|---|
| 2025-09 | 3 | 135.40 | 45.13 |
| 2025-10 | 8 | 283.30 | 35.41 |
October had more than twice as many orders as September, but the average order was a little smaller.
Top customers
How much did each customer spend in all, and what percent of all sales is that? Write "all sales" next to every row with SUM() OVER (), and the division is easy:
1WITH spending AS (2 SELECT c.id, c.name, SUM(oi.quantity * oi.unit_price) AS spent3 FROM customers AS c4 JOIN orders AS o ON o.customer_id = c.id5 JOIN order_items AS oi ON oi.order_id = o.id6 WHERE o.status <> 'cancelled'7 GROUP BY c.id, c.name8)9SELECT name, spent,10 ROUND(100 * spent / SUM(spent) OVER (), 1) AS percent11FROM spending12ORDER BY spent DESC;
| name | spent | percent |
|---|---|---|
| Ana Silva | 116.50 | 27.8 |
| Ben Okafor | 101.80 | 24.3 |
| George Brown | 59.00 | 14.1 |
| Emma Novak | 55.80 | 13.3 |
| David Kim | 39.60 | 9.5 |
| Hana Sato | 23.50 | 5.6 |
| Chloe Martin | 22.50 | 5.4 |
The top two customers together make up more than half.
Practice
Write the query, then press Check. A hint costs nothing; the answer is there when you need it. Each one is harder than the last.
Customers who never ordered
✓Get the name and city of the customers who have never placed an order, ordered by customer id.
| name | city |
|---|---|
| Farah Khan | Paris |
| Ivan Petrov | Berlin |
| Julia Costa | Sydney |
1SELECT c.name, c.city2FROM customers AS c;
Revenue per category
✓Work out how much each category sold (cancelled orders left out): get the columns category and revenue, ordered by revenue, highest first.
| category | revenue |
|---|---|
| Electronics | 158.50 |
| Bags | 101.80 |
| Kitchen | 95.40 |
| Stationery | 63.00 |
1SELECT category2FROM products;
Each city's biggest spender
✓In each city, find the customer who spent the most (cancelled orders left out): get city, name and the amount spent, ordered by city.
| city | name | spent |
|---|---|---|
| Berlin | Emma Novak | 55.80 |
| London | Ana Silva | 116.50 |
| Paris | Ben Okafor | 101.80 |
| Tokyo | George Brown | 59.00 |
1SELECT c.city, c.name2FROM customers AS c3ORDER BY c.city;
What you learned
- Settle the rules first (what counts as "sold", how an amount is worked out), and have every query follow them.
- "Each … per …" usually means joining with
JOIN, thenGROUP BY; "at least …" meansHAVING. - "Never …" means
LEFT JOINwithIS NULL; a plainJOINquietly drops the rows with no match. - For a "top N" with ties,
RANK()is more honest thanLIMIT; for "the top of each group", rank withPARTITION BYand keep number 1. - Build a long analysis step by step with
WITH; for shares of a total,SUM() OVER ()gives the total. DATE_FORMAT(date, '%Y-%m')takes out the year and month (MySQL's way).