Project: Analysing a Shop

Time to use everything together. You're the new data analyst at the demo shop, and the managers have questions. Each task below states a business question; try writing the query yourself in the editor before opening the model solution. Every solution runs against the same database you've used throughout the course.

1. Headline numbers

How many customers, orders and products do we have, and what is total revenue from orders that weren't cancelled?

Try it yourself
Result
Show a model solution
SQL
SELECT (SELECT COUNT(*) FROM customers) AS customers,
       (SELECT COUNT(*) FROM orders) AS orders,
       (SELECT COUNT(*) FROM products) AS products,
       (SELECT round(SUM(oi.quantity * oi.unit_price), 2)
        FROM order_items AS oi JOIN orders AS o ON o.id = oi.order_id
        WHERE o.status <> 'cancelled') AS revenue;
Result 1 row
customersordersproductsrevenue
1015121055.02

2. Revenue by month, with growth

Show revenue per month and the change from the previous month.

Try it yourself
Result
Show a model solution
SQL
WITH monthly AS (
  SELECT strftime('%Y-%m', o.order_date) AS month,
         round(SUM(oi.quantity * oi.unit_price), 2) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.id
  WHERE o.status <> 'cancelled'
  GROUP BY month
)
SELECT month, revenue,
       round(revenue - LAG(revenue) OVER (ORDER BY month), 2) AS change
FROM monthly
ORDER BY month;
Result 7 rows
monthrevenuechange
2025-09109.89NULL
2025-10171.4861.59
2025-1181-90.48
2025-12148.8967.89
2026-01203.7854.89
2026-02162-41.78
2026-03177.9815.98

3. Best-selling products

List the top five products by revenue, with units sold and their share of total revenue.

Try it yourself
Result
Show a model solution
SQL
WITH sales AS (
  SELECT p.name, SUM(oi.quantity) AS units, SUM(oi.quantity * oi.unit_price) AS revenue
  FROM order_items AS oi
  JOIN orders AS o ON o.id = oi.order_id
  JOIN products AS p ON p.id = oi.product_id
  WHERE o.status <> 'cancelled'
  GROUP BY p.id, p.name
)
SELECT name, units, round(revenue, 2) AS revenue,
       round(100.0 * revenue / SUM(revenue) OVER (), 1) AS pct_of_total
FROM sales
ORDER BY revenue DESC
LIMIT 5;
Result 5 rows
nameunitsrevenuepct_of_total
Wireless Earbuds4239.9622.7
Linen Throw314713.9
Learning SQL Step by Step413012.3
Mechanical Keyboard1898.4
Ceramic Mug787.58.3

4. Customer value

For every customer, show number of orders, total spent, first and last order dates — including customers who have never ordered.

Try it yourself
Result
Show a model solution
SQL
SELECT c.name,
       COUNT(DISTINCT o.id) AS orders,
       round(COALESCE(SUM(oi.quantity * oi.unit_price), 0), 2) AS spent,
       MIN(o.order_date) AS first_order,
       MAX(o.order_date) AS last_order
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id AND o.status <> 'cancelled'
LEFT JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY spent DESC;
Result 10 rows
nameordersspentfirst_orderlast_order
Ben Carter2168.982025-09-142025-12-01
Ana Pereira3168.882025-09-032026-02-03
George Brown2137.92025-12-122026-03-05
Hana Novak1119.982026-01-222026-01-22
Chloé Martin21172025-10-202026-02-20
Isaac Levi197.52026-02-142026-02-14
Farah Haddad183.82026-01-072026-01-07
Elena García1812025-11-152025-11-15
David Müller179.982026-03-012026-03-01
Julia Santos00NULLNULL

5. Repeat customers

What percentage of customers who ordered have ordered more than once?

Try it yourself
Result
Show a model solution
SQL
WITH per_customer AS (
  SELECT customer_id, COUNT(*) AS orders
  FROM orders
  WHERE status <> 'cancelled'
  GROUP BY customer_id
)
SELECT COUNT(*) AS customers_who_ordered,
       COUNT(*) FILTER (WHERE orders > 1) AS repeat_customers,
       round(100.0 * COUNT(*) FILTER (WHERE orders > 1) / COUNT(*), 1) AS repeat_pct
FROM per_customer;
Result 1 row
customers_who_orderedrepeat_customersrepeat_pct
9444.4

6. Stock alerts

Which products have fewer units in stock than are waiting in pending orders, or are out of stock?

Try it yourself
Result
Show a model solution
SQL
SELECT p.name, p.stock,
       COALESCE(SUM(oi.quantity) FILTER (WHERE o.status = 'pending'), 0) AS pending_units
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
LEFT JOIN orders AS o ON o.id = oi.order_id
GROUP BY p.id, p.name, p.stock
HAVING p.stock = 0 OR p.stock < pending_units
ORDER BY p.stock;
Result 1 row
namestockpending_units
Mechanical Keyboard00

7. Category performance by country

Show revenue per category for each country, with each country's best category marked.

Try it yourself
Result
Show a model solution
SQL
WITH revenue AS (
  SELECT c.country, p.category, round(SUM(oi.quantity * oi.unit_price), 2) AS revenue
  FROM customers AS c
  JOIN orders AS o ON o.customer_id = c.id
  JOIN order_items AS oi ON oi.order_id = o.id
  JOIN products AS p ON p.id = oi.product_id
  WHERE o.status <> 'cancelled'
  GROUP BY c.country, p.category
)
SELECT country, category, revenue,
       CASE WHEN RANK() OVER (PARTITION BY country ORDER BY revenue DESC) = 1 THEN '★' ELSE '' END AS best
FROM revenue
ORDER BY country, revenue DESC;
Result 13 rows
countrycategoryrevenuebest
CzechiaElectronics119.98★
FranceHome133★
FranceBooks49.8
FranceToys18
GermanyElectronics79.98★
PortugalBooks154.9★
PortugalHome71.5
PortugalElectronics39.98
SpainBooks45★
SpainToys36
UKElectronics168.98★
UKHome98
UKToys39.9

8. Payroll report

For each department, show headcount, total and average salary, and the highest-paid person — and include the Research department and the CEO.

Try it yourself
Result
Show a model solution
SQL
WITH ranked AS (
  SELECT e.*, ROW_NUMBER() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS pos
  FROM employees AS e
)
SELECT COALESCE(d.name, '(no department)') AS department,
       COUNT(r.id) AS headcount,
       COALESCE(SUM(r.salary), 0) AS payroll,
       round(AVG(r.salary)) AS avg_salary,
       MAX(CASE WHEN r.pos = 1 THEN r.first_name || ' ' || r.last_name END) AS top_earner
FROM departments AS d
FULL JOIN ranked AS r ON r.department_id = d.id
GROUP BY d.id, d.name
ORDER BY payroll DESC;
Result 6 rows
departmentheadcountpayrollavg_salarytop_earner
Engineering431900079750Mei Tanaka
Sales318800062667Rafael Costa
(no department)1150000150000Olivia Brandt
Marketing211900059500Daniel Kim
Support210200051000Ethan Walsh
Research00NULLNULL

9. Build a reporting view

Create a view sales_lines with one row per order line: date, month, customer, country, product, category, quantity and line total (excluding cancelled orders). Then answer question 7 again using it.

Try it yourself
Result
Show a model solution
SQL
CREATE VIEW sales_lines AS
SELECT o.order_date, strftime('%Y-%m', o.order_date) AS month,
       c.name AS customer, c.country, p.name AS product, p.category,
       oi.quantity, oi.quantity * oi.unit_price AS line_total
FROM order_items AS oi
JOIN orders AS o ON o.id = oi.order_id
JOIN customers AS c ON c.id = o.customer_id
JOIN products AS p ON p.id = oi.product_id
WHERE o.status <> 'cancelled';

SELECT country, category, round(SUM(line_total), 2) AS revenue
FROM sales_lines
GROUP BY country, category
ORDER BY country, revenue DESC;
Result 13 rows
countrycategoryrevenue
CzechiaElectronics119.98
FranceHome133
FranceBooks49.8
FranceToys18
GermanyElectronics79.98
PortugalBooks154.9
PortugalHome71.5
PortugalElectronics39.98
SpainBooks45
SpainToys36
UKElectronics168.98
UKHome98
UKToys39.9

Going further

  • Add a reviews table (lesson 40), fill it with data, and find the best-rated category.
  • Write a trigger that stops orders for products with no stock (lesson 48).
  • Install PostgreSQL or SQLite locally, load the demo data, and run these reports there.
  • Test yourself with the exercises and the quiz.