SQL Practice — Intermediate (Problems 31-70)
40 problems on joins (inner, left, self), correlated subqueries, GROUP BY/HAVING at scale, date range filtering, and string/date functions — every one solved, run against real data, and explained.
Learning objectives
- Choose INNER JOIN vs LEFT JOIN correctly based on whether unmatched rows should survive.
- Write correlated subqueries that compare a row against a per-group baseline (its own department, its own category).
- Filter date ranges safely with half-open bounds instead of off-by-one BETWEEN mistakes.
- Combine COALESCE, CASE, and conditional aggregation to compute rates and totals in a single pass.
P31 — Customer Order List · Intermediate
Display customer names along with their order IDs and order amounts.
SQL
SELECT c.customer_name, o.order_id, o.total_amount FROM customers c JOIN orders o ON c.customer_id = o.customer_id;
Output (computed against this section's live 9-table dataset)
| customer_name | order_id | total_amount |
|---|---|---|
| Rajesh Pandey | 1 | 13,490 |
| Vikas Rana | 2 | 7,797 |
| Manoj Kulkarni | 3 | 9,092 |
| Kunal Malhotra | 4 | 12,293 |
| Swati Yadav | 5 | 14,792 |
| John Smith | 6 | 115,286 |
| Rahul Agarwal | 7 | 14,494 |
| Swati Yadav | 8 | 6,294 |
| Sneha Nair | 9 | 13,891 |
| Sneha Nair | 10 | 76,385 |
(showing 10 of 148 rows)
Explanation
A plain JOIN between customers and orders on customer_id produces one output row per matching order — since every order in this dataset references a real customer, this returns all 148 orders, each now carrying its customer's name alongside the order ID and amount. Customers with zero orders (there are 3 of them here) simply never appear, because an inner join only keeps rows that matched on both sides.
💻 Code example
SELECT c.customer_name, o.order_id, o.total_amount FROM customers c JOIN orders o ON c.customer_id = o.customer_id;
P32 — Employees With Department Names · Intermediate
Show employee name, department name, and salary.
SQL
SELECT e.first_name, e.last_name, d.department_name, e.salary FROM employees e JOIN departments d ON e.department_id = d.department_id;
Output (computed against this section's live 9-table dataset)
| first_name | last_name | department_name | salary |
|---|---|---|---|
| Arvind | Krishnan | Engineering | 285,000 |
| Meera | Nair | Engineering | 215,000 |
| Karan | Malhotra | Sales | 198,000 |
| Priya | Chopra | Marketing | 175,000 |
| Suresh | Iyer | Human Resources | 160,000 |
| Anil | Bhatt | Finance | 205,000 |
| Sunita | Rao | Operations | 168,000 |
| Rohan | Verma | Engineering | 152,000 |
| Ananya | Singh | Engineering | 148,000 |
| Vikram | Reddy | Sales | 131,000 |
(showing 10 of 40 rows)
Explanation
This extends the Problem 30 join by also pulling in salary, giving a single row per employee that reads like a payroll report: name, department, and pay all together. All 40 employees appear because every one of them has a valid department_id that matches a row in departments.
💻 Code example
SELECT e.first_name, e.last_name, d.department_name, e.salary FROM employees e JOIN departments d ON e.department_id = d.department_id;
P33 — Customers With No Orders · Intermediate
Find customers who have never placed an order.
SQL
SELECT c.customer_id, c.customer_name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.order_id IS NULL;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name |
|---|---|
| 30 | Grace Clark |
| 28 | Sofia Rossi |
| 29 | David Miller |
(3 rows total)
Explanation
LEFT JOIN keeps every row from customers regardless of whether a match exists in orders — when a customer has no orders, the joined orders columns (including order_id) come back as NULL for that row, which is exactly the signal WHERE o.order_id IS NULL filters for. Three customers never placed a single order: Grace Clark, Sofia Rossi, and David Miller — this dataset's deliberately quiet accounts.
💻 Code example
SELECT c.customer_id, c.customer_name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.order_id IS NULL;
P34 — Orders With Customer Details · Intermediate
Show order ID, customer name, order date, and status.
SQL
SELECT o.order_id, c.customer_name, o.order_date, o.status FROM orders o JOIN customers c ON o.customer_id = c.customer_id;
Output (computed against this section's live 9-table dataset)
| order_id | customer_name | order_date | status |
|---|---|---|---|
| 1 | Rajesh Pandey | 2025-07-18 | Delivered |
| 2 | Vikas Rana | 2024-08-13 | Delivered |
| 3 | Manoj Kulkarni | 2025-05-20 | Pending |
| 4 | Kunal Malhotra | 2025-09-26 | Delivered |
| 5 | Swati Yadav | 2024-10-25 | Delivered |
| 6 | John Smith | 2025-04-07 | Delivered |
| 7 | Rahul Agarwal | 2024-12-08 | Shipped |
| 8 | Swati Yadav | 2025-11-26 | Cancelled |
| 9 | Sneha Nair | 2025-11-18 | Cancelled |
| 10 | Sneha Nair | 2025-06-20 | Delivered |
(showing 10 of 148 rows)
Explanation
This is the same shape as Problem 31 with order_date and status added to the projection — a straightforward inner join that returns all 148 orders paired with their customer's name, useful as a general-purpose 'orders list' report.
💻 Code example
SELECT o.order_id, c.customer_name, o.order_date, o.status FROM orders o JOIN customers c ON o.customer_id = c.customer_id;
P35 — Products Never Ordered · Intermediate
Find products that have never appeared in order_items.
SQL
SELECT p.product_id, p.product_name FROM products p LEFT JOIN order_items oi ON p.product_id = oi.product_id WHERE oi.product_id IS NULL;
Output (computed against this section's live 9-table dataset)
| product_id | product_name |
|---|---|
| 13 | Cycling Helmet |
(1 row total)
Explanation
LEFT JOIN ... WHERE oi.product_id IS NULL finds products that never matched any row in order_items — the classic 'find things with no matching child rows' pattern. Only the Cycling Helmet (product 13) shows up: it's one of two low-stock products this dataset deliberately keeps out of every order (the other, the Smartwatch, gets pulled back in by a dedicated order built for Problem 111, so only the Cycling Helmet remains a true never-sold item here).
💻 Code example
SELECT p.product_id, p.product_name FROM products p LEFT JOIN order_items oi ON p.product_id = oi.product_id WHERE oi.product_id IS NULL;
P36 — Total Spending Per Customer · Intermediate
Calculate total order value for every customer.
SQL
SELECT c.customer_id, c.customer_name, COALESCE(SUM(o.total_amount), 0) AS total_spent FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name ORDER BY total_spent DESC;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | total_spent |
|---|---|---|
| 5 | Vikas Rana | 364,784 |
| 7 | Rajesh Pandey | 301,121 |
| 2 | Sneha Nair | 288,591 |
| 3 | Amit Bhatia | 284,503 |
| 1 | Rahul Agarwal | 279,165 |
| 4 | Pooja Sinha | 234,617 |
| 6 | Neha Ghosh | 168,306 |
| 8 | Swati Yadav | 152,727 |
| 13 | Chetan Chauhan | 146,279 |
| 19 | Kunal Malhotra | 136,660 |
(showing 10 of 30 rows)
Explanation
LEFT JOIN plus COALESCE(SUM(...), 0) is the standard way to compute a total that should still show 0 — not a missing row — for entities with no activity; without COALESCE, a customer with zero orders would get NULL instead of 0 after the SUM. Vikas Rana tops the list at ₹364,784 in total order value (including cancelled orders, since this query doesn't filter status), followed by Rajesh Pandey and Sneha Nair.
💻 Code example
SELECT c.customer_id, c.customer_name, COALESCE(SUM(o.total_amount), 0) AS total_spent FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name ORDER BY total_spent DESC;
P37 — Customers Spending More Than 10000 · Intermediate
Find customers whose total order value exceeds 10,000.
SQL
SELECT c.customer_id, c.customer_name, SUM(o.total_amount) AS total_spent FROM customers c JOIN orders o ON c.customer_id = o.customer_id WHERE o.status <> 'Cancelled' GROUP BY c.customer_id, c.customer_name HAVING SUM(o.total_amount) > 10000;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | total_spent |
|---|---|---|
| 17 | Imran Khan | 10,388 |
| 14 | Divya Naidu | 35,179 |
| 24 | Olivia Davis | 60,289 |
| 15 | Gagan Menon | 64,780 |
| 21 | John Smith | 122,783 |
| 2 | Sneha Nair | 257,915 |
| 26 | Fatima Ali | 36,989 |
| 9 | Manoj Kulkarni | 42,467 |
| 10 | Kirti Shetty | 17,583 |
| 12 | Bhavna Trivedi | 24,388 |
(showing 10 of 23 rows)
Explanation
Unlike Problem 36, this query uses an inner JOIN (dropping customers with no orders entirely) and explicitly excludes cancelled orders with WHERE o.status <> 'Cancelled' before the HAVING SUM(...) > 10000 filter runs — so the ₹10,000 threshold is judged against real, non-cancelled spending only. 23 of the 30 customers clear that bar.
💻 Code example
SELECT c.customer_id, c.customer_name, SUM(o.total_amount) AS total_spent FROM customers c JOIN orders o ON c.customer_id = o.customer_id WHERE o.status <> 'Cancelled' GROUP BY c.customer_id, c.customer_name HAVING SUM(o.total_amount) > 10000;
P38 — Average Order Value by Customer · Intermediate
Find the average order amount for each customer.
SQL
SELECT customer_id, AVG(total_amount) AS average_order_value FROM orders GROUP BY customer_id;
Output (computed against this section's live 9-table dataset)
| customer_id | average_order_value |
|---|---|
| 19 | 34,165 |
| 11 | 7,744 |
| 3 | 25,863.91 |
| 16 | 18,091.25 |
| 23 | 5,794 |
| 2 | 16,975.94 |
| 21 | 61,391.50 |
| 13 | 48,759.67 |
| 7 | 30,112.10 |
| 4 | 23,461.70 |
(showing 10 of 28 rows)
Explanation
Grouping directly on orders.customer_id (no join needed, since customer_id already lives on that table) and averaging total_amount per group gives each customer's typical order size — note this doesn't exclude cancelled orders, so it's a slightly different, simpler metric than the lifetime-value calculations later in the chapter. 28 of the 30 customers have at least one order and therefore an average.
💻 Code example
SELECT customer_id, AVG(total_amount) AS average_order_value FROM orders GROUP BY customer_id;
P39 — Revenue by Product · Intermediate
Calculate revenue generated by each product using order_items.
SQL
SELECT p.product_id, p.product_name, SUM(oi.quantity * oi.unit_price) AS revenue FROM products p JOIN order_items oi ON p.product_id = oi.product_id JOIN orders o ON oi.order_id = o.order_id WHERE o.status <> 'Cancelled' GROUP BY p.product_id, p.product_name ORDER BY revenue DESC;
Output (computed against this section's live 9-table dataset)
| product_id | product_name | revenue |
|---|---|---|
| 3 | 27-inch 4K Monitor | 849,966 |
| 17 | Air Fryer | 215,964 |
| 5 | Noise Cancelling Headphones | 206,977 |
| 7 | Portable SSD 1TB | 202,971 |
| 4 | USB-C Hub | 158,478 |
| 1 | Wireless Mouse | 131,835 |
| 2 | Mechanical Keyboard | 125,964 |
| 11 | Dumbbell Set 10kg | 96,163 |
| 19 | Mixer Grinder | 95,671 |
| 20 | Ceramic Dinner Set | 85,761 |
(showing 10 of 27 rows)
Explanation
Revenue has to be computed from order_items (quantity * unit_price), not from orders.total_amount, because the question asks for revenue per product — a single order's total_amount is a sum across possibly several different products. The two joins connect products to their line items and then to the parent order so the Cancelled filter can be applied; the 27-inch 4K Monitor leads by a wide margin at ₹849,966, roughly four times the next highest product, reflecting its high unit price (₹24,999) even at modest sales volume.
💻 Code example
SELECT p.product_id, p.product_name, SUM(oi.quantity * oi.unit_price) AS revenue FROM products p JOIN order_items oi ON p.product_id = oi.product_id JOIN orders o ON oi.order_id = o.order_id WHERE o.status <> 'Cancelled' GROUP BY p.product_id, p.product_name ORDER BY revenue DESC;
P40 — Best-Selling Products by Quantity · Intermediate
Find the five products with the highest total quantity sold.
SQL
SELECT p.product_name, SUM(oi.quantity) AS total_quantity FROM products p JOIN order_items oi ON p.product_id = oi.product_id JOIN orders o ON oi.order_id = o.order_id WHERE o.status <> 'Cancelled' GROUP BY p.product_id, p.product_name ORDER BY total_quantity DESC LIMIT 5;
Output (computed against this section's live 9-table dataset)
| product_name | total_quantity |
|---|---|
| Wireless Mouse | 165 |
| USB-C Hub | 122 |
| Ceramic Dinner Set | 39 |
| Dumbbell Set 10kg | 37 |
| Air Fryer | 36 |
(5 rows total)
Explanation
This is nearly identical to Problem 39 but sums quantity instead of quantity * unit_price and keeps only the top 5 — so it ranks products by units moved, not revenue earned. The Wireless Mouse tops this list (165 units) despite being one of the cheapest products in the catalog, which is exactly the kind of 'high revenue' vs 'high volume' distinction that matters when deciding what to reorder versus what to keep pushing.
💻 Code example
SELECT p.product_name, SUM(oi.quantity) AS total_quantity FROM products p JOIN order_items oi ON p.product_id = oi.product_id JOIN orders o ON oi.order_id = o.order_id WHERE o.status <> 'Cancelled' GROUP BY p.product_id, p.product_name ORDER BY total_quantity DESC LIMIT 5;
P41 — Employees With Their Managers · Intermediate
Display each employee and their manager's name.
SQL
SELECT e.first_name AS employee, m.first_name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.employee_id;
Output (computed against this section's live 9-table dataset)
| employee | manager |
|---|---|
| Meera | Arvind |
| Karan | Arvind |
| Priya | Arvind |
| Suresh | Arvind |
| Anil | Arvind |
| Sunita | Arvind |
| Rohan | Meera |
| Ananya | Meera |
| Vikram | Karan |
| Kavya | Priya |
(showing 10 of 40 rows)
Explanation
This is a self-join: employees is joined to itself (aliased e and m) by matching each employee's manager_id to another employee's employee_id. LEFT JOIN is important here — using a plain JOIN would silently drop Arvind Krishnan, the CEO, since his manager_id is NULL and has no match; with LEFT JOIN, he still appears in the result with manager showing as NULL.
💻 Code example
SELECT e.first_name AS employee, m.first_name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.employee_id;
P42 — Employees Earning Above Department Average · Intermediate
Find employees whose salary is greater than their department's average salary.
SQL
SELECT e.employee_id, e.first_name, e.salary, e.department_id FROM employees e WHERE e.salary > ( SELECT AVG(e2.salary) FROM employees e2 WHERE e2.department_id = e.department_id );
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | salary | department_id |
|---|---|---|---|
| 1 | Arvind | 285,000 | 1 |
| 2 | Meera | 215,000 | 1 |
| 3 | Karan | 198,000 | 2 |
| 4 | Priya | 175,000 | 3 |
| 5 | Suresh | 160,000 | 4 |
| 6 | Anil | 205,000 | 5 |
| 7 | Sunita | 168,000 | 6 |
| 8 | Rohan | 152,000 | 1 |
| 9 | Ananya | 148,000 | 1 |
| 10 | Vikram | 131,000 | 2 |
(showing 10 of 15 rows)
Explanation
The correlated subquery SELECT AVG(e2.salary) FROM employees e2 WHERE e2.department_id = e.department_id re-runs once per outer row, each time computing that specific employee's own department average — it can't be computed once up front because the answer depends on which employee's department is being checked. 15 employees clear their department's bar, which by definition can never be more than half of any department (an average splits a group roughly down the middle, modulo how skewed the distribution is).
💻 Code example
SELECT e.employee_id, e.first_name, e.salary, e.department_id FROM employees e WHERE e.salary > ( SELECT AVG(e2.salary) FROM employees e2 WHERE e2.department_id = e.department_id );
P43 — Employees With Highest Salary · Intermediate
Find all employees who earn the maximum salary.
SQL
SELECT employee_id, first_name, last_name, salary FROM employees WHERE salary = ( SELECT MAX(salary) FROM employees );
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | last_name | salary |
|---|---|---|---|
| 1 | Arvind | Krishnan | 285,000 |
(1 row total)
Explanation
The subquery SELECT MAX(salary) FROM employees runs once and returns a single number (₹285,000), and the outer query then finds every employee whose salary equals it — using = rather than ORDER BY ... LIMIT 1 matters because it correctly returns all employees tied for the maximum, not just one, even though in this dataset there's exactly one: Arvind Krishnan.
💻 Code example
SELECT employee_id, first_name, last_name, salary FROM employees WHERE salary = ( SELECT MAX(salary) FROM employees );
P44 — Second Highest Salary · Intermediate
Find the second highest distinct employee salary.
SQL
SELECT MAX(salary) AS second_highest_salary FROM employees WHERE salary < ( SELECT MAX(salary) FROM employees );
Output (computed against this section's live 9-table dataset)
| second_highest_salary |
|---|
| 215,000 |
(1 row total)
Explanation
This finds the highest salary that is less than the overall maximum — effectively the second-highest distinct value. Since Arvind's ₹285,000 is unique, filtering it out and taking the max of what remains correctly returns ₹215,000 (Meera Nair's salary) without needing LIMIT 1, 1-style offset syntax, which isn't standard SQL anyway.
💻 Code example
SELECT MAX(salary) AS second_highest_salary FROM employees WHERE salary < ( SELECT MAX(salary) FROM employees );
P45 — Employees With Same Salary · Intermediate
Find salary values shared by more than one employee.
SQL
SELECT salary, COUNT(*) AS employee_count FROM employees GROUP BY salary HAVING COUNT(*) > 1;
Output (computed against this section's live 9-table dataset)
| salary | employee_count |
|---|---|
| 83,315.91 | 2 |
(1 row total)
Explanation
GROUP BY salary groups all employees who share the exact same numeric salary, and HAVING COUNT(*) > 1 keeps only groups with more than one member — normally salaries this granular (down to the cent) almost never collide by chance, and indeed this dataset returns exactly one such pair: two employees who happen to share a salary of ₹83,315.91. In a real payroll table, a hit here would usually be worth double-checking as a possible data-entry coincidence rather than assuming it's meaningful.
💻 Code example
SELECT salary, COUNT(*) AS employee_count FROM employees GROUP BY salary HAVING COUNT(*) > 1;
P46 — Duplicate Customer Emails · Intermediate
Find email addresses used by more than one customer.
SQL
SELECT email, COUNT(*) AS email_count FROM customers GROUP BY email HAVING COUNT(*) > 1;
Output (computed against this section's live 9-table dataset)
| email_count | |
|---|---|
| rahul.agarwal1@mail.com | 2 |
(1 row total)
Explanation
The same GROUP BY ... HAVING COUNT(*) > 1 pattern as Problem 45, applied to email instead of salary — this is a genuinely useful data-quality check, since two customer accounts sharing one email address usually means either a duplicate signup or a shared family/work inbox. One pair turns up here.
💻 Code example
SELECT email, COUNT(*) AS email_count FROM customers GROUP BY email HAVING COUNT(*) > 1;
P47 — Orders in 2025 · Intermediate
Find all orders placed during 2025.
SQL
SELECT * FROM orders WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01';
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | employee_id | order_date | status | total_amount |
|---|---|---|---|---|---|
| 1 | 7 | 10 | 2025-07-18 | Delivered | 13,490 |
| 3 | 9 | 3 | 2025-05-20 | Pending | 9,092 |
| 4 | 19 | 21 | 2025-09-26 | Delivered | 12,293 |
| 6 | 21 | 27 | 2025-04-07 | Delivered | 115,286 |
| 8 | 8 | 3 | 2025-11-26 | Cancelled | 6,294 |
| 9 | 2 | 39 | 2025-11-18 | Cancelled | 13,891 |
| 10 | 2 | 3 | 2025-06-20 | Delivered | 76,385 |
| 11 | 7 | 15 | 2025-10-20 | Delivered | 35,991 |
| 12 | 6 | 39 | 2025-01-05 | Delivered | 4,995 |
| 13 | 17 | 39 | 2025-11-17 | Delivered | 10,388 |
(showing 10 of 75 rows)
Explanation
order_date >= '2025-01-01' AND order_date < '2026-01-01' is a half-open range — it deliberately avoids <= '2025-12-31', because if order_date ever carried a time component (a timestamp instead of a plain date), an order at 2025-12-31 15:00:00 would be excluded by a <= comparison against midnight but correctly included by < '2026-01-01'. 75 of the 148 orders in this dataset fall in 2025, versus the remainder in 2024.
💻 Code example
SELECT * FROM orders WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01';
P48 — Monthly Revenue · Intermediate
Calculate revenue for each month in 2025.
SQL
SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01' AND status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ORDER BY month;
Output (computed against this section's live 9-table dataset)
| month | revenue |
|---|---|
| 2025-01-01 | 121,116 |
| 2025-02-01 | 144,580 |
| 2025-03-01 | 30,777 |
| 2025-04-01 | 224,547 |
| 2025-05-01 | 59,558 |
| 2025-06-01 | 182,141 |
| 2025-07-01 | 32,380 |
| 2025-08-01 | 95,174 |
| 2025-09-01 | 146,534 |
| 2025-10-01 | 86,872 |
(showing 10 of 12 rows)
Explanation
DATE_TRUNC('month', order_date) rounds every date down to the first day of its month, so all orders placed anywhere in, say, January 2025 collapse into one 2025-01-01 group; summing total_amount per group (after excluding cancelled orders and restricting to 2025) gives a real month-by-month revenue trend. Revenue swings noticeably month to month in this dataset — January comes in at ₹121,116 and February climbs to ₹144,580 before March drops sharply to ₹30,777 — exactly the kind of volatility that later problems (P89-P91) analyze with LAG and moving averages.
💻 Code example
SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01' AND status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ORDER BY month;
P49 — Orders Per Month · Intermediate
Count how many orders were placed in each month.
SQL
SELECT DATE_TRUNC('month', order_date) AS month, COUNT(*) AS order_count FROM orders GROUP BY DATE_TRUNC('month', order_date) ORDER BY month;
Output (computed against this section's live 9-table dataset)
| month | order_count |
|---|---|
| 2024-01-01 | 4 |
| 2024-02-01 | 4 |
| 2024-03-01 | 7 |
| 2024-04-01 | 4 |
| 2024-05-01 | 6 |
| 2024-06-01 | 6 |
| 2024-07-01 | 9 |
| 2024-08-01 | 8 |
| 2024-09-01 | 7 |
| 2024-10-01 | 7 |
(showing 10 of 24 rows)
Explanation
This mirrors Problem 48's DATE_TRUNC('month', ...) grouping but counts orders instead of summing revenue, and — unlike P48 — doesn't filter by year or status, so it covers the full 24-month history (2024-2025) including cancelled orders. Order volume trends gently upward over the two years, consistent with the growing customer base this dataset was generated with.
💻 Code example
SELECT DATE_TRUNC('month', order_date) AS month, COUNT(*) AS order_count FROM orders GROUP BY DATE_TRUNC('month', order_date) ORDER BY month;
P50 — Customers Who Placed Multiple Orders · Intermediate
Find customers with at least three orders.
SQL
SELECT customer_id, COUNT(*) AS order_count FROM orders GROUP BY customer_id HAVING COUNT(*) >= 3;
Output (computed against this section's live 9-table dataset)
| customer_id | order_count |
|---|---|
| 6 | 12 |
| 10 | 3 |
| 15 | 4 |
| 18 | 3 |
| 22 | 4 |
| 3 | 11 |
| 2 | 17 |
| 16 | 4 |
| 9 | 4 |
| 20 | 3 |
(showing 10 of 18 rows)
Explanation
Grouping by customer_id and filtering with HAVING COUNT(*) >= 3 identifies repeat buyers — 18 of the 30 customers have placed three or more orders. Customer 6 leads by a wide margin with 12 orders, reflecting this dataset's deliberate mix of a handful of very frequent buyers alongside many occasional ones.
💻 Code example
SELECT customer_id, COUNT(*) AS order_count FROM orders GROUP BY customer_id HAVING COUNT(*) >= 3;
P51 — Latest Order for Each Customer · Intermediate
Find the most recent order date for every customer.
SQL
SELECT customer_id, MAX(order_date) AS latest_order_date FROM orders GROUP BY customer_id;
Output (computed against this section's live 9-table dataset)
| customer_id | latest_order_date |
|---|---|
| 1 | 2025-09-02 |
| 17 | 2025-11-17 |
| 12 | 2025-08-03 |
| 27 | 2025-09-12 |
| 16 | 2025-11-17 |
| 23 | 2024-07-11 |
| 6 | 2025-06-09 |
| 10 | 2025-03-11 |
| 15 | 2025-12-06 |
| 18 | 2025-06-17 |
(showing 10 of 28 rows)
Explanation
MAX(order_date) per customer_id group gives each customer's single most recent order — a useful building block for recency analysis, reused directly in the next problem's inactivity check. 28 of the 30 customers have at least one order and therefore a latest-order date; the 2 with zero orders are silently excluded since this uses an inner-style aggregation with no LEFT JOIN back to customers.
💻 Code example
SELECT customer_id, MAX(order_date) AS latest_order_date FROM orders GROUP BY customer_id;
P52 — Customers Inactive for 180 Days · Intermediate
Find customers whose latest order was more than 180 days before 2026-01-01.
SQL
SELECT c.customer_id, c.customer_name, MAX(o.order_date) AS latest_order FROM customers c JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name HAVING MAX(o.order_date) < DATE '2026-01-01' - INTERVAL '180 days';
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | latest_order |
|---|---|---|
| 24 | Olivia Davis | 2024-07-16 |
| 14 | Divya Naidu | 2025-04-03 |
| 18 | Jyoti Das | 2025-06-17 |
| 6 | Neha Ghosh | 2025-06-09 |
| 21 | John Smith | 2025-04-07 |
| 20 | Lata Pillai | 2025-05-24 |
| 26 | Fatima Ali | 2024-01-16 |
| 10 | Kirti Shetty | 2025-03-11 |
| 23 | Michael Johnson | 2024-07-11 |
| 11 | Alok Bose | 2024-04-19 |
(10 rows total)
Explanation
This wraps the MAX(order_date) idea from Problem 51 in a HAVING clause compared against a computed cutoff — DATE '2026-01-01' - INTERVAL '180 days' evaluates to 2025-07-05, so any customer whose most recent order predates that is flagged as inactive. 10 customers qualify, including Olivia Davis, whose last order was back in July 2024 — well over a year stale relative to the 2026-01-01 reference point.
💻 Code example
SELECT c.customer_id, c.customer_name, MAX(o.order_date) AS latest_order FROM customers c JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name HAVING MAX(o.order_date) < DATE '2026-01-01' - INTERVAL '180 days';
P53 — Orders Above Customer Average · Intermediate
Find orders whose amount is greater than that customer's average order value.
SQL
SELECT o.order_id, o.customer_id, o.total_amount FROM orders o WHERE o.total_amount > ( SELECT AVG(o2.total_amount) FROM orders o2 WHERE o2.customer_id = o.customer_id );
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | total_amount |
|---|---|---|
| 6 | 21 | 115,286 |
| 10 | 2 | 76,385 |
| 11 | 7 | 35,991 |
| 13 | 17 | 10,388 |
| 15 | 3 | 52,588 |
| 17 | 6 | 31,786 |
| 23 | 3 | 38,588 |
| 28 | 1 | 64,089 |
| 30 | 20 | 24,688 |
| 36 | 7 | 87,790 |
(showing 10 of 43 rows)
Explanation
The correlated subquery here recomputes AVG(o2.total_amount) separately for each customer (matching o2.customer_id = o.customer_id in the inner query to the current outer row), so 'above average' means above that customer's own average order size, not the company-wide average. 43 orders qualify — by construction, roughly half of any customer's orders will sit above their own personal average, so this is a reasonable-looking result.
💻 Code example
SELECT o.order_id, o.customer_id, o.total_amount FROM orders o WHERE o.total_amount > ( SELECT AVG(o2.total_amount) FROM orders o2 WHERE o2.customer_id = o.customer_id );
P54 — Highest Order Per Customer · Intermediate
Find each customer's highest order amount.
SQL
SELECT customer_id, MAX(total_amount) AS highest_order FROM orders GROUP BY customer_id;
Output (computed against this section's live 9-table dataset)
| customer_id | highest_order |
|---|---|
| 1 | 64,089 |
| 17 | 10,388 |
| 12 | 24,388 |
| 27 | 3,397 |
| 16 | 39,986 |
| 23 | 5,794 |
| 3 | 60,191 |
| 19 | 80,987 |
| 11 | 8,892 |
| 9 | 22,389 |
(showing 10 of 28 rows)
Explanation
A straightforward MAX(total_amount) grouped by customer_id — each customer's single largest order. This becomes useful context for Problem 116 later, which checks whether a customer's orders have been trending upward over time.
💻 Code example
SELECT customer_id, MAX(total_amount) AS highest_order FROM orders GROUP BY customer_id;
P55 — Orders Above Overall Average · Intermediate
Find all orders whose amount is above the overall average order amount.
SQL
SELECT order_id, customer_id, total_amount FROM orders WHERE total_amount > ( SELECT AVG(total_amount) FROM orders );
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | total_amount |
|---|---|---|
| 6 | 21 | 115,286 |
| 10 | 2 | 76,385 |
| 11 | 7 | 35,991 |
| 15 | 3 | 52,588 |
| 17 | 6 | 31,786 |
| 19 | 24 | 60,289 |
| 23 | 3 | 38,588 |
| 28 | 1 | 64,089 |
| 30 | 20 | 24,688 |
| 36 | 7 | 87,790 |
(showing 10 of 39 rows)
Explanation
This is the company-wide version of Problem 53: the subquery SELECT AVG(total_amount) FROM orders runs once (not once per customer) and returns a single number, so every order is compared against the same global bar. 39 orders clear it — fewer than Problem 53's 43, because a company-wide average pulled up by a few very large orders is a harder bar to clear than each customer's own, more personalized average.
💻 Code example
SELECT order_id, customer_id, total_amount FROM orders WHERE total_amount > ( SELECT AVG(total_amount) FROM orders );
P56 — Employee Salary Bands · Intermediate
Show employees and classify salaries into four bands.
SQL
SELECT first_name, salary, CASE WHEN salary < 40000 THEN 'Band 1' WHEN salary < 60000 THEN 'Band 2' WHEN salary < 90000 THEN 'Band 3' ELSE 'Band 4' END AS salary_band FROM employees;
Output (computed against this section's live 9-table dataset)
| first_name | salary | salary_band |
|---|---|---|
| Arvind | 285,000 | Band 4 |
| Meera | 215,000 | Band 4 |
| Karan | 198,000 | Band 4 |
| Priya | 175,000 | Band 4 |
| Suresh | 160,000 | Band 4 |
| Anil | 205,000 | Band 4 |
| Sunita | 168,000 | Band 4 |
| Rohan | 152,000 | Band 4 |
| Ananya | 148,000 | Band 4 |
| Vikram | 131,000 | Band 4 |
(showing 10 of 40 rows)
Explanation
This is the same CASE WHEN pattern as Problem 29 but with four bands instead of three and different thresholds. Because this org's leadership salaries are so high relative to the bands (all six figures well above the ₹90,000 top threshold), most senior employees land in 'Band 4', while the newer individual contributors spread across the lower bands — the CEO, the department heads, and several managers all land in Band 4 here.
💻 Code example
SELECT first_name, salary, CASE WHEN salary < 40000 THEN 'Band 1' WHEN salary < 60000 THEN 'Band 2' WHEN salary < 90000 THEN 'Band 3' ELSE 'Band 4' END AS salary_band FROM employees;
P57 — Payment Success Rate · Intermediate
Calculate the percentage of payments that were successful.
SQL
SELECT ROUND( 100.0 * SUM(CASE WHEN payment_status = 'Success' THEN 1 ELSE 0 END) / COUNT(*), 2 ) AS success_rate FROM payments;
Output (computed against this section's live 9-table dataset)
| success_rate |
|---|
| 83.69 |
(1 row total)
Explanation
SUM(CASE WHEN payment_status = 'Success' THEN 1 ELSE 0 END) is 'conditional counting': the CASE expression turns each row into a 1 or 0 depending on whether it matches, and SUM then adds those up — this is a very common technique for computing a rate or percentage in one pass without a self-join or subquery. Multiplying by 100.0 (not 100) before dividing forces floating-point division instead of integer division, which matters in engines where integer/integer truncates the decimal part. The result: 83.69% of all payment attempts in this dataset succeeded on the first try.
💻 Code example
SELECT ROUND( 100.0 * SUM(CASE WHEN payment_status = 'Success' THEN 1 ELSE 0 END) / COUNT(*), 2 ) AS success_rate FROM payments;
P58 — Payment Method Usage · Intermediate
Count payments made using each payment method.
SQL
SELECT payment_method, COUNT(*) AS payment_count FROM payments GROUP BY payment_method ORDER BY payment_count DESC;
Output (computed against this section's live 9-table dataset)
| payment_method | payment_count |
|---|---|
| Cash | 38 |
| Netbanking | 37 |
| Card | 33 |
| UPI | 33 |
(4 rows total)
Explanation
Grouping payments by payment_method and counting rows per group shows how customers are actually paying — Cash leads narrowly (38 payments) over Netbanking (37) and Card (33), with the fourth method (UPI) close behind, a fairly even spread across all four methods in this dataset.
💻 Code example
SELECT payment_method, COUNT(*) AS payment_count FROM payments GROUP BY payment_method ORDER BY payment_count DESC;
P59 — Successful Payment Revenue · Intermediate
Calculate total amount from successful payments.
SQL
SELECT SUM(amount) AS successful_payment_amount FROM payments WHERE payment_status = 'Success';
Output (computed against this section's live 9-table dataset)
| successful_payment_amount |
|---|
| 2,487,710.56 |
(1 row total)
Explanation
WHERE payment_status = 'Success' filters out failed and refunded payment rows before the SUM, so this total (~₹2.49 million) reflects money that was actually, successfully collected — it will always be somewhat less than the ₹2.7 million non-cancelled order revenue from Problem 24, since not every non-cancelled order has a fully successful payment (see Problem 60 and Problem 143 for exactly which ones don't).
💻 Code example
SELECT SUM(amount) AS successful_payment_amount FROM payments WHERE payment_status = 'Success';
P60 — Orders Without Successful Payments · Intermediate
Find orders that do not have a successful payment.
SQL
SELECT o.order_id, o.total_amount FROM orders o LEFT JOIN payments p ON o.order_id = p.order_id AND p.payment_status = 'Success' WHERE p.payment_id IS NULL;
Output (computed against this section's live 9-table dataset)
| order_id | total_amount |
|---|---|
| 4 | 12,293 |
| 8 | 6,294 |
| 9 | 13,891 |
| 16 | 8,392 |
| 18 | 13,196 |
| 21 | 9,394 |
| 26 | 2,397 |
| 29 | 3,696 |
| 30 | 24,688 |
| 33 | 13,592 |
(showing 10 of 38 rows)
Explanation
The join condition here does double duty: LEFT JOIN payments p ON o.order_id = p.order_id AND p.payment_status = 'Success' filters for successful payments inside the join itself, not in a WHERE clause — that distinction matters, because putting p.payment_status = 'Success' in WHERE instead would silently turn this back into an inner join and lose every order that has zero payment rows at all. WHERE p.payment_id IS NULL then catches orders where no successful payment ever matched. 38 orders show up here — a mix of this dataset's deliberately unpaid orders and its deliberately underpaid ones (their one payment row exists but wasn't enough, or wasn't marked Success).
💻 Code example
SELECT o.order_id, o.total_amount FROM orders o LEFT JOIN payments p ON o.order_id = p.order_id AND p.payment_status = 'Success' WHERE p.payment_id IS NULL;
P61 — Employee Name Formatting · Intermediate
Return each employee's full name in one column.
SQL
SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM employees;
Output (computed against this section's live 9-table dataset)
| full_name |
|---|
| Arvind Krishnan |
| Meera Nair |
| Karan Malhotra |
| Priya Chopra |
| Suresh Iyer |
| Anil Bhatt |
| Sunita Rao |
| Rohan Verma |
| Ananya Singh |
| Vikram Reddy |
(showing 10 of 40 rows)
Explanation
CONCAT(first_name, ' ', last_name) joins three pieces of text — first name, a literal space, and last name — into a single computed column; the underlying table is untouched, this only affects what's returned. The same effect could be written with first_name || ' ' || last_name in DuckDB.
💻 Code example
SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM employees;
P62 — Uppercase Customer Names · Intermediate
Display all customer names in uppercase.
SQL
SELECT UPPER(customer_name) AS customer_name FROM customers;
Output (computed against this section's live 9-table dataset)
| customer_name |
|---|
| RAHUL AGARWAL |
| SNEHA NAIR |
| AMIT BHATIA |
| POOJA SINHA |
| VIKAS RANA |
| NEHA GHOSH |
| RAJESH PANDEY |
| SWATI YADAV |
| MANOJ KULKARNI |
| KIRTI SHETTY |
(showing 10 of 30 rows)
Explanation
UPPER() transforms the text of customer_name to all-caps for display, without changing the stored data — useful for case-insensitive comparisons or simply for a formatting requirement in a report or label.
💻 Code example
SELECT UPPER(customer_name) AS customer_name FROM customers;
P63 — Product Name Length · Intermediate
Find products whose names contain more than 20 characters.
SQL
SELECT product_name, LENGTH(product_name) AS name_length FROM products WHERE LENGTH(product_name) > 20;
Output (computed against this section's live 9-table dataset)
| product_name | name_length |
|---|---|
| Noise Cancelling Headphones | 27 |
| The Pragmatic Programmer | 24 |
| Designing Data-Intensive Applications | 37 |
(3 rows total)
Explanation
LENGTH() counts characters, and the WHERE clause filters for names longer than 20 — only 3 of the 28 products have names that long: 'Noise Cancelling Headphones' (27 characters), 'The Pragmatic Programmer' (24), and 'Designing Data-Intensive Applications' (37, the longest product name in the catalog).
💻 Code example
SELECT product_name, LENGTH(product_name) AS name_length FROM products WHERE LENGTH(product_name) > 20;
P64 — Customers Signed Up in 2024 · Intermediate
Find customers who registered during 2024.
SQL
SELECT customer_id, customer_name, signup_date FROM customers WHERE signup_date >= '2024-01-01' AND signup_date < '2025-01-01';
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | signup_date |
|---|---|---|
| 1 | Rahul Agarwal | 2024-02-08 |
| 2 | Sneha Nair | 2024-02-11 |
| 4 | Pooja Sinha | 2024-10-08 |
| 8 | Swati Yadav | 2024-02-17 |
| 9 | Manoj Kulkarni | 2024-05-22 |
| 10 | Kirti Shetty | 2024-04-18 |
| 11 | Alok Bose | 2024-12-19 |
| 13 | Chetan Chauhan | 2024-07-07 |
| 15 | Gagan Menon | 2024-06-14 |
| 16 | Harsha Iyer | 2024-08-28 |
(showing 10 of 18 rows)
Explanation
The same half-open date-range pattern as Problem 47, now applied to signup_date on customers and shifted to the 2024 calendar year. 18 of the 30 customers signed up during 2024, the year this dataset weights most heavily for new signups.
💻 Code example
SELECT customer_id, customer_name, signup_date FROM customers WHERE signup_date >= '2024-01-01' AND signup_date < '2025-01-01';
P65 — Employees Hired by Year · Intermediate
Count employees hired in each year.
SQL
SELECT EXTRACT(YEAR FROM hire_date) AS hire_year, COUNT(*) AS employee_count FROM employees GROUP BY EXTRACT(YEAR FROM hire_date) ORDER BY hire_year;
Output (computed against this section's live 9-table dataset)
| hire_year | employee_count |
|---|---|
| 2,019 | 4 |
| 2,020 | 7 |
| 2,021 | 6 |
| 2,022 | 5 |
| 2,023 | 8 |
| 2,024 | 7 |
| 2,025 | 3 |
(7 rows total)
Explanation
EXTRACT(YEAR FROM hire_date) pulls just the year component out of each date, so grouping by it buckets employees by hiring year regardless of month or day. Hiring is fairly steady from 2019 through 2025 in this dataset, with 4 hires in the founding year (2019) climbing to a peak in the following years as the company grew.
💻 Code example
SELECT EXTRACT(YEAR FROM hire_date) AS hire_year, COUNT(*) AS employee_count FROM employees GROUP BY EXTRACT(YEAR FROM hire_date) ORDER BY hire_year;
P66 — Average Order by Status · Intermediate
Calculate the average order amount for each order status.
SQL
SELECT status, AVG(total_amount) AS average_amount FROM orders GROUP BY status;
Output (computed against this section's live 9-table dataset)
| status | average_amount |
|---|---|
| Returned | 14,392 |
| Cancelled | 14,909.72 |
| processing | 52,588 |
| Shipped | 26,860.09 |
| Delivered | 21,845.28 |
| Pending | 10,394.83 |
(6 rows total)
Explanation
This groups by the raw status column with no validation or filtering — which means it faithfully surfaces this dataset's two deliberately dirty status values ('Returned' and lowercase 'processing') as their own separate groups alongside the four legitimate statuses (Pending, Shipped, Delivered, Cancelled). That's a realistic outcome: a naive GROUP BY status report in production will just as readily reveal bad data as it reveals real trends — which is exactly the kind of issue Problem 149 later hunts down explicitly.
💻 Code example
SELECT status, AVG(total_amount) AS average_amount FROM orders GROUP BY status;
P67 — Department Salary Summary · Intermediate
Show employee count, minimum salary, maximum salary, and average salary per department.
SQL
SELECT department_id, COUNT(*) AS employee_count, MIN(salary) AS minimum_salary, MAX(salary) AS maximum_salary, AVG(salary) AS average_salary FROM employees GROUP BY department_id;
Output (computed against this section's live 9-table dataset)
| department_id | employee_count | minimum_salary | maximum_salary | average_salary |
|---|---|---|---|---|
| 1 | 9 | 46,377.82 | 285,000 | 138,588.51 |
| 2 | 7 | 44,873.10 | 198,000 | 92,711.24 |
| 3 | 7 | 44,693.13 | 175,000 | 99,118.03 |
| 4 | 5 | 38,194.72 | 160,000 | 74,451.17 |
| 5 | 6 | 41,971.32 | 205,000 | 96,749.44 |
| 6 | 6 | 45,545.63 | 168,000 | 90,014.29 |
(6 rows total)
Explanation
Four aggregates — COUNT, MIN, MAX, AVG — computed together in a single grouped query give a compact salary summary per department in one pass, rather than needing four separate queries. Engineering has both the highest average (~₹138,589) and the widest spread (from ₹46,378 up to ₹285,000), since it contains both the CEO and several newer, lower-paid ICs.
💻 Code example
SELECT department_id, COUNT(*) AS employee_count, MIN(salary) AS minimum_salary, MAX(salary) AS maximum_salary, AVG(salary) AS average_salary FROM employees GROUP BY department_id;
P68 — Products Above Category Average · Intermediate
Find products priced above their category's average price.
SQL
SELECT p.product_id, p.product_name, p.category, p.price FROM products p WHERE p.price > ( SELECT AVG(p2.price) FROM products p2 WHERE p2.category = p.category );
Output (computed against this section's live 9-table dataset)
| product_id | product_name | category | price |
|---|---|---|---|
| 3 | 27-inch 4K Monitor | Electronics | 24,999 |
| 5 | Noise Cancelling Headphones | Electronics | 8,999 |
| 6 | Smartwatch | Electronics | 12,999 |
| 9 | Men's Running Shoes | Sports | 3,299 |
| 11 | Dumbbell Set 10kg | Sports | 2,599 |
| 17 | Air Fryer | Home & Kitchen | 5,999 |
| 19 | Mixer Grinder | Home & Kitchen | 3,299 |
| 23 | Denim Jacket | Clothing | 2,499 |
| 25 | The Pragmatic Programmer | Books | 899 |
| 27 | Designing Data-Intensive Applications | Books | 1,499 |
(10 rows total)
Explanation
This is the product-catalog equivalent of Problem 42's correlated subquery: for each product, the inner query recomputes the average price within that product's own category and compares the outer row against it. 10 of the 28 products sit above their category's average price — the 27-inch 4K Monitor (₹24,999) is the most extreme example, sitting far above the Electronics category average thanks to being the single most expensive product in the whole catalog.
💻 Code example
SELECT p.product_id, p.product_name, p.category, p.price FROM products p WHERE p.price > ( SELECT AVG(p2.price) FROM products p2 WHERE p2.category = p.category );
P69 — First Order Per Customer · Intermediate
Find each customer's first order date.
SQL
SELECT customer_id, MIN(order_date) AS first_order_date FROM orders GROUP BY customer_id;
Output (computed against this section's live 9-table dataset)
| customer_id | first_order_date |
|---|---|
| 1 | 2024-02-03 |
| 17 | 2024-09-12 |
| 12 | 2024-12-01 |
| 27 | 2025-09-12 |
| 6 | 2024-03-22 |
| 10 | 2024-03-06 |
| 15 | 2024-09-17 |
| 18 | 2024-08-25 |
| 3 | 2024-03-04 |
| 9 | 2025-04-13 |
(showing 10 of 28 rows)
Explanation
MIN(order_date) per customer group gives each customer's very first order — the mirror image of Problem 51's MAX. This becomes the anchor point Problem 114 later compares against MAX(order_date) to detect customers who only ever ordered once.
💻 Code example
SELECT customer_id, MIN(order_date) AS first_order_date FROM orders GROUP BY customer_id;
P70 — Customer Lifetime Value · Intermediate
Calculate lifetime order value for every customer, including customers with no orders.
SQL
SELECT c.customer_id, c.customer_name, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS lifetime_value FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name ORDER BY lifetime_value DESC;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | lifetime_value |
|---|---|---|
| 5 | Vikas Rana | 316,810 |
| 7 | Rajesh Pandey | 301,121 |
| 3 | Amit Bhatia | 272,008 |
| 2 | Sneha Nair | 257,915 |
| 4 | Pooja Sinha | 213,137 |
| 1 | Rahul Agarwal | 194,986 |
| 6 | Neha Ghosh | 168,306 |
| 8 | Swati Yadav | 146,433 |
| 19 | Kunal Malhotra | 136,660 |
| 21 | John Smith | 122,783 |
(showing 10 of 30 rows)
Explanation
COALESCE(SUM(CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END), 0) combines two ideas at once: the CASE expression zeroes out cancelled orders' contribution before summing, and COALESCE catches customers with zero orders at all (where the SUM itself would otherwise be NULL after the LEFT JOIN). All 30 customers appear with a real number — Vikas Rana again leads at ₹316,810, slightly lower than his Problem 36 total because this version correctly excludes his cancelled orders.
💻 Code example
SELECT c.customer_id, c.customer_name, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS lifetime_value FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name ORDER BY lifetime_value DESC;
Want a visual for this concept?
Generate a diagram tailored to “SQL Practice — Intermediate (Problems 31-70)” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →