SQL Tutorial › SQL Projects › Project: E-commerce Sales Analysis

SQL Project: E-commerce Sales Analysis

ProjectProject 1 of 530 min read13 queries run on MySQL 8.4

In this project you are the data analyst of an online store. Management wants answers about 2025: how much did we sell, what is growing, which products and customers matter most, and where do we lose money? You will answer each question with one query on a realistic four-table database, using everything from GROUP BY to window functions and CTEs. Every query was run on MySQL 8.4.

Advertisement
In this lesson
  • Define revenue, profit and AOV correctly in SQL
  • Build monthly trends with month-over-month growth
  • Rank products and customers, including per category
  • Segment customers (repeat, RFM) and build a cohort table

The business and its database

TableOne row perKey columns
customersregistered customercustomer_id, city, signup_date
productsproduct in the cataloguecategory, list_price, unit_cost
ordersorder placed in 2025order_date, status (Delivered, Cancelled, Returned), channel
order_itemsproduct line in an orderquantity, unit_price (the price actually charged)
customers · 14 rows
customer_idfull_namecitysignup_date
1Aarav ShahPune2024-10-24
2Diya MenonKolkata2024-04-09
3Kabir AliDelhi2024-10-03
4Isha ReddyHyderabad2024-07-02
5Vihaan GuptaJaipur2024-08-27
6Anaya IyerBengaluru2024-01-25
7Reyansh DasChennai2024-12-05
8Myra KapoorMumbai2024-09-14
9Arjun NairPune2024-04-19
10Saanvi JoshiKolkata2024-11-04
11Ayaan KhanDelhi2024-04-23
12Kiara BoseHyderabad2024-05-27
13Rudra PatilJaipur2024-09-05
14Zara ThomasBengaluru2024-12-20
products · 13 rows
product_idproduct_namecategorylist_priceunit_cost
1Wireless EarbudsElectronics24991400
2Smart WatchElectronics49993100
3Bluetooth SpeakerElectronics1799950
4Power BankElectronics1299700
5Running ShoesFashion32991700
6Denim JacketFashion25991300
7Cotton T-ShirtFashion599250
8Yoga MatSports899400
9Dumbbell SetSports21991250
10Cricket BatSports34992000
11Coffee MakerHome39992400
12Table LampHome1199600
13Water BottleHome499180
orders · first 8 of 60 rows
order_idcustomer_idorder_datestatuschannel
500142025-01-02DeliveredWeb
500212025-01-04ReturnedWeb
500372025-01-17DeliveredWeb
500462025-01-20DeliveredMarketplace
500522025-01-21DeliveredMarketplace
500682025-01-30DeliveredApp
5007112025-01-30DeliveredWeb
500872025-02-01DeliveredApp
order_items · first 8 of 113 rows
order_idproduct_idquantityunit_price
5001224999
5001311799
5002513299
5003122499
5003911980
50031211199
5004132250
500473599

Business rules for this project:

  • Revenue counts only Delivered orders: SUM(quantity * unit_price).
  • Cost is quantity * unit_cost; gross profit is revenue minus cost.
  • AOV (average order value) is revenue divided by the number of delivered orders.

To follow along on your own MySQL server, create a database and run this script:

ecommerce.sql
-- sqatools.in SQL project 1: e-commerce sales analysis (generated by gen_projects.py)CREATE TABLE customers (  customer_id INT PRIMARY KEY,  full_name   VARCHAR(50) NOT NULL,  city        VARCHAR(30) NOT NULL,  signup_date DATE NOT NULL);CREATE TABLE products (  product_id   INT PRIMARY KEY,  product_name VARCHAR(50) NOT NULL,  category     VARCHAR(20) NOT NULL,  list_price   INT NOT NULL,  unit_cost    INT NOT NULL);CREATE TABLE orders (  order_id    INT PRIMARY KEY,  customer_id INT NOT NULL,  order_date  DATE NOT NULL,  status      VARCHAR(12) NOT NULL,  channel     VARCHAR(12) NOT NULL,  FOREIGN KEY (customer_id) REFERENCES customers (customer_id));CREATE TABLE order_items (  order_id   INT,  product_id INT,  quantity   INT NOT NULL,  unit_price INT NOT NULL,  PRIMARY KEY (order_id, product_id),  FOREIGN KEY (order_id) REFERENCES orders (order_id),  FOREIGN KEY (product_id) REFERENCES products (product_id));INSERT INTO customers VALUES(1, 'Aarav Shah', 'Pune', '2024-10-24'),(2, 'Diya Menon', 'Kolkata', '2024-04-09'),(3, 'Kabir Ali', 'Delhi', '2024-10-03'),(4, 'Isha Reddy', 'Hyderabad', '2024-07-02'),(5, 'Vihaan Gupta', 'Jaipur', '2024-08-27'),(6, 'Anaya Iyer', 'Bengaluru', '2024-01-25'),(7, 'Reyansh Das', 'Chennai', '2024-12-05'),(8, 'Myra Kapoor', 'Mumbai', '2024-09-14'),(9, 'Arjun Nair', 'Pune', '2024-04-19'),(10, 'Saanvi Joshi', 'Kolkata', '2024-11-04'),(11, 'Ayaan Khan', 'Delhi', '2024-04-23'),(12, 'Kiara Bose', 'Hyderabad', '2024-05-27'),(13, 'Rudra Patil', 'Jaipur', '2024-09-05'),(14, 'Zara Thomas', 'Bengaluru', '2024-12-20');INSERT INTO products VALUES(1, 'Wireless Earbuds', 'Electronics', 2499, 1400),(2, 'Smart Watch', 'Electronics', 4999, 3100),(3, 'Bluetooth Speaker', 'Electronics', 1799, 950),(4, 'Power Bank', 'Electronics', 1299, 700),(5, 'Running Shoes', 'Fashion', 3299, 1700),(6, 'Denim Jacket', 'Fashion', 2599, 1300),(7, 'Cotton T-Shirt', 'Fashion', 599, 250),(8, 'Yoga Mat', 'Sports', 899, 400),(9, 'Dumbbell Set', 'Sports', 2199, 1250),(10, 'Cricket Bat', 'Sports', 3499, 2000),(11, 'Coffee Maker', 'Home', 3999, 2400),(12, 'Table Lamp', 'Home', 1199, 600),(13, 'Water Bottle', 'Home', 499, 180);INSERT INTO orders VALUES(5001, 4, '2025-01-02', 'Delivered', 'Web'),(5002, 1, '2025-01-04', 'Returned', 'Web'),(5003, 7, '2025-01-17', 'Delivered', 'Web'),(5004, 6, '2025-01-20', 'Delivered', 'Marketplace'),(5005, 2, '2025-01-21', 'Delivered', 'Marketplace'),(5006, 8, '2025-01-30', 'Delivered', 'App'),(5007, 11, '2025-01-30', 'Delivered', 'Web'),(5008, 7, '2025-02-01', 'Delivered', 'App'),(5009, 3, '2025-02-14', 'Delivered', 'Marketplace'),(5010, 1, '2025-02-19', 'Delivered', 'App'),(5011, 4, '2025-02-23', 'Delivered', 'Web'),(5012, 3, '2025-02-26', 'Delivered', 'App'),(5013, 8, '2025-03-13', 'Delivered', 'App'),(5014, 4, '2025-03-13', 'Delivered', 'App'),(5015, 10, '2025-03-18', 'Delivered', 'Marketplace'),(5016, 7, '2025-03-19', 'Delivered', 'App'),(5017, 5, '2025-03-23', 'Delivered', 'Marketplace'),(5018, 7, '2025-04-03', 'Delivered', 'Web'),(5019, 1, '2025-04-04', 'Delivered', 'App'),(5020, 3, '2025-04-06', 'Delivered', 'Web'),(5021, 8, '2025-04-15', 'Delivered', 'Marketplace'),(5022, 6, '2025-05-03', 'Delivered', 'Web'),(5023, 8, '2025-05-05', 'Delivered', 'App'),(5024, 7, '2025-05-06', 'Delivered', 'App'),(5025, 11, '2025-05-26', 'Cancelled', 'App'),(5026, 8, '2025-05-27', 'Delivered', 'App'),(5027, 5, '2025-05-31', 'Returned', 'Marketplace'),(5028, 7, '2025-06-03', 'Delivered', 'App'),(5029, 1, '2025-06-05', 'Delivered', 'App'),(5030, 9, '2025-06-12', 'Delivered', 'App'),(5031, 4, '2025-06-18', 'Returned', 'Web'),(5032, 6, '2025-06-21', 'Delivered', 'App'),(5033, 7, '2025-06-21', 'Delivered', 'Web'),(5034, 4, '2025-07-02', 'Delivered', 'Marketplace'),(5035, 4, '2025-08-04', 'Cancelled', 'Marketplace'),(5036, 1, '2025-08-10', 'Delivered', 'Web'),(5037, 1, '2025-08-18', 'Cancelled', 'App'),(5038, 11, '2025-08-23', 'Delivered', 'Web'),(5039, 6, '2025-08-23', 'Delivered', 'App'),(5040, 1, '2025-08-26', 'Delivered', 'Web'),(5041, 11, '2025-09-01', 'Delivered', 'Marketplace'),(5042, 6, '2025-09-07', 'Delivered', 'Web'),(5043, 9, '2025-09-21', 'Delivered', 'Marketplace'),(5044, 4, '2025-09-22', 'Delivered', 'App'),(5045, 7, '2025-09-27', 'Delivered', 'Marketplace'),(5046, 12, '2025-09-27', 'Delivered', 'Marketplace'),(5047, 1, '2025-09-28', 'Delivered', 'Marketplace'),(5048, 4, '2025-10-03', 'Delivered', 'Web'),(5049, 1, '2025-10-22', 'Delivered', 'Web'),(5050, 1, '2025-10-24', 'Returned', 'Web'),(5051, 3, '2025-10-28', 'Delivered', 'App'),(5052, 2, '2025-11-15', 'Delivered', 'Web'),(5053, 8, '2025-11-22', 'Delivered', 'App'),(5054, 8, '2025-11-23', 'Delivered', 'App'),(5055, 11, '2025-11-23', 'Delivered', 'Web'),(5056, 4, '2025-11-25', 'Cancelled', 'Marketplace'),(5057, 3, '2025-12-05', 'Delivered', 'Web'),(5058, 6, '2025-12-07', 'Cancelled', 'App'),(5059, 9, '2025-12-11', 'Delivered', 'Web'),(5060, 2, '2025-12-16', 'Cancelled', 'Web');INSERT INTO order_items VALUES(5001, 2, 2, 4999),(5001, 3, 1, 1799),(5002, 5, 1, 3299),(5003, 1, 2, 2499),(5003, 9, 1, 1980),(5003, 12, 1, 1199),(5004, 1, 3, 2250),(5004, 7, 3, 599),(5005, 2, 1, 4999),(5005, 5, 3, 3299),(5005, 10, 1, 3499),(5006, 5, 1, 3299),(5007, 3, 1, 1620),(5007, 10, 1, 3499),(5008, 4, 1, 1299),(5009, 1, 3, 2499),(5009, 3, 1, 1799),(5010, 12, 3, 1199),(5011, 3, 1, 1799),(5011, 9, 2, 2199),(5012, 4, 1, 1299),(5012, 5, 1, 2970),(5012, 10, 2, 3499),(5013, 6, 3, 2340),(5013, 7, 1, 599),(5014, 2, 2, 4500),(5014, 10, 1, 3499),(5015, 11, 1, 3999),(5015, 12, 1, 1199),(5016, 4, 1, 1299),(5017, 9, 1, 2199),(5017, 10, 1, 3499),(5018, 4, 2, 1299),(5018, 10, 1, 3150),(5019, 5, 1, 3299),(5019, 6, 3, 2599),(5019, 10, 1, 3499),(5020, 1, 1, 2499),(5020, 3, 1, 1799),(5021, 2, 2, 4500),(5021, 4, 1, 1299),(5022, 2, 3, 4999),(5022, 4, 1, 1299),(5023, 3, 1, 1799),(5023, 8, 1, 899),(5023, 11, 1, 3999),(5024, 3, 2, 1799),(5024, 10, 3, 3499),(5025, 9, 1, 2199),(5026, 3, 2, 1799),(5026, 5, 1, 3299),(5026, 6, 2, 2599),(5027, 3, 1, 1799),(5027, 11, 3, 3999),(5028, 9, 1, 2199),(5029, 1, 1, 2499),(5029, 7, 1, 599),(5029, 10, 3, 3499),(5030, 4, 1, 1299),(5030, 7, 1, 599),(5031, 3, 2, 1799),(5031, 4, 3, 1170),(5032, 6, 2, 2599),(5032, 11, 1, 3999),(5033, 2, 1, 4999),(5033, 5, 1, 3299),(5033, 11, 2, 3600),(5034, 4, 1, 1299),(5035, 1, 1, 2499),(5036, 7, 1, 599),(5037, 9, 1, 2199),(5037, 11, 3, 3600),(5038, 4, 1, 1299),(5038, 5, 3, 3299),(5039, 8, 1, 899),(5040, 1, 1, 2499),(5040, 6, 1, 2599),(5041, 3, 1, 1799),(5041, 9, 1, 2199),(5042, 6, 1, 2599),(5042, 10, 2, 3150),(5043, 12, 3, 1080),(5044, 3, 1, 1799),(5045, 2, 2, 4999),(5046, 2, 1, 4500),(5046, 3, 1, 1799),(5046, 8, 2, 810),(5047, 2, 1, 4999),(5047, 8, 2, 899),(5048, 1, 1, 2250),(5048, 3, 3, 1799),(5049, 11, 3, 3999),(5050, 1, 2, 2499),(5050, 11, 1, 3999),(5051, 1, 3, 2499),(5051, 3, 3, 1799),(5052, 1, 1, 2499),(5052, 8, 3, 899),(5052, 11, 1, 3999),(5053, 2, 1, 4999),(5053, 6, 2, 2599),(5054, 12, 3, 1199),(5055, 6, 2, 2599),(5055, 8, 1, 899),(5055, 9, 1, 1980),(5056, 3, 2, 1620),(5056, 12, 1, 1199),(5057, 7, 1, 540),(5058, 4, 2, 1299),(5058, 7, 2, 599),(5059, 1, 3, 2499),(5060, 5, 1, 3299),(5060, 9, 1, 1980);

Task 1: Headline KPIs

Total delivered orders, revenue, gross profit and average order value for 2025.

MySQL
SELECT COUNT(DISTINCT o.order_id)                        AS delivered_orders,       SUM(oi.quantity * oi.unit_price)                  AS revenue,       SUM(oi.quantity * (oi.unit_price - p.unit_cost))  AS gross_profit,       ROUND(SUM(oi.quantity * oi.unit_price) / COUNT(DISTINCT o.order_id)) AS avg_order_valueFROM orders oJOIN order_items oi ON oi.order_id = o.order_idJOIN products p     ON p.product_id = oi.product_idWHERE o.status = 'Delivered';
ResultMySQL 8.4
delivered_ordersrevenuegross_profitavg_order_value
503791951634957584
1 row in set
How this query works, step by step
  1. The joins create one row per order line with its price and cost.
  2. WHERE o.status = 'Delivered' applies the revenue rule.
  3. COUNT(DISTINCT o.order_id) counts orders, not lines.
  4. Profit per line is quantity × (unit_price − unit_cost); AOV divides revenue by delivered orders.

Task 2: Order status breakdown

What share of orders is cancelled or returned, per sales channel?

MySQL
SELECT channel,       COUNT(*) AS orders,       SUM(status = 'Cancelled') AS cancelled,       SUM(status = 'Returned')  AS returned,       ROUND(100 * SUM(status <> 'Delivered') / COUNT(*), 1) AS problem_pctFROM ordersGROUP BY channelORDER BY problem_pct DESC;
ResultMySQL 8.4
channelorderscancelledreturnedproblem_pct
Marketplace152120.0
Web221318.2
App233013.0
3 rows in set
How this query works, step by step
  1. GROUP BY channel makes one row per channel.
  2. In MySQL a comparison is 1 or 0, so SUM(status = 'Cancelled') counts cancelled orders: a short form of SUM(CASE WHEN ... THEN 1 ELSE 0 END).
  3. problem_pct is the share of orders not delivered.

Task 3: Monthly revenue and growth

MySQL
WITH monthly AS (  SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month,         COUNT(DISTINCT o.order_id)          AS orders,         SUM(oi.quantity * oi.unit_price)    AS revenue  FROM orders o  JOIN order_items oi ON oi.order_id = o.order_id  WHERE o.status = 'Delivered'  GROUP BY month)SELECT month, orders, revenue,       ROUND(100 * (revenue - LAG(revenue) OVER (ORDER BY month))             / LAG(revenue) OVER (ORDER BY month), 1) AS mom_growth_pct,       SUM(revenue) OVER (ORDER BY month) AS revenue_ytdFROM monthlyORDER BY month;
ResultMySQL 8.4
monthordersrevenuemom_growth_pctrevenue_ytd
2025-01655334NULL55334
2025-02531656-42.886990
2025-035323132.1119303
2025-044349408.1154243
2025-0544918340.8203426
2025-06542387-13.8245813
2025-0711299-96.9247112
2025-084177921269.7264904
2025-09742650139.7307554
2025-10332538-23.7340092
2025-11431066-4.5371158
2025-1228037-74.1379195
12 rows in set
How this query works, step by step
  1. The CTE totals delivered revenue per month.
  2. LAG(revenue) brings in the previous month, so month-over-month growth is simple arithmetic (LAG & LEAD).
  3. SUM(revenue) OVER (ORDER BY month) is the year-to-date running total (Running Totals).

Task 4: Top 5 products by revenue

SQL
SELECT p.product_name, p.category,       SUM(oi.quantity)                 AS units,       SUM(oi.quantity * oi.unit_price) AS revenueFROM order_items oiJOIN orders o   ON o.order_id = oi.order_idJOIN products p ON p.product_id = oi.product_idWHERE o.status = 'Delivered'GROUP BY p.product_id, p.product_name, p.categoryORDER BY revenue DESCLIMIT 5;
ResultMySQL 8.4
product_namecategoryunitsrevenue
Smart WatchElectronics1677489
Cricket BatSports1654937
Wireless EarbudsElectronics1946485
Denim JacketFashion1640807
Running ShoesFashion1135960
5 rows in set

Task 5: Category revenue and margin

MySQL
SELECT p.category,       SUM(oi.quantity * oi.unit_price)                       AS revenue,       SUM(oi.quantity * (oi.unit_price - p.unit_cost))       AS profit,       ROUND(100 * SUM(oi.quantity * (oi.unit_price - p.unit_cost))                 / SUM(oi.quantity * oi.unit_price), 1)        AS margin_pctFROM order_items oiJOIN orders o   ON o.order_id = oi.order_idJOIN products p ON p.product_id = oi.product_idWHERE o.status = 'Delivered'GROUP BY p.categoryORDER BY profit DESC;
ResultMySQL 8.4
categoryrevenueprofitmargin_pct
Electronics1709666971640.8
Fashion815004000049.1
Sports787043395443.1
Home480251982541.3
4 rows in set
How this query works, step by step
  1. Revenue and profit are summed per category.
  2. Margin % = profit ÷ revenue × 100. A category can have high revenue but a thin margin, which is why both columns matter.

Task 6: Discount impact

Some lines were sold below the list price. How much revenue did discounts cost per product?

SQL
SELECT p.product_name,       SUM(oi.quantity) AS units,       SUM(oi.quantity * (p.list_price - oi.unit_price)) AS discount_givenFROM order_items oiJOIN orders o   ON o.order_id = oi.order_idJOIN products p ON p.product_id = oi.product_idWHERE o.status = 'Delivered' AND oi.unit_price < p.list_priceGROUP BY p.product_id, p.product_nameORDER BY discount_given DESC;
ResultMySQL 8.4
product_nameunitsdiscount_given
Smart Watch52495
Cricket Bat31047
Wireless Earbuds4996
Coffee Maker2798
Denim Jacket3777
Dumbbell Set2438
Table Lamp3357
Running Shoes1329
Bluetooth Speaker1179
Yoga Mat2178
Cotton T-Shirt159
11 rows in set

Task 7: Best seller in each category

MySQL
WITH product_sales AS (  SELECT p.category, p.product_name, SUM(oi.quantity) AS units  FROM order_items oi  JOIN orders o   ON o.order_id = oi.order_id  JOIN products p ON p.product_id = oi.product_id  WHERE o.status = 'Delivered'  GROUP BY p.category, p.product_name),ranked AS (  SELECT product_sales.*, RANK() OVER (PARTITION BY category ORDER BY units DESC) AS rnk  FROM product_sales)SELECT category, product_name, unitsFROM rankedWHERE rnk = 1ORDER BY category;
ResultMySQL 8.4
categoryproduct_nameunits
ElectronicsWireless Earbuds19
ElectronicsBluetooth Speaker19
FashionDenim Jacket16
HomeTable Lamp11
SportsCricket Bat16
5 rows in set

The Top N per Group pattern: rank inside each category, keep rank 1 (ties are kept).

Task 8: Products that never sold

SQL
SELECT p.product_id, p.product_name, p.categoryFROM products pWHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.product_id = p.product_id);
ResultMySQL 8.4
product_idproduct_namecategory
13Water BottleHome
1 row in set

Task 9: Top customers and repeat buyers

SQL
SELECT c.customer_id, c.full_name, c.city,       COUNT(DISTINCT o.order_id)       AS orders,       SUM(oi.quantity * oi.unit_price) AS lifetime_value,       CASE WHEN COUNT(DISTINCT o.order_id) > 1 THEN 'Repeat' ELSE 'One-time' END AS buyer_typeFROM customers cJOIN orders o       ON o.customer_id = c.customer_id AND o.status = 'Delivered'JOIN order_items oi ON oi.order_id = o.order_idGROUP BY c.customer_id, c.full_name, c.cityORDER BY lifetime_value DESCLIMIT 5;
ResultMySQL 8.4
customer_idfull_namecityorderslifetime_valuebuyer_type
7Reyansh DasChennai858313Repeat
1Aarav ShahPune756278Repeat
8Myra KapoorMumbai753803Repeat
6Anaya IyerBengaluru543838Repeat
4Isha ReddyHyderabad641238Repeat
5 rows in set

How many customers are repeat buyers, one-time buyers, or have never bought?

SQL
WITH per_customer AS (  SELECT c.customer_id, COUNT(o.order_id) AS delivered_orders  FROM customers c  LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'Delivered'  GROUP BY c.customer_id)SELECT CASE WHEN delivered_orders = 0 THEN 'Never bought'            WHEN delivered_orders = 1 THEN 'One-time'            ELSE 'Repeat' END AS segment,       COUNT(*) AS customersFROM per_customerGROUP BY segmentORDER BY customers DESC;
ResultMySQL 8.4
segmentcustomers
Repeat9
One-time3
Never bought2
3 rows in set
How this query works, step by step
  1. The LEFT JOIN keeps customers with no delivered order; the status condition sits in ON so it does not remove them (LEFT JOIN).
  2. COUNT(o.order_id) gives 0 for them.
  3. A CASE turns the count into a segment, and the outer query counts customers per segment.

Task 10: RFM segmentation

RFM scores customers by Recency (days since last order), Frequency (orders) and Monetary value, here as of 31 Dec 2025:

MySQL
WITH rfm AS (  SELECT o.customer_id,         DATEDIFF('2025-12-31', MAX(o.order_date)) AS recency_days,         COUNT(DISTINCT o.order_id)                AS frequency,         SUM(oi.quantity * oi.unit_price)          AS monetary  FROM orders o  JOIN order_items oi ON oi.order_id = o.order_id  WHERE o.status = 'Delivered'  GROUP BY o.customer_id),scored AS (  SELECT rfm.*,         NTILE(3) OVER (ORDER BY recency_days DESC) AS r_score,         NTILE(3) OVER (ORDER BY frequency)         AS f_score,         NTILE(3) OVER (ORDER BY monetary)          AS m_score  FROM rfm)SELECT customer_id, recency_days, frequency, monetary, r_score, f_score, m_score,       CASE WHEN r_score = 3 AND f_score >= 2 AND m_score >= 2 THEN 'Champion'            WHEN r_score = 1 THEN 'At risk'            ELSE 'Regular' END AS segmentFROM scoredORDER BY r_score DESC, m_score DESC, customer_id;
ResultMySQL 8.4
customer_idrecency_daysfrequencymonetaryr_scoref_scorem_scoresegment
838753803333Champion
326538295322Champion
1138428390322Champion
920312635321Regular
170756278233Regular
246227590212Regular
489641238232Regular
129517919211Regular
6115543838123At risk
795858313133At risk
528315698111At risk
1028815198111At risk
12 rows in set
How this query works, step by step
  1. rfm computes the three raw measures per customer.
  2. NTILE(3) splits customers into thirds for each measure: 3 is best (most recent, most frequent, highest spend). Recency is sorted descending so that a small number of days gets the high score.
  3. A CASE turns the scores into marketing segments.

Task 11: Cohort analysis

Group customers by the month of their first delivered order, and count how many of each cohort ordered again in later months:

MySQL
WITH firsts AS (  SELECT customer_id, MIN(order_date) AS first_order  FROM orders  WHERE status = 'Delivered'  GROUP BY customer_id)SELECT DATE_FORMAT(f.first_order, '%Y-%m') AS cohort,       COUNT(DISTINCT f.customer_id) AS customers,       COUNT(DISTINCT CASE WHEN o.order_date > f.first_order THEN o.customer_id END) AS came_backFROM firsts fLEFT JOIN orders o ON o.customer_id = f.customer_id AND o.status = 'Delivered'GROUP BY cohortORDER BY cohort;
ResultMySQL 8.4
cohortcustomerscame_back
2025-0166
2025-0222
2025-0320
2025-0611
2025-0910
5 rows in set
How this query works, step by step
  1. firsts finds each customer’s first delivered order date.
  2. Customers are grouped by that month (their cohort).
  3. COUNT(DISTINCT CASE WHEN o.order_date > f.first_order THEN o.customer_id END) counts customers who have at least one later order. CASE without ELSE gives NULL, which COUNT ignores.

Task 12: Revenue by city and channel (pivot)

SQL
SELECT c.city,       SUM(CASE WHEN o.channel = 'Web'         THEN oi.quantity * oi.unit_price ELSE 0 END) AS web,       SUM(CASE WHEN o.channel = 'App'         THEN oi.quantity * oi.unit_price ELSE 0 END) AS app,       SUM(CASE WHEN o.channel = 'Marketplace' THEN oi.quantity * oi.unit_price ELSE 0 END) AS marketplace,       SUM(oi.quantity * oi.unit_price) AS totalFROM orders oJOIN customers c    ON c.customer_id = o.customer_idJOIN order_items oi ON oi.order_id = o.order_idWHERE o.status = 'Delivered'GROUP BY c.cityORDER BY total DESC;
ResultMySQL 8.4
citywebappmarketplacetotal
Pune25191336851003768913
Delhi29230241611329466685
Chennai2942318892999858313
Mumbai0435041029953803
Hyderabad2564114298921849157
Bengaluru2519510096854743838
Kolkata919502359332788
Jaipur0056985698
8 rows in set

Your turn

Exercise: Quarterly revenue

Show delivered revenue per quarter of 2025 (Q1 to Q4).

Expected resultMySQL 8.4
quarterrevenue
Q1119303
Q2126510
Q361741
Q471641
4 rows in set
Hint 1

CONCAT('Q', QUARTER(o.order_date)) as the group.

Exercise: Customers who never ordered

List registered customers who have never placed any order.

Expected resultMySQL 8.4
customer_idfull_namesignup_date
13Rudra Patil2024-09-05
14Zara Thomas2024-12-20
2 rows in set
Hint 1

LEFT JOIN orders ... WHERE o.order_id IS NULL, or NOT EXISTS.

What you practised

TaskSQL skills
KPIs, marginsjoins, SUM, COUNT(DISTINCT), business rules in WHERE
Status, pivotconditional aggregation
Monthly trendCTE, DATE_FORMAT, LAG, running SUM() OVER
Top products, best per categoryORDER BY ... LIMIT, RANK() OVER (PARTITION BY)
Never sold, never orderedNOT EXISTS, LEFT JOIN ... IS NULL
Segments, RFM, cohortsCASE, NTILE, MIN, conditional COUNT(DISTINCT)
Tip

To turn this into a portfolio piece, put the script and your queries in a GitHub repository with a README that states each business question, the query and a one-line insight from the result.

Project FAQ

What SQL projects are good for a portfolio?

Projects that answer real business questions on a multi-table database, such as sales analysis, inventory, HR analytics or banking. Show joins, aggregation, window functions and CTEs, and explain the insight each query gives.

How is revenue calculated in SQL?

Sum quantity × price over the order lines that count as sales, usually excluding cancelled and returned orders: SUM(oi.quantity * oi.unit_price) with a WHERE on the order status.

What is RFM analysis in SQL?

A customer segmentation that scores Recency, Frequency and Monetary value, typically with DATEDIFF, COUNT, SUM and NTILE() window functions.