SQL Project: E-commerce Sales Analysis
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.
- 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
| Table | One row per | Key columns |
|---|---|---|
customers | registered customer | customer_id, city, signup_date |
products | product in the catalogue | category, list_price, unit_cost |
orders | order placed in 2025 | order_date, status (Delivered, Cancelled, Returned), channel |
order_items | product line in an order | quantity, unit_price (the price actually charged) |
| customer_id | full_name | city | signup_date |
|---|---|---|---|
| 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 |
| product_id | product_name | category | list_price | unit_cost |
|---|---|---|---|---|
| 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 |
| order_id | customer_id | order_date | status | channel |
|---|---|---|---|---|
| 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 |
| order_id | product_id | quantity | unit_price |
|---|---|---|---|
| 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 |
Business rules for this project:
- Revenue counts only
Deliveredorders: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:
-- 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.
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';| delivered_orders | revenue | gross_profit | avg_order_value |
|---|---|---|---|
| 50 | 379195 | 163495 | 7584 |
- The joins create one row per order line with its price and cost.
WHERE o.status = 'Delivered'applies the revenue rule.COUNT(DISTINCT o.order_id)counts orders, not lines.- 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?
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;| channel | orders | cancelled | returned | problem_pct |
|---|---|---|---|---|
| Marketplace | 15 | 2 | 1 | 20.0 |
| Web | 22 | 1 | 3 | 18.2 |
| App | 23 | 3 | 0 | 13.0 |
GROUP BY channelmakes one row per channel.- In MySQL a comparison is 1 or 0, so
SUM(status = 'Cancelled')counts cancelled orders: a short form ofSUM(CASE WHEN ... THEN 1 ELSE 0 END). problem_pctis the share of orders not delivered.
Task 3: Monthly revenue and growth
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;| month | orders | revenue | mom_growth_pct | revenue_ytd |
|---|---|---|---|---|
| 2025-01 | 6 | 55334 | NULL | 55334 |
| 2025-02 | 5 | 31656 | -42.8 | 86990 |
| 2025-03 | 5 | 32313 | 2.1 | 119303 |
| 2025-04 | 4 | 34940 | 8.1 | 154243 |
| 2025-05 | 4 | 49183 | 40.8 | 203426 |
| 2025-06 | 5 | 42387 | -13.8 | 245813 |
| 2025-07 | 1 | 1299 | -96.9 | 247112 |
| 2025-08 | 4 | 17792 | 1269.7 | 264904 |
| 2025-09 | 7 | 42650 | 139.7 | 307554 |
| 2025-10 | 3 | 32538 | -23.7 | 340092 |
| 2025-11 | 4 | 31066 | -4.5 | 371158 |
| 2025-12 | 2 | 8037 | -74.1 | 379195 |
- The CTE totals delivered revenue per month.
LAG(revenue)brings in the previous month, so month-over-month growth is simple arithmetic (LAG & LEAD).SUM(revenue) OVER (ORDER BY month)is the year-to-date running total (Running Totals).
Task 4: Top 5 products by revenue
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;| product_name | category | units | revenue |
|---|---|---|---|
| Smart Watch | Electronics | 16 | 77489 |
| Cricket Bat | Sports | 16 | 54937 |
| Wireless Earbuds | Electronics | 19 | 46485 |
| Denim Jacket | Fashion | 16 | 40807 |
| Running Shoes | Fashion | 11 | 35960 |
Task 5: Category revenue and margin
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;| category | revenue | profit | margin_pct |
|---|---|---|---|
| Electronics | 170966 | 69716 | 40.8 |
| Fashion | 81500 | 40000 | 49.1 |
| Sports | 78704 | 33954 | 43.1 |
| Home | 48025 | 19825 | 41.3 |
- Revenue and profit are summed per category.
- 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?
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;| product_name | units | discount_given |
|---|---|---|
| Smart Watch | 5 | 2495 |
| Cricket Bat | 3 | 1047 |
| Wireless Earbuds | 4 | 996 |
| Coffee Maker | 2 | 798 |
| Denim Jacket | 3 | 777 |
| Dumbbell Set | 2 | 438 |
| Table Lamp | 3 | 357 |
| Running Shoes | 1 | 329 |
| Bluetooth Speaker | 1 | 179 |
| Yoga Mat | 2 | 178 |
| Cotton T-Shirt | 1 | 59 |
Task 7: Best seller in each category
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;| category | product_name | units |
|---|---|---|
| Electronics | Wireless Earbuds | 19 |
| Electronics | Bluetooth Speaker | 19 |
| Fashion | Denim Jacket | 16 |
| Home | Table Lamp | 11 |
| Sports | Cricket Bat | 16 |
The Top N per Group pattern: rank inside each category, keep rank 1 (ties are kept).
Task 8: Products that never sold
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);| product_id | product_name | category |
|---|---|---|
| 13 | Water Bottle | Home |
Task 9: Top customers and repeat buyers
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;| customer_id | full_name | city | orders | lifetime_value | buyer_type |
|---|---|---|---|---|---|
| 7 | Reyansh Das | Chennai | 8 | 58313 | Repeat |
| 1 | Aarav Shah | Pune | 7 | 56278 | Repeat |
| 8 | Myra Kapoor | Mumbai | 7 | 53803 | Repeat |
| 6 | Anaya Iyer | Bengaluru | 5 | 43838 | Repeat |
| 4 | Isha Reddy | Hyderabad | 6 | 41238 | Repeat |
How many customers are repeat buyers, one-time buyers, or have never bought?
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;| segment | customers |
|---|---|
| Repeat | 9 |
| One-time | 3 |
| Never bought | 2 |
- The
LEFT JOINkeeps customers with no delivered order; the status condition sits inONso it does not remove them (LEFT JOIN). COUNT(o.order_id)gives 0 for them.- A
CASEturns 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:
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;| customer_id | recency_days | frequency | monetary | r_score | f_score | m_score | segment |
|---|---|---|---|---|---|---|---|
| 8 | 38 | 7 | 53803 | 3 | 3 | 3 | Champion |
| 3 | 26 | 5 | 38295 | 3 | 2 | 2 | Champion |
| 11 | 38 | 4 | 28390 | 3 | 2 | 2 | Champion |
| 9 | 20 | 3 | 12635 | 3 | 2 | 1 | Regular |
| 1 | 70 | 7 | 56278 | 2 | 3 | 3 | Regular |
| 2 | 46 | 2 | 27590 | 2 | 1 | 2 | Regular |
| 4 | 89 | 6 | 41238 | 2 | 3 | 2 | Regular |
| 12 | 95 | 1 | 7919 | 2 | 1 | 1 | Regular |
| 6 | 115 | 5 | 43838 | 1 | 2 | 3 | At risk |
| 7 | 95 | 8 | 58313 | 1 | 3 | 3 | At risk |
| 5 | 283 | 1 | 5698 | 1 | 1 | 1 | At risk |
| 10 | 288 | 1 | 5198 | 1 | 1 | 1 | At risk |
rfmcomputes the three raw measures per customer.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.- A
CASEturns 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:
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;| cohort | customers | came_back |
|---|---|---|
| 2025-01 | 6 | 6 |
| 2025-02 | 2 | 2 |
| 2025-03 | 2 | 0 |
| 2025-06 | 1 | 1 |
| 2025-09 | 1 | 0 |
firstsfinds each customer’s first delivered order date.- Customers are grouped by that month (their cohort).
COUNT(DISTINCT CASE WHEN o.order_date > f.first_order THEN o.customer_id END)counts customers who have at least one later order.CASEwithoutELSEgives NULL, whichCOUNTignores.
Task 12: Revenue by city and channel (pivot)
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;| city | web | app | marketplace | total |
|---|---|---|---|---|
| Pune | 25191 | 33685 | 10037 | 68913 |
| Delhi | 29230 | 24161 | 13294 | 66685 |
| Chennai | 29423 | 18892 | 9998 | 58313 |
| Mumbai | 0 | 43504 | 10299 | 53803 |
| Hyderabad | 25641 | 14298 | 9218 | 49157 |
| Bengaluru | 25195 | 10096 | 8547 | 43838 |
| Kolkata | 9195 | 0 | 23593 | 32788 |
| Jaipur | 0 | 0 | 5698 | 5698 |
Your turn
Exercise: Quarterly revenue
Show delivered revenue per quarter of 2025 (Q1 to Q4).
| quarter | revenue |
|---|---|
| Q1 | 119303 |
| Q2 | 126510 |
| Q3 | 61741 |
| Q4 | 71641 |
Hint 1
CONCAT('Q', QUARTER(o.order_date)) as the group.
SELECT CONCAT('Q', QUARTER(o.order_date)) AS quarter, SUM(oi.quantity * oi.unit_price) AS revenueFROM orders oJOIN order_items oi ON oi.order_id = o.order_idWHERE o.status = 'Delivered'GROUP BY quarterORDER BY quarter;| quarter | revenue |
|---|---|
| Q1 | 119303 |
| Q2 | 126510 |
| Q3 | 61741 |
| Q4 | 71641 |
Exercise: Customers who never ordered
List registered customers who have never placed any order.
| customer_id | full_name | signup_date |
|---|---|---|
| 13 | Rudra Patil | 2024-09-05 |
| 14 | Zara Thomas | 2024-12-20 |
Hint 1
LEFT JOIN orders ... WHERE o.order_id IS NULL, or NOT EXISTS.
SELECT c.customer_id, c.full_name, c.signup_dateFROM customers cLEFT JOIN orders o ON o.customer_id = c.customer_idWHERE o.order_id IS NULL;| customer_id | full_name | signup_date |
|---|---|---|
| 13 | Rudra Patil | 2024-09-05 |
| 14 | Zara Thomas | 2024-12-20 |
What you practised
| Task | SQL skills |
|---|---|
| KPIs, margins | joins, SUM, COUNT(DISTINCT), business rules in WHERE |
| Status, pivot | conditional aggregation |
| Monthly trend | CTE, DATE_FORMAT, LAG, running SUM() OVER |
| Top products, best per category | ORDER BY ... LIMIT, RANK() OVER (PARTITION BY) |
| Never sold, never ordered | NOT EXISTS, LEFT JOIN ... IS NULL |
| Segments, RFM, cohorts | CASE, NTILE, MIN, conditional COUNT(DISTINCT) |
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.