SQL Practice — Expert & Real-World (Problems 111-150)
40 real-interview-style problems on recursive CTEs, cohort analysis, retention, Pareto analysis, medians, and data-quality validation — every one solved, run against real data, and explained, including two problems where the given solution has a real limitation worth understanding.
Learning objectives
- Build and read a recursive CTE for an org chart or any other self-referencing hierarchy.
- Run cohort, retention, and streak analysis (3 consecutive months, Pareto 80/20) with window functions.
- Compute medians and percentiles with PERCENTILE_CONT and know why they resist skew better than AVG.
- Spot the specific failure mode of LEFT JOIN ... IS NULL against a one-to-many relationship, and fix it with NOT EXISTS.
P111 — Customers Who Bought Every Product in a Category · Expert
Find customers who purchased every product in the Electronics category.
SQL
SELECT o.customer_id FROM orders o JOIN order_items oi ON o.order_id = oi.order_id JOIN products p ON oi.product_id = p.product_id WHERE p.category = 'Electronics' AND o.status <> 'Cancelled' GROUP BY o.customer_id HAVING COUNT(DISTINCT p.product_id) = ( SELECT COUNT(*) FROM products WHERE category = 'Electronics' );
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 1 |
(1 row total)
Explanation
This checks a strict 'bought absolutely everything' condition: HAVING COUNT(DISTINCT p.product_id) = (SELECT COUNT(*) FROM products WHERE category = 'Electronics') only passes if a customer's distinct Electronics purchases equal the entire size of that category (8 products). Only customer 1 clears that bar in this dataset — via a dedicated order deliberately built to cover all 8 Electronics products in one go, since hitting every single item in a category by ordinary chance across a handful of orders would be extremely unlikely.
💻 Code example
SELECT o.customer_id FROM orders o JOIN order_items oi ON o.order_id = oi.order_id JOIN products p ON oi.product_id = p.product_id WHERE p.category = 'Electronics' AND o.status <> 'Cancelled' GROUP BY o.customer_id HAVING COUNT(DISTINCT p.product_id) = ( SELECT COUNT(*) FROM products WHERE category = 'Electronics' );
P112 — Customers Who Bought at Least One Product From Every Category · Expert
Find customers who purchased products from every product category.
SQL
WITH customer_categories AS ( SELECT o.customer_id, COUNT(DISTINCT p.category) AS category_count FROM orders o JOIN order_items oi ON o.order_id = oi.order_id JOIN products p ON oi.product_id = p.product_id WHERE o.status <> 'Cancelled' GROUP BY o.customer_id ) SELECT customer_id FROM customer_categories WHERE category_count = ( SELECT COUNT(DISTINCT category) FROM products );
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 6 |
| 3 |
| 9 |
| 1 |
| 8 |
| 7 |
| 4 |
(7 rows total)
Explanation
This relaxes Problem 111's bar considerably: instead of buying every product in one category, a customer just needs to have bought something from every category (5 total: Electronics, Sports, Home & Kitchen, Clothing, Books). That's a much easier target for a customer with several varied orders to hit naturally, and 7 of the 30 customers manage it in this dataset.
💻 Code example
WITH customer_categories AS ( SELECT o.customer_id, COUNT(DISTINCT p.category) AS category_count FROM orders o JOIN order_items oi ON o.order_id = oi.order_id JOIN products p ON oi.product_id = p.product_id WHERE o.status <> 'Cancelled' GROUP BY o.customer_id ) SELECT customer_id FROM customer_categories WHERE category_count = ( SELECT COUNT(DISTINCT category) FROM products );
P113 — Most Popular Product Pair · Expert
Find the pair of products most frequently purchased together in the same order.
SQL
SELECT oi1.product_id AS product_a, oi2.product_id AS product_b, COUNT(*) AS pair_count FROM order_items oi1 JOIN order_items oi2 ON oi1.order_id = oi2.order_id AND oi1.product_id < oi2.product_id GROUP BY oi1.product_id, oi2.product_id ORDER BY pair_count DESC LIMIT 1;
Output (computed against this section's live 9-table dataset)
| product_a | product_b | pair_count |
|---|---|---|
| 1 | 4 | 40 |
(1 row total)
Explanation
The self-join oi1.order_id = oi2.order_id AND oi1.product_id < oi2.product_id pairs up every two different products that appeared together in the same order, counts how often each specific pair recurs, and the < (rather than <>) is what prevents each pair from being counted twice in both directions (product 1 with product 4, and product 4 with product 1). Products 1 and 4 — the Wireless Mouse and the USB-C Hub — come out as the single most frequent pairing, appearing together in 40 separate orders, a strong affinity deliberately built into this dataset's order-generation logic.
💻 Code example
SELECT oi1.product_id AS product_a, oi2.product_id AS product_b, COUNT(*) AS pair_count FROM order_items oi1 JOIN order_items oi2 ON oi1.order_id = oi2.order_id AND oi1.product_id < oi2.product_id GROUP BY oi1.product_id, oi2.product_id ORDER BY pair_count DESC LIMIT 1;
P114 — Customers Who Bought a Product but Never Again · Expert
Find customers whose last order was exactly their first order.
SQL
SELECT customer_id FROM orders GROUP BY customer_id HAVING MIN(order_date) = MAX(order_date);
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 23 |
| 24 |
| 25 |
| 9,999 |
| 27 |
| 26 |
(6 rows total)
Explanation
MIN(order_date) = MAX(order_date) per customer is only true when a customer has exactly one order — if they had two or more, their earliest and latest order dates would necessarily differ. 6 customers in this dataset placed exactly one order and never returned, including customers 23-25, three accounts deliberately generated as single-order buyers.
💻 Code example
SELECT customer_id FROM orders GROUP BY customer_id HAVING MIN(order_date) = MAX(order_date);
P115 — Longest Gap Between Customer Orders · Expert
Find the maximum number of days between consecutive orders for each customer.
SQL
WITH gaps AS ( SELECT customer_id, order_date, order_date - LAG(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS gap_days FROM orders ) SELECT customer_id, MAX(gap_days) AS longest_gap FROM gaps GROUP BY customer_id;
Output (computed against this section's live 9-table dataset)
| customer_id | longest_gap |
|---|---|
| 17 | 431 |
| 1 | 177 |
| 12 | 245 |
| 27 | NULL |
| 23 | NULL |
| 16 | 381 |
| 10 | 250 |
| 18 | 181 |
| 6 | 62 |
| 15 | 220 |
(showing 10 of 28 rows)
Explanation
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) computes the days since the previous order for every order a customer placed, and MAX(gap_days) per customer then finds their single longest quiet stretch. Customer 17 has the largest gap in the dataset at 431 days between two orders — more than a year of inactivity before they came back.
💻 Code example
WITH gaps AS ( SELECT customer_id, order_date, order_date - LAG(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS gap_days FROM orders ) SELECT customer_id, MAX(gap_days) AS longest_gap FROM gaps GROUP BY customer_id;
P116 — Customers With Increasing Order Values · Expert
Find customers whose order amount increased compared with their previous order every time after the first order.
SQL
WITH x AS ( SELECT customer_id, order_id, total_amount, LAG(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS previous_amount FROM orders ), summary AS ( SELECT customer_id, COUNT(*) FILTER ( WHERE previous_amount IS NOT NULL AND total_amount > previous_amount ) AS increases, COUNT(*) FILTER ( WHERE previous_amount IS NOT NULL ) AS comparisons FROM x GROUP BY customer_id ) SELECT customer_id FROM summary WHERE comparisons > 0 AND increases = comparisons;
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 17 |
| 11 |
| 21 |
(3 rows total)
Explanation
The FILTER (WHERE ...) clause lets a single COUNT(*) aggregate only rows matching an extra condition, without needing separate CASE WHEN expressions — increases counts comparisons where the order grew, comparisons counts all valid (non-first) comparisons, and a customer only qualifies if every single comparison was an increase (increases = comparisons, with comparisons > 0 guarding against customers with only one order, who'd otherwise trivially pass with 0 = 0). Only 3 customers in this dataset had every one of their orders strictly larger than the one before it.
💻 Code example
WITH x AS ( SELECT customer_id, order_id, total_amount, LAG(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS previous_amount FROM orders ), summary AS ( SELECT customer_id, COUNT(*) FILTER ( WHERE previous_amount IS NOT NULL AND total_amount > previous_amount ) AS increases, COUNT(*) FILTER ( WHERE previous_amount IS NOT NULL ) AS comparisons FROM x GROUP BY customer_id ) SELECT customer_id FROM summary WHERE comparisons > 0 AND increases = comparisons;
P117 — Employees With No Projects in the Last Year · Expert
Assuming the reference date is 2026-01-01, find employees who worked on no projects during 2025.
SQL
SELECT e.employee_id, e.first_name FROM employees e LEFT JOIN employee_projects ep ON e.employee_id = ep.employee_id LEFT JOIN projects p ON ep.project_id = p.project_id AND p.start_date >= '2025-01-01' AND p.start_date < '2026-01-01' WHERE p.project_id IS NULL;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name |
|---|---|
| 2 | Meera |
| 5 | Suresh |
| 8 | Rohan |
| 14 | Rohan |
| 18 | Sanjay |
| 20 | Deepak |
| 24 | Rahul |
| 26 | Suresh |
| 2 | Meera |
| 4 | Priya |
(showing 10 of 39 rows)
Explanation
This query has a real, worth-knowing limitation. Its LEFT JOIN ... WHERE p.project_id IS NULL pattern correctly finds employees with zero project rows at all, but for employees with multiple project assignments, it filters row by row rather than per employee — so an employee who worked on one project in 2025 and one project before 2025 still has a row where the date-filtered join fails to match (their pre-2025 project), and that row alone is enough to let them slip into the 'no projects in 2025' result. In this dataset, employees 4, 6, 9, 32, and 38 all genuinely worked on a 2025 project but leak into the 39-row result anyway for exactly this reason — only employees 11, 12, 17, and 30 (who happened to have only a single, 2025-dated project) are excluded correctly. A reliable version of this check would use NOT EXISTS (SELECT 1 FROM employee_projects ep JOIN projects p ON ... WHERE ep.employee_id = e.employee_id AND p.start_date >= '2025-01-01' AND p.start_date < '2026-01-01') instead, which evaluates the condition per employee rather than per matched row.
💻 Code example
SELECT e.employee_id, e.first_name FROM employees e LEFT JOIN employee_projects ep ON e.employee_id = ep.employee_id LEFT JOIN projects p ON ep.project_id = p.project_id AND p.start_date >= '2025-01-01' AND p.start_date < '2026-01-01' WHERE p.project_id IS NULL;
P118 — Project Utilization · Expert
Calculate each project's percentage of the total hours worked across all projects.
SQL
WITH project_hours AS ( SELECT p.project_id, p.project_name, COALESCE(SUM(ep.hours_worked), 0) AS hours FROM projects p LEFT JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name ) SELECT *, ROUND( 100.0 * hours / NULLIF(SUM(hours) OVER (), 0), 2 ) AS utilization_percentage FROM project_hours;
Output (computed against this section's live 9-table dataset)
| project_id | project_name | hours | utilization_percentage |
|---|---|---|---|
| 5 | Internal HR Portal | 270 | 6.79 |
| 1 | Customer Portal Revamp | 810 | 20.38 |
| 3 | Mobile App Launch | 670 | 16.86 |
| 2 | Data Warehouse Migration | 1,190 | 29.94 |
| 6 | Fraud Detection Engine | 510 | 12.83 |
| 7 | Vendor Payment Automation | 115 | 2.89 |
| 8 | Legacy System Decommission | 0 | 0 |
| 4 | Marketing Automation | 410 | 10.31 |
(8 rows total)
Explanation
SUM(hours) OVER () (no partition) totals hours across every project to use as a shared denominator, the same 'grand total window function' trick as Problem 108's revenue-share calculation — NULLIF guards against division by zero if the grand total were ever 0. Data Warehouse Migration, the project with the most hours logged, would claim the largest utilization share, while the Internal HR Portal (already completed, with a smaller team) sits at the low end around 6.79%.
💻 Code example
WITH project_hours AS ( SELECT p.project_id, p.project_name, COALESCE(SUM(ep.hours_worked), 0) AS hours FROM projects p LEFT JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name ) SELECT *, ROUND( 100.0 * hours / NULLIF(SUM(hours) OVER (), 0), 2 ) AS utilization_percentage FROM project_hours;
P119 — Over-Budget Projects by Hourly Cost · Expert
Assume each project hour costs 100. Find projects where calculated labor cost exceeds the budget.
SQL
SELECT p.project_id, p.project_name, p.budget, SUM(ep.hours_worked) * 100 AS labor_cost FROM projects p JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name, p.budget HAVING SUM(ep.hours_worked) * 100 > p.budget;
Output (computed against this section's live 9-table dataset)
| project_id | project_name | budget | labor_cost |
|---|---|---|---|
| 7 | Vendor Payment Automation | 8,000 | 11,500 |
(1 row total)
Explanation
Multiplying total logged hours by a flat ₹100/hour rate and comparing that estimate against each project's actual budget is a simple way to flag projects burning more labor than they were funded for — the Vendor Payment Automation project is the one case in this dataset where that happens (115 hours × ₹100 = ₹11,500 against an ₹8,000 budget), because it was deliberately given a low budget relative to the ongoing work logged against it.
💻 Code example
SELECT p.project_id, p.project_name, p.budget, SUM(ep.hours_worked) * 100 AS labor_cost FROM projects p JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name, p.budget HAVING SUM(ep.hours_worked) * 100 > p.budget;
P120 — Manager With Most Direct Reports · Expert
Find the manager who has the most direct reports.
SQL
SELECT m.employee_id, m.first_name, COUNT(e.employee_id) AS direct_reports FROM employees m JOIN employees e ON e.manager_id = m.employee_id GROUP BY m.employee_id, m.first_name ORDER BY direct_reports DESC LIMIT 1;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | direct_reports |
|---|---|---|
| 1 | Arvind | 6 |
(1 row total)
Explanation
The self-join e.manager_id = m.employee_id pairs every employee with their manager, and grouping by manager while counting matched employees finds who has the largest team reporting directly to them. Arvind Krishnan, the CEO, has the most direct reports at 6 — exactly the six department heads who report straight to him, before the hierarchy fans out further at the manager and IC levels.
💻 Code example
SELECT m.employee_id, m.first_name, COUNT(e.employee_id) AS direct_reports FROM employees m JOIN employees e ON e.manager_id = m.employee_id GROUP BY m.employee_id, m.first_name ORDER BY direct_reports DESC LIMIT 1;
P121 — Managers With Average Team Salary · Expert
Calculate the average salary of each manager's direct reports.
SQL
SELECT m.employee_id AS manager_id, m.first_name AS manager_name, AVG(e.salary) AS average_team_salary FROM employees m JOIN employees e ON e.manager_id = m.employee_id GROUP BY m.employee_id, m.first_name;
Output (computed against this section's live 9-table dataset)
| manager_id | manager_name | average_team_salary |
|---|---|---|
| 12 | Deepak | 58,374.15 |
| 4 | Priya | 118,000 |
| 2 | Meera | 150,000 |
| 3 | Karan | 75,163.12 |
| 1 | Arvind | 186,833.33 |
| 6 | Anil | 142,000 |
| 7 | Sunita | 74,417.14 |
| 5 | Suresh | 53,063.97 |
| 11 | Kavya | 80,165.24 |
| 8 | Rohan | 89,459.31 |
(10 rows total)
Explanation
This is the same self-join as Problem 120, but averaging salary instead of counting rows — it reveals each manager's team pay level, not just team size. Meera Nair's direct reports average ₹150,000 (she manages senior engineering managers), while Deepak Saxena's team averages a more modest ~₹58,374, reflecting the more junior finance ICs reporting to him.
💻 Code example
SELECT m.employee_id AS manager_id, m.first_name AS manager_name, AVG(e.salary) AS average_team_salary FROM employees m JOIN employees e ON e.manager_id = m.employee_id GROUP BY m.employee_id, m.first_name;
P122 — Employees Earning More Than Their Manager · Expert
Find employees whose salary is greater than their manager's salary.
SQL
SELECT e.employee_id, e.first_name, e.salary, m.first_name AS manager_name, m.salary AS manager_salary FROM employees e JOIN employees m ON e.manager_id = m.employee_id WHERE e.salary > m.salary;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | salary | manager_name | manager_salary |
|---|---|---|---|---|
| 14 | Rohan | 167,000 | Rohan | 152,000 |
(1 row total)
Explanation
Comparing e.salary > m.salary across the same self-join surfaces an organizational anomaly: an individual contributor out-earning their own manager. Exactly one such case exists in this dataset — employee Rohan (id 14, ₹167,000) earns more than his manager, also confusingly named Rohan (id 8, ₹152,000) — a scenario deliberately built into the data, since this kind of compression is a real (if awkward) thing that happens in growing companies when a specialist's market rate outpaces their manager's.
💻 Code example
SELECT e.employee_id, e.first_name, e.salary, m.first_name AS manager_name, m.salary AS manager_salary FROM employees e JOIN employees m ON e.manager_id = m.employee_id WHERE e.salary > m.salary;
P123 — Department With Highest Revenue · Expert
Calculate sales revenue attributed to employees by department and find the highest-revenue department.
SQL
SELECT d.department_id, d.department_name, SUM(o.total_amount) AS revenue FROM departments d JOIN employees e ON d.department_id = e.department_id JOIN orders o ON e.employee_id = o.employee_id WHERE o.status <> 'Cancelled' GROUP BY d.department_id, d.department_name ORDER BY revenue DESC LIMIT 1;
Output (computed against this section's live 9-table dataset)
| department_id | department_name | revenue |
|---|---|---|
| 2 | Sales | 2,705,025 |
(1 row total)
Explanation
Chaining three tables — departments → employees → orders — attributes each non-cancelled order's revenue to the department of the employee who processed the sale (not the customer's department, since customers don't have one). Because orders.employee_id in this schema always references a Sales-team employee (the "sales employee" role in this schema), every single order traces back to the Sales department — so Sales is trivially both the only department with any attributed revenue at all, and therefore also the department with the highest.
💻 Code example
SELECT d.department_id, d.department_name, SUM(o.total_amount) AS revenue FROM departments d JOIN employees e ON d.department_id = e.department_id JOIN orders o ON e.employee_id = o.employee_id WHERE o.status <> 'Cancelled' GROUP BY d.department_id, d.department_name ORDER BY revenue DESC LIMIT 1;
P124 — Employee Sales Ranking · Expert
Rank employees by total sales revenue.
SQL
WITH sales AS ( SELECT e.employee_id, e.first_name, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS revenue FROM employees e LEFT JOIN orders o ON e.employee_id = o.employee_id GROUP BY e.employee_id, e.first_name ) SELECT *, RANK() OVER (ORDER BY revenue DESC) AS sales_rank FROM sales;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | revenue | sales_rank |
|---|---|---|---|
| 27 | Neha | 635,539 | 1 |
| 15 | Vihaan | 533,517 | 2 |
| 33 | Amit | 492,045 | 3 |
| 3 | Karan | 491,241 | 4 |
| 21 | Diya | 228,589 | 5 |
| 39 | Kiara | 171,178 | 6 |
| 10 | Vikram | 152,916 | 7 |
| 25 | Meera | 0 | 8 |
| 24 | Rahul | 0 | 8 |
| 20 | Deepak | 0 | 8 |
(showing 10 of 40 rows)
Explanation
This mirrors Problem 95's customer-spending rank, but for employees and sales revenue instead: the sales CTE sums each employee's non-cancelled order revenue (defaulting to 0 via COALESCE for anyone with none), and RANK() orders the result. Neha (employee 27) leads the whole company at ₹635,539 in attributed sales revenue — notably, only employees in the Sales department can have any nonzero revenue at all here, for the same reason as Problem 123.
💻 Code example
WITH sales AS ( SELECT e.employee_id, e.first_name, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS revenue FROM employees e LEFT JOIN orders o ON e.employee_id = o.employee_id GROUP BY e.employee_id, e.first_name ) SELECT *, RANK() OVER (ORDER BY revenue DESC) AS sales_rank FROM sales;
P125 — Employees With Sales Above Department Average · Expert
Find employees whose sales revenue is above the average employee revenue in their department.
SQL
WITH sales AS ( SELECT e.employee_id, e.first_name, e.department_id, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS revenue FROM employees e LEFT JOIN orders o ON e.employee_id = o.employee_id GROUP BY e.employee_id, e.first_name, e.department_id ) SELECT * FROM ( SELECT s.*, AVG(revenue) OVER ( PARTITION BY department_id ) AS department_avg FROM sales s ) x WHERE revenue > department_avg;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | department_id | revenue | department_avg |
|---|---|---|---|---|
| 27 | Neha | 2 | 635,539 | 386,432.14 |
| 33 | Amit | 2 | 492,045 | 386,432.14 |
| 3 | Karan | 2 | 491,241 | 386,432.14 |
| 15 | Vihaan | 2 | 533,517 | 386,432.14 |
(4 rows total)
Explanation
Because orders.employee_id only ever points to a Sales-department employee, the department_avg window average computed here is only ever nonzero for department_id 2 (Sales) — every other department's employees uniformly show 0 revenue against a 0 average, and 0 > 0 is false, so they correctly never appear. All 4 employees who clear this bar are Sales-team members outperforming their own team's average.
💻 Code example
WITH sales AS ( SELECT e.employee_id, e.first_name, e.department_id, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS revenue FROM employees e LEFT JOIN orders o ON e.employee_id = o.employee_id GROUP BY e.employee_id, e.first_name, e.department_id ) SELECT * FROM ( SELECT s.*, AVG(revenue) OVER ( PARTITION BY department_id ) AS department_avg FROM sales s ) x WHERE revenue > department_avg;
P126 — Recursive Employee Hierarchy · Expert
Build an employee hierarchy starting from top-level employees.
SQL
WITH RECURSIVE hierarchy AS ( SELECT employee_id, first_name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.first_name, e.manager_id, h.level + 1 FROM employees e JOIN hierarchy h ON e.manager_id = h.employee_id ) SELECT * FROM hierarchy ORDER BY level, employee_id;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | manager_id | level |
|---|---|---|---|
| 1 | Arvind | NULL | 1 |
| 2 | Meera | 1 | 2 |
| 3 | Karan | 1 | 2 |
| 4 | Priya | 1 | 2 |
| 5 | Suresh | 1 | 2 |
| 6 | Anil | 1 | 2 |
| 7 | Sunita | 1 | 2 |
| 8 | Rohan | 2 | 3 |
| 9 | Ananya | 2 | 3 |
| 10 | Vikram | 3 | 3 |
(showing 10 of 40 rows)
Explanation
WITH RECURSIVE works in two parts: the 'anchor' half (WHERE manager_id IS NULL) seeds the recursion with the top of the hierarchy — Arvind Krishnan, at level 1 — and the 'recursive' half then repeatedly joins employees back onto the growing hierarchy result, one level at a time, until no more matches are found. All 40 employees eventually appear, each correctly tagged with their depth in the org chart, from Arvind at level 1 down to individual contributors at level 4.
💻 Code example
WITH RECURSIVE hierarchy AS ( SELECT employee_id, first_name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.first_name, e.manager_id, h.level + 1 FROM employees e JOIN hierarchy h ON e.manager_id = h.employee_id ) SELECT * FROM hierarchy ORDER BY level, employee_id;
P127 — Employee Hierarchy Path · Expert
Build a text path showing each employee's hierarchy chain.
SQL
WITH RECURSIVE hierarchy AS ( SELECT employee_id, first_name, manager_id, first_name::TEXT AS path FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.first_name, e.manager_id, h.path || ' > ' || e.first_name FROM employees e JOIN hierarchy h ON e.manager_id = h.employee_id ) SELECT * FROM hierarchy;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | manager_id | path |
|---|---|---|---|
| 1 | Arvind | NULL | Arvind |
| 2 | Meera | 1 | Arvind > Meera |
| 3 | Karan | 1 | Arvind > Karan |
| 4 | Priya | 1 | Arvind > Priya |
| 5 | Suresh | 1 | Arvind > Suresh |
| 6 | Anil | 1 | Arvind > Anil |
| 7 | Sunita | 1 | Arvind > Sunita |
| 8 | Rohan | 2 | Arvind > Meera > Rohan |
| 9 | Ananya | 2 | Arvind > Meera > Ananya |
| 10 | Vikram | 3 | Arvind > Karan > Vikram |
(showing 10 of 40 rows)
Explanation
This is structurally the same recursive CTE as Problem 126, but instead of tracking a numeric level, it builds up a readable text trail with h.path || ' > ' || e.first_name at each recursive step — so by the time the recursion reaches a leaf-level individual contributor, their path column reads as a full chain like 'Arvind > Meera > Rohan > ...', showing every manager between them and the CEO in one string.
💻 Code example
WITH RECURSIVE hierarchy AS ( SELECT employee_id, first_name, manager_id, first_name::TEXT AS path FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.first_name, e.manager_id, h.path || ' > ' || e.first_name FROM employees e JOIN hierarchy h ON e.manager_id = h.employee_id ) SELECT * FROM hierarchy;
P128 — Customers With Orders in Three Consecutive Months · Expert
Find customers who placed at least one order in three consecutive calendar months.
SQL
WITH months AS ( SELECT DISTINCT customer_id, DATE_TRUNC('month', order_date) AS month FROM orders WHERE status <> 'Cancelled' ), x AS ( SELECT customer_id, month, LAG(month, 1) OVER ( PARTITION BY customer_id ORDER BY month ) AS prev1, LAG(month, 2) OVER ( PARTITION BY customer_id ORDER BY month ) AS prev2 FROM months ) SELECT DISTINCT customer_id FROM x WHERE month = prev1 + INTERVAL '1 month' AND prev1 = prev2 + INTERVAL '1 month';
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 1 |
| 6 |
| 3 |
| 2 |
| 5 |
| 7 |
| 8 |
(7 rows total)
Explanation
This chains two LAG calls (LAG(month, 1) and LAG(month, 2)) to look back one and two months respectively within each customer's distinct list of active months, then checks that the current month, the month before it, and the month before that are all exactly one calendar month apart — i.e., a genuine unbroken 3-month streak, not just any three months scattered across the year. 7 customers achieve this in the dataset, several of them deliberately given a guaranteed 4-month consecutive run of orders when the data was generated.
💻 Code example
WITH months AS ( SELECT DISTINCT customer_id, DATE_TRUNC('month', order_date) AS month FROM orders WHERE status <> 'Cancelled' ), x AS ( SELECT customer_id, month, LAG(month, 1) OVER ( PARTITION BY customer_id ORDER BY month ) AS prev1, LAG(month, 2) OVER ( PARTITION BY customer_id ORDER BY month ) AS prev2 FROM months ) SELECT DISTINCT customer_id FROM x WHERE month = prev1 + INTERVAL '1 month' AND prev1 = prev2 + INTERVAL '1 month';
P129 — Customer Cohort by Signup Month · Expert
Group customers into signup-month cohorts and count them.
SQL
SELECT DATE_TRUNC('month', signup_date) AS cohort_month, COUNT(*) AS customer_count FROM customers GROUP BY DATE_TRUNC('month', signup_date) ORDER BY cohort_month;
Output (computed against this section's live 9-table dataset)
| cohort_month | customer_count |
|---|---|
| 2023-01-01 | 1 |
| 2023-02-01 | 3 |
| 2023-07-01 | 1 |
| 2023-10-01 | 1 |
| 2024-01-01 | 1 |
| 2024-02-01 | 5 |
| 2024-03-01 | 2 |
| 2024-04-01 | 2 |
| 2024-05-01 | 2 |
| 2024-06-01 | 1 |
(showing 10 of 19 rows)
Explanation
DATE_TRUNC('month', signup_date) groups every customer into the calendar month they signed up in — a 'cohort' in growth-analytics terms — and counting rows per group shows how signups were distributed over time. Signups trickle in from as early as January 2023 (this dataset's earliest customer) through the most recent months, with no single month dominating.
💻 Code example
SELECT DATE_TRUNC('month', signup_date) AS cohort_month, COUNT(*) AS customer_count FROM customers GROUP BY DATE_TRUNC('month', signup_date) ORDER BY cohort_month;
P130 — Cohort Revenue · Expert
Calculate total revenue generated by each customer signup cohort.
SQL
SELECT DATE_TRUNC('month', c.signup_date) AS cohort_month, SUM(o.total_amount) AS revenue FROM customers c JOIN orders o ON c.customer_id = o.customer_id WHERE o.status <> 'Cancelled' GROUP BY DATE_TRUNC('month', c.signup_date) ORDER BY cohort_month;
Output (computed against this section's live 9-table dataset)
| cohort_month | revenue |
|---|---|
| 2023-01-01 | 301,121 |
| 2023-02-01 | 355,386 |
| 2023-07-01 | 136,660 |
| 2023-10-01 | 272,008 |
| 2024-02-01 | 665,719 |
| 2024-03-01 | 120,877 |
| 2024-04-01 | 140,366 |
| 2024-05-01 | 48,261 |
| 2024-06-01 | 64,780 |
| 2024-07-01 | 59,792 |
(showing 10 of 18 rows)
Explanation
Joining each customer's signup cohort to their (non-cancelled) order history and summing revenue per cohort answers a slightly different question than raw signup counts: not 'how many people signed up in a given month', but 'how much lifetime revenue has that whole cohort generated since'. Early cohorts like January and February 2023 already show substantial revenue (₹301,121 and ₹355,386 respectively), since those customers have had the most time to place orders.
💻 Code example
SELECT DATE_TRUNC('month', c.signup_date) AS cohort_month, SUM(o.total_amount) AS revenue FROM customers c JOIN orders o ON c.customer_id = o.customer_id WHERE o.status <> 'Cancelled' GROUP BY DATE_TRUNC('month', c.signup_date) ORDER BY cohort_month;
P131 — Customers With No Orders in Their Signup Month · Expert
Find customers who did not place an order during the month they signed up.
SQL
SELECT c.customer_id, c.customer_name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND DATE_TRUNC('month', o.order_date) = DATE_TRUNC('month', c.signup_date) );
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name |
|---|---|
| 3 | Amit Bhatia |
| 4 | Pooja Sinha |
| 5 | Vikas Rana |
| 7 | Rajesh Pandey |
| 8 | Swati Yadav |
| 9 | Manoj Kulkarni |
| 10 | Kirti Shetty |
| 11 | Alok Bose |
| 13 | Chetan Chauhan |
| 14 | Divya Naidu |
(showing 10 of 25 rows)
Explanation
NOT EXISTS checks, for each customer, whether any row in orders matches both their customer_id and a DATE_TRUNC('month', ...) equal to their own signup month — if no such row exists, the customer is flagged. 25 of the 30 customers never ordered during their actual signup month (most likely ordering for the first time some weeks or months after registering instead), which is a very common real-world pattern: signing up and buying immediately is the exception, not the rule.
💻 Code example
SELECT c.customer_id, c.customer_name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND DATE_TRUNC('month', o.order_date) = DATE_TRUNC('month', c.signup_date) );
P132 — Customers With Repeat Purchase Within 30 Days · Expert
Find customers who placed another order within 30 days of a previous order.
SQL
WITH x AS ( SELECT customer_id, order_date, LEAD(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS next_order_date FROM orders ) SELECT DISTINCT customer_id FROM x WHERE next_order_date <= order_date + INTERVAL '30 days';
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 1 |
| 15 |
| 6 |
| 3 |
| 19 |
| 9 |
| 14 |
| 2 |
| 7 |
| 4 |
(showing 10 of 12 rows)
Explanation
LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) looks ahead to each customer's next order, and the filter checks whether that next order landed within 30 days. 12 customers show this kind of quick repeat-purchase behavior, including customer 9, deliberately given two orders 19 days apart when the data was generated specifically to exercise this check.
💻 Code example
WITH x AS ( SELECT customer_id, order_date, LEAD(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS next_order_date FROM orders ) SELECT DISTINCT customer_id FROM x WHERE next_order_date <= order_date + INTERVAL '30 days';
P133 — Customers With Increasing Order Frequency · Expert
Compare each customer's order count in the first and second half of 2025.
SQL
SELECT customer_id, SUM(CASE WHEN order_date >= '2025-01-01' AND order_date < '2025-07-01' THEN 1 ELSE 0 END) AS first_half, SUM(CASE WHEN order_date >= '2025-07-01' AND order_date < '2026-01-01' THEN 1 ELSE 0 END) AS second_half FROM orders GROUP BY customer_id HAVING SUM(CASE WHEN order_date >= '2025-07-01' AND order_date < '2026-01-01' THEN 1 ELSE 0 END) > SUM(CASE WHEN order_date >= '2025-01-01' AND order_date < '2025-07-01' THEN 1 ELSE 0 END);
Output (computed against this section's live 9-table dataset)
| customer_id | first_half | second_half |
|---|---|---|
| 15 | 1 | 2 |
| 8 | 2 | 5 |
| 5 | 3 | 5 |
| 25 | 0 | 1 |
| 17 | 0 | 1 |
| 12 | 0 | 1 |
| 27 | 0 | 1 |
| 7 | 1 | 2 |
| 9,999 | 0 | 1 |
| 19 | 0 | 2 |
(10 rows total)
Explanation
Two SUM(CASE WHEN ... THEN 1 ELSE 0 END) expressions in the same query count each customer's order volume in the first half of 2025 (Jan-Jun) and second half (Jul-Dec) side by side, and the HAVING clause keeps only customers whose second-half count exceeds their first-half count. 10 customers ordered more often in the back half of the year than the front half — customer 8 nearly tripled their pace, from 2 orders in H1 to 5 in H2.
💻 Code example
SELECT customer_id, SUM(CASE WHEN order_date >= '2025-01-01' AND order_date < '2025-07-01' THEN 1 ELSE 0 END) AS first_half, SUM(CASE WHEN order_date >= '2025-07-01' AND order_date < '2026-01-01' THEN 1 ELSE 0 END) AS second_half FROM orders GROUP BY customer_id HAVING SUM(CASE WHEN order_date >= '2025-07-01' AND order_date < '2026-01-01' THEN 1 ELSE 0 END) > SUM(CASE WHEN order_date >= '2025-01-01' AND order_date < '2025-07-01' THEN 1 ELSE 0 END);
P134 — Find the Median Employee Salary · Expert
Calculate the median salary of employees.
SQL
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary FROM employees;
Output (computed against this section's live 9-table dataset)
| median_salary |
|---|
| 85,078.57 |
(1 row total)
Explanation
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) computes the continuous median — for an even number of rows (40 employees here) it interpolates between the two middle values rather than picking one of them arbitrarily. At ₹85,078.57, the median sits well below the ₹102,073 average from Problem 22, confirming what that earlier problem already hinted at: the average is being pulled upward by a handful of very high leadership salaries, while the median reflects where the 'typical' employee actually sits.
💻 Code example
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary FROM employees;
P135 — Median Order Value by Country · Expert
Find the median order value for each customer country.
SQL
SELECT c.country, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY o.total_amount) AS median_order_value FROM customers c JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.country;
Output (computed against this section's live 9-table dataset)
| country | median_order_value |
|---|---|
| UK | 7,043.50 |
| India | 11,794 |
| UAE | 3,397 |
| USA | 61,391.50 |
| Canada | 5,794 |
| Australia | 3,897 |
| Singapore | 36,989 |
| Germany | 60,289 |
(8 rows total)
Explanation
The same PERCENTILE_CONT function as Problem 134, now computed once per country group via GROUP BY. Median order values vary meaningfully by market — the UAE sits at just ₹3,397 (a single customer with modest orders) while India's median of ₹11,794 reflects both a much larger and more varied set of orders.
💻 Code example
SELECT c.country, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY o.total_amount) AS median_order_value FROM customers c JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.country;
P136 — Pareto Revenue Customers · Expert
Find the smallest set of customers contributing to approximately the first 80% of total revenue.
SQL
WITH spending AS ( SELECT customer_id, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY customer_id ), ranked AS ( SELECT *, SUM(revenue) OVER ( ORDER BY revenue DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_revenue, SUM(revenue) OVER () AS total_revenue FROM spending ) SELECT * FROM ranked WHERE cumulative_revenue <= total_revenue * 0.80;
Output (computed against this section's live 9-table dataset)
| customer_id | revenue | cumulative_revenue | total_revenue |
|---|---|---|---|
| 5 | 316,810 | 316,810 | 2,705,025 |
| 7 | 301,121 | 617,931 | 2,705,025 |
| 3 | 272,008 | 889,939 | 2,705,025 |
| 2 | 257,915 | 1,147,854 | 2,705,025 |
| 4 | 213,137 | 1,360,991 | 2,705,025 |
| 1 | 194,986 | 1,555,977 | 2,705,025 |
| 6 | 168,306 | 1,724,283 | 2,705,025 |
| 8 | 146,433 | 1,870,716 | 2,705,025 |
| 19 | 136,660 | 2,007,376 | 2,705,025 |
| 21 | 122,783 | 2,130,159 | 2,705,025 |
(10 rows total)
Explanation
This builds a running cumulative-revenue total ordered from the highest customer down to the lowest (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), compares it against the grand total computed separately with an unpartitioned SUM(revenue) OVER (), and keeps every customer up to the point where the running total first reaches 80% of all revenue — a direct SQL implementation of the Pareto ('80/20') principle. It takes only 10 of this dataset's highest-spending customers to account for that first 80% of total revenue, a concentration that's common in real customer bases.
💻 Code example
WITH spending AS ( SELECT customer_id, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY customer_id ), ranked AS ( SELECT *, SUM(revenue) OVER ( ORDER BY revenue DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_revenue, SUM(revenue) OVER () AS total_revenue FROM spending ) SELECT * FROM ranked WHERE cumulative_revenue <= total_revenue * 0.80;
P137 — Product Market Share · Expert
Calculate each product's percentage share of total product revenue.
SQL
WITH product_sales AS ( 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 ) SELECT *, ROUND( 100.0 * revenue / SUM(revenue) OVER (), 2 ) AS market_share FROM product_sales ORDER BY market_share DESC;
Output (computed against this section's live 9-table dataset)
| product_id | product_name | revenue | market_share |
|---|---|---|---|
| 3 | 27-inch 4K Monitor | 849,966 | 31.47 |
| 17 | Air Fryer | 215,964 | 8 |
| 5 | Noise Cancelling Headphones | 206,977 | 7.66 |
| 7 | Portable SSD 1TB | 202,971 | 7.52 |
| 4 | USB-C Hub | 158,478 | 5.87 |
| 1 | Wireless Mouse | 131,835 | 4.88 |
| 2 | Mechanical Keyboard | 125,964 | 4.66 |
| 11 | Dumbbell Set 10kg | 96,163 | 3.56 |
| 19 | Mixer Grinder | 95,671 | 3.54 |
| 20 | Ceramic Dinner Set | 85,761 | 3.18 |
(showing 10 of 27 rows)
Explanation
Revenue per product is computed from order_items (excluding cancelled orders), and SUM(revenue) OVER () again supplies the shared grand-total denominator for a percentage-of-total 'market share' figure. The 27-inch 4K Monitor alone claims 31.47% of all product revenue in this dataset — a single high-price, high-volume item dominating the entire catalog's earnings.
💻 Code example
WITH product_sales AS ( 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 ) SELECT *, ROUND( 100.0 * revenue / SUM(revenue) OVER (), 2 ) AS market_share FROM product_sales ORDER BY market_share DESC;
P138 — Category Revenue Ranking · Expert
Rank product categories by revenue.
SQL
WITH category_sales AS ( SELECT p.category, 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.category ) SELECT *, DENSE_RANK() OVER (ORDER BY revenue DESC) AS category_rank FROM category_sales;
Output (computed against this section's live 9-table dataset)
| category | revenue | category_rank |
|---|---|---|
| Electronics | 1,758,160 | 1 |
| Home & Kitchen | 477,027 | 2 |
| Sports | 226,675 | 3 |
| Clothing | 154,804 | 4 |
| Books | 83,812 | 5 |
(5 rows total)
Explanation
This groups product-level revenue up one more level, into category, and uses DENSE_RANK() rather than RANK() so that (in principle) tied categories wouldn't produce a gap in the ranking. Electronics leads by a wide margin at ₹1,758,160 — more than triple the second-place category (Home & Kitchen at ₹477,027), driven largely by that same dominant 4K Monitor from Problem 137.
💻 Code example
WITH category_sales AS ( SELECT p.category, 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.category ) SELECT *, DENSE_RANK() OVER (ORDER BY revenue DESC) AS category_rank FROM category_sales;
P139 — Highest Revenue Product Per Category · Expert
Find the top revenue-generating product in every category.
SQL
WITH product_sales AS ( SELECT p.product_id, p.product_name, p.category, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN oi.quantity * oi.unit_price ELSE 0 END ), 0) AS revenue FROM products p LEFT JOIN order_items oi ON p.product_id = oi.product_id LEFT JOIN orders o ON oi.order_id = o.order_id GROUP BY p.product_id, p.product_name, p.category ), ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY revenue DESC ) AS rn FROM product_sales ) SELECT * FROM ranked WHERE rn = 1;
Output (computed against this section's live 9-table dataset)
| product_id | product_name | category | revenue | rn |
|---|---|---|---|---|
| 23 | Denim Jacket | Clothing | 82,467 | 1 |
| 27 | Designing Data-Intensive Applications | Books | 40,473 | 1 |
| 17 | Air Fryer | Home & Kitchen | 215,964 | 1 |
| 11 | Dumbbell Set 10kg | Sports | 96,163 | 1 |
| 3 | 27-inch 4K Monitor | Electronics | 849,966 | 1 |
(5 rows total)
Explanation
Filtering ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) = 1 after computing every product's revenue (with LEFT JOINs and COALESCE so even a product with zero sales still gets a 0 and stays in contention) picks the single best-selling product within each of the 5 categories. Interestingly, Clothing's top product is the Denim Jacket (₹82,467), not the cheaper, higher-volume items in that category — reflecting its higher unit price relative to how many units of it actually sold.
💻 Code example
WITH product_sales AS ( SELECT p.product_id, p.product_name, p.category, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN oi.quantity * oi.unit_price ELSE 0 END ), 0) AS revenue FROM products p LEFT JOIN order_items oi ON p.product_id = oi.product_id LEFT JOIN orders o ON oi.order_id = o.order_id GROUP BY p.product_id, p.product_name, p.category ), ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY revenue DESC ) AS rn FROM product_sales ) SELECT * FROM ranked WHERE rn = 1;
P140 — Products With Declining Monthly Sales · Expert
Find products whose sales decreased from one month to the next.
SQL
WITH monthly AS ( SELECT oi.product_id, DATE_TRUNC('month', o.order_date) AS month, SUM(oi.quantity) AS units FROM order_items oi JOIN orders o ON oi.order_id = o.order_id WHERE o.status <> 'Cancelled' GROUP BY oi.product_id, DATE_TRUNC('month', o.order_date) ), x AS ( SELECT *, LAG(units) OVER ( PARTITION BY product_id ORDER BY month ) AS previous_units FROM monthly ) SELECT * FROM x WHERE previous_units IS NOT NULL AND units < previous_units;
Output (computed against this section's live 9-table dataset)
| product_id | month | units | previous_units |
|---|---|---|---|
| 10 | 2024-08-01 | 2 | 5 |
| 10 | 2025-02-01 | 3 | 5 |
| 10 | 2025-12-01 | 3 | 8 |
| 18 | 2024-11-01 | 1 | 4 |
| 18 | 2025-06-01 | 4 | 5 |
| 18 | 2025-09-01 | 2 | 4 |
| 21 | 2024-06-01 | 2 | 4 |
| 21 | 2024-11-01 | 1 | 2 |
| 21 | 2025-12-01 | 3 | 4 |
| 25 | 2024-08-01 | 4 | 5 |
(showing 10 of 83 rows)
Explanation
The monthly CTE first collapses order_items into total units sold per product per month, and LAG(units) OVER (PARTITION BY product_id ORDER BY month) then compares each month against the one before it for that same product — the outer query keeps every month where sales dropped versus the prior month. This is a broad, expected-to-be-noisy signal by design (83 rows match here), since almost every product will have some month where sales dipped relative to the month before; it's a starting point for investigation, not by itself proof of a real declining trend (Problem 141 tackles that stricter, more meaningful version).
💻 Code example
WITH monthly AS ( SELECT oi.product_id, DATE_TRUNC('month', o.order_date) AS month, SUM(oi.quantity) AS units FROM order_items oi JOIN orders o ON oi.order_id = o.order_id WHERE o.status <> 'Cancelled' GROUP BY oi.product_id, DATE_TRUNC('month', o.order_date) ), x AS ( SELECT *, LAG(units) OVER ( PARTITION BY product_id ORDER BY month ) AS previous_units FROM monthly ) SELECT * FROM x WHERE previous_units IS NOT NULL AND units < previous_units;
P141 — Three Consecutive Months of Revenue Growth · Expert
Find categories whose revenue increased for three consecutive months.
SQL
WITH monthly AS ( SELECT p.category, DATE_TRUNC('month', o.order_date) AS month, 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.category, DATE_TRUNC('month', o.order_date) ), x AS ( SELECT *, LAG(revenue, 1) OVER ( PARTITION BY category ORDER BY month ) AS prev1, LAG(revenue, 2) OVER ( PARTITION BY category ORDER BY month ) AS prev2 FROM monthly ) SELECT DISTINCT category FROM x WHERE revenue > prev1 AND prev1 > prev2;
Output (computed against this section's live 9-table dataset)
| category |
|---|
| Books |
| Sports |
| Home & Kitchen |
| Electronics |
(4 rows total)
Explanation
This is Problem 128's '3 consecutive periods' pattern (two chained LAG calls) applied to category-level monthly revenue instead of customer order months — a category only qualifies if its revenue was strictly higher than the previous month, which was itself strictly higher than the month before that: a genuine 3-month growth streak, not just an isolated good month. 4 of the 5 categories achieve this at some point in the two-year history, showing that sustained growth streaks aren't rare in this dataset's overall upward order-volume trend.
💻 Code example
WITH monthly AS ( SELECT p.category, DATE_TRUNC('month', o.order_date) AS month, 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.category, DATE_TRUNC('month', o.order_date) ), x AS ( SELECT *, LAG(revenue, 1) OVER ( PARTITION BY category ORDER BY month ) AS prev1, LAG(revenue, 2) OVER ( PARTITION BY category ORDER BY month ) AS prev2 FROM monthly ) SELECT DISTINCT category FROM x WHERE revenue > prev1 AND prev1 > prev2;
P142 — Orders With Multiple Payments · Expert
Find orders that have more than one payment record.
SQL
SELECT order_id, COUNT(*) AS payment_count, SUM(amount) AS total_paid FROM payments GROUP BY order_id HAVING COUNT(*) > 1;
Output (computed against this section's live 9-table dataset)
| order_id | payment_count | total_paid |
|---|---|---|
| 28 | 2 | 128,178 |
| 63 | 2 | 7,791 |
| 74 | 2 | 1,499 |
| 76 | 2 | 21,982 |
| 88 | 2 | 11,588 |
| 89 | 2 | 79,782 |
| 95 | 2 | 35,578 |
| 98 | 2 | 7,891 |
| 100 | 2 | 24,590 |
| 104 | 2 | 4,796 |
(showing 10 of 16 rows)
Explanation
Grouping payments by order_id and filtering HAVING COUNT(*) > 1 finds orders with more than one payment row attached — in this dataset that covers both the deliberately-generated installment/two-payment orders and any order where an initial failed payment attempt was followed by a successful retry, both entirely plausible real-world reasons for multiple payment records against one order.
💻 Code example
SELECT order_id, COUNT(*) AS payment_count, SUM(amount) AS total_paid FROM payments GROUP BY order_id HAVING COUNT(*) > 1;
P143 — Underpaid Orders · Expert
Find orders where successful payments total less than the order amount.
SQL
SELECT o.order_id, o.total_amount, COALESCE(SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ), 0) AS paid_amount FROM orders o LEFT JOIN payments p ON o.order_id = p.order_id GROUP BY o.order_id, o.total_amount HAVING COALESCE(SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ), 0) < o.total_amount;
Output (computed against this section's live 9-table dataset)
| order_id | total_amount | paid_amount |
|---|---|---|
| 43 | 8,892 | 0 |
| 2 | 7,797 | 4,582.01 |
| 49 | 7,797 | 0 |
| 33 | 13,592 | 0 |
| 40 | 6,396 | 0 |
| 18 | 13,196 | 0 |
| 9 | 13,891 | 0 |
| 47 | 5,794 | 0 |
| 10 | 76,385 | 45,370.22 |
| 131 | 57,890 | 0 |
(showing 10 of 41 rows)
Explanation
This flags every order where successfully-collected payments (COALESCE(SUM(... WHERE payment_status = 'Success' ...), 0)) fall short of the order's total_amount — but it's worth reading the 41-row result carefully: 23 of those are Cancelled orders that were simply never expected to be paid in full, and only the remaining 18 (15 Delivered, 3 Pending) represent orders that actually should have been paid but weren't. A tighter, more production-ready version of this check would add WHERE o.status <> 'Cancelled' to avoid flagging cancellations as a payment problem.
💻 Code example
SELECT o.order_id, o.total_amount, COALESCE(SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ), 0) AS paid_amount FROM orders o LEFT JOIN payments p ON o.order_id = p.order_id GROUP BY o.order_id, o.total_amount HAVING COALESCE(SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ), 0) < o.total_amount;
P144 — Fully Paid Orders · Expert
Find orders where successful payments exactly equal the order total.
SQL
SELECT o.order_id, o.total_amount, SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ) AS paid_amount FROM orders o LEFT JOIN payments p ON o.order_id = p.order_id GROUP BY o.order_id, o.total_amount HAVING SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ) = o.total_amount;
Output (computed against this section's live 9-table dataset)
| order_id | total_amount | paid_amount |
|---|---|---|
| 7 | 14,494 | 14,494 |
| 23 | 38,588 | 38,588 |
| 79 | 155,589 | 155,589 |
| 97 | 6,598 | 6,598 |
| 117 | 2,098 | 2,098 |
| 120 | 32,384 | 32,384 |
| 132 | 17,489 | 17,489 |
| 6 | 115,286 | 115,286 |
| 122 | 3,995 | 3,995 |
| 129 | 45,590 | 45,590 |
(showing 10 of 107 rows)
Explanation
The mirror image of Problem 143: orders where successful payments add up to exactly the order total. 107 of this dataset's 149 orders are fully paid this way — the expected common case, since most orders that aren't cancelled, underpaid, or still pending do eventually get paid in full.
💻 Code example
SELECT o.order_id, o.total_amount, SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ) AS paid_amount FROM orders o LEFT JOIN payments p ON o.order_id = p.order_id GROUP BY o.order_id, o.total_amount HAVING SUM( CASE WHEN p.payment_status = 'Success' THEN p.amount ELSE 0 END ) = o.total_amount;
P145 — Refund Amount by Customer · Expert
Calculate the total refunded amount for each customer.
SQL
SELECT c.customer_id, c.customer_name, COALESCE(SUM( CASE WHEN p.payment_status = 'Refunded' THEN p.amount ELSE 0 END ), 0) AS refunded_amount FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id LEFT JOIN payments p ON o.order_id = p.order_id GROUP BY c.customer_id, c.customer_name ORDER BY refunded_amount DESC;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | refunded_amount |
|---|---|---|
| 5 | Vikas Rana | 31,584 |
| 2 | Sneha Nair | 22,782 |
| 4 | Pooja Sinha | 13,088 |
| 17 | Imran Khan | 8,093 |
| 8 | Swati Yadav | 6,294 |
| 20 | Lata Pillai | 2,397 |
| 14 | Divya Naidu | 1,598 |
| 26 | Fatima Ali | 0 |
| 25 | Wei Zhang | 0 |
| 6 | Neha Ghosh | 0 |
(showing 10 of 30 rows)
Explanation
Chaining customers → orders → payments and summing only rows where payment_status = 'Refunded' (via conditional aggregation, defaulting to 0 for customers with none) totals how much money has actually been returned to each customer. Vikas Rana — this dataset's single highest-spending customer overall — also has the largest refunded amount at ₹31,584, a reminder that a customer's highest lifetime value and their highest refund exposure can be the same person simply because they transact the most.
💻 Code example
SELECT c.customer_id, c.customer_name, COALESCE(SUM( CASE WHEN p.payment_status = 'Refunded' THEN p.amount ELSE 0 END ), 0) AS refunded_amount FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id LEFT JOIN payments p ON o.order_id = p.order_id GROUP BY c.customer_id, c.customer_name ORDER BY refunded_amount DESC;
P146 — Customer Churn Candidates · Expert
Assuming 2026-01-01 as the reference date, find customers whose latest order was more than 365 days ago.
SQL
SELECT c.customer_id, c.customer_name, MAX(o.order_date) AS last_order_date 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 '365 days';
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | last_order_date |
|---|---|---|
| 24 | Olivia Davis | 2024-07-16 |
| 11 | Alok Bose | 2024-04-19 |
| 26 | Fatima Ali | 2024-01-16 |
| 23 | Michael Johnson | 2024-07-11 |
(4 rows total)
Explanation
The same 'reference date minus an interval' pattern as Problem 52's 180-day inactivity check, extended to 365 days to define real customer churn. Only 4 customers in this dataset haven't ordered anything in over a year as of the 2026-01-01 reference date — a notably smaller list than Problem 52's 180-day version, since a full year of silence is a much higher bar to clear.
💻 Code example
SELECT c.customer_id, c.customer_name, MAX(o.order_date) AS last_order_date 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 '365 days';
P147 — Customer Reactivation · Expert
Find customers who returned after being inactive for at least 180 days.
SQL
WITH gaps AS ( SELECT customer_id, order_date, LAG(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS previous_order_date FROM orders ) SELECT DISTINCT customer_id FROM gaps WHERE order_date - previous_order_date >= 180;
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 12 |
| 17 |
| 16 |
| 15 |
| 10 |
| 18 |
| 19 |
| 20 |
| 14 |
| 22 |
(showing 10 of 12 rows)
Explanation
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) computes the gap before every order (same mechanism as Problem 115's longest-gap calculation), and this query flags every individual order that came after a 180+ day silence — meaning a customer can appear here more than once if they've 'reactivated' from a long gap on multiple separate occasions. 12 such reactivation events show up in this dataset.
💻 Code example
WITH gaps AS ( SELECT customer_id, order_date, LAG(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS previous_order_date FROM orders ) SELECT DISTINCT customer_id FROM gaps WHERE order_date - previous_order_date >= 180;
P148 — Employee Productivity Score · Expert
Create a simple productivity score using sales revenue and project hours: revenue divided by total project hours.
SQL
WITH sales AS ( SELECT employee_id, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY employee_id ), hours AS ( SELECT employee_id, SUM(hours_worked) AS total_hours FROM employee_projects GROUP BY employee_id ) SELECT e.employee_id, e.first_name, COALESCE(s.revenue, 0) AS revenue, COALESCE(h.total_hours, 0) AS total_hours, COALESCE( s.revenue / NULLIF(h.total_hours, 0), 0 ) AS productivity_score FROM employees e LEFT JOIN sales s ON e.employee_id = s.employee_id LEFT JOIN hours h ON e.employee_id = h.employee_id;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | revenue | total_hours | productivity_score |
|---|---|---|---|---|
| 2 | Meera | 0 | 150 | 0 |
| 4 | Priya | 0 | 130 | 0 |
| 5 | Suresh | 0 | 30 | 0 |
| 6 | Anil | 0 | 60 | 0 |
| 8 | Rohan | 0 | 500 | 0 |
| 9 | Ananya | 0 | 630 | 0 |
| 11 | Kavya | 0 | 200 | 0 |
| 12 | Deepak | 0 | 60 | 0 |
| 14 | Rohan | 0 | 400 | 0 |
| 17 | Rakesh | 0 | 150 | 0 |
(showing 10 of 40 rows)
Explanation
Two independent CTEs — one summing sales revenue per employee, one summing project hours per employee — are joined back onto the full employees table with LEFT JOINs so nobody is dropped, and NULLIF(h.total_hours, 0) prevents a division-by-zero for anyone with hours logged but, in principle, zero total (a defensive habit worth keeping even when it can't currently happen). Because only Sales-department employees ever have order-derived revenue in this schema, every non-Sales employee's productivity score correctly comes out to exactly 0, regardless of how many project hours they logged — a limitation of the metric itself, not a bug: 'revenue per hour' simply isn't a meaningful measure for people who were never credited with any sales.
💻 Code example
WITH sales AS ( SELECT employee_id, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY employee_id ), hours AS ( SELECT employee_id, SUM(hours_worked) AS total_hours FROM employee_projects GROUP BY employee_id ) SELECT e.employee_id, e.first_name, COALESCE(s.revenue, 0) AS revenue, COALESCE(h.total_hours, 0) AS total_hours, COALESCE( s.revenue / NULLIF(h.total_hours, 0), 0 ) AS productivity_score FROM employees e LEFT JOIN sales s ON e.employee_id = s.employee_id LEFT JOIN hours h ON e.employee_id = h.employee_id;
P149 — Identify Data Quality Issues in Orders · Expert
Find orders with any of these issues: negative amount, zero amount, invalid status, or missing customer.
SQL
SELECT o.* FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id WHERE o.total_amount <= 0 OR o.status NOT IN ( 'Pending', 'Shipped', 'Delivered', 'Cancelled' ) OR c.customer_id IS NULL;
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | employee_id | order_date | status | total_amount |
|---|---|---|---|---|---|
| 15 | 3 | 33 | 2024-11-25 | processing | 52,588 |
| 68 | 12 | 15 | 2025-08-03 | Cancelled | 0 |
| 75 | 1 | 10 | 2024-02-03 | Cancelled | -499 |
| 96 | 4 | 33 | 2024-02-20 | Returned | 14,392 |
| 149 | 9,999 | 3 | 2025-08-05 | Pending | 2,499 |
(5 rows total)
Explanation
Three independent conditions — total_amount <= 0, an invalid status value (checked with NOT IN against the four legitimate statuses), or a customer_id with no matching row in customers (via LEFT JOIN ... IS NULL) — are combined with OR, so an order needs to trip just one of them to be flagged as suspect. All 5 of this dataset's deliberately planted data-quality problems surface here in one query: two orders with an invalid free-text status ('processing', 'Returned'), one with a zero total, one with a negative total, and the orphan order referencing a customer_id (9999) that doesn't exist in customers at all — exactly the kind of validation sweep a real data-quality job would run on this table nightly.
💻 Code example
SELECT o.* FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id WHERE o.total_amount <= 0 OR o.status NOT IN ( 'Pending', 'Shipped', 'Delivered', 'Cancelled' ) OR c.customer_id IS NULL;
P150 — Comprehensive Customer Analytics · Expert
Build a customer-level report containing total orders, total spending, average order value, first order, latest order, and spending rank.
SQL
WITH customer_metrics AS ( SELECT c.customer_id, c.customer_name, COUNT(o.order_id) AS total_orders, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS total_spending, COALESCE(AVG( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount END ), 0) AS average_order_value, MIN(o.order_date) AS first_order_date, MAX(o.order_date) AS latest_order_date FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name ) SELECT *, RANK() OVER ( ORDER BY total_spending DESC ) AS spending_rank FROM customer_metrics ORDER BY spending_rank, customer_id;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | total_orders | total_spending | average_order_value | first_order_date | latest_order_date | spending_rank |
|---|---|---|---|---|---|---|---|
| 5 | Vikas Rana | 15 | 316,810 | 26,400.83 | 2024-02-10 | 2025-12-06 | 1 |
| 7 | Rajesh Pandey | 10 | 301,121 | 30,112.10 | 2024-01-11 | 2025-10-20 | 2 |
| 3 | Amit Bhatia | 11 | 272,008 | 27,200.80 | 2024-03-04 | 2025-11-03 | 3 |
| 2 | Sneha Nair | 17 | 257,915 | 19,839.62 | 2024-02-25 | 2025-11-18 | 4 |
| 4 | Pooja Sinha | 10 | 213,137 | 30,448.14 | 2024-02-20 | 2025-10-16 | 5 |
| 1 | Rahul Agarwal | 14 | 194,986 | 19,498.60 | 2024-02-03 | 2025-09-02 | 6 |
| 6 | Neha Ghosh | 12 | 168,306 | 14,025.50 | 2024-03-22 | 2025-06-09 | 7 |
| 8 | Swati Yadav | 10 | 146,433 | 16,270.33 | 2024-10-25 | 2025-12-15 | 8 |
| 19 | Kunal Malhotra | 4 | 136,660 | 34,165 | 2024-07-13 | 2025-09-26 | 9 |
| 21 | John Smith | 2 | 122,783 | 61,391.50 | 2024-09-09 | 2025-04-07 | 10 |
(showing 10 of 30 rows)
Explanation
This is the capstone problem of this practice set: one CTE builds a complete per-customer profile — order count, non-cancelled total spending, average non-cancelled order value, first and last order dates — using LEFT JOIN and COALESCE/conditional AVG so every customer appears even with zero orders, and the outer query then ranks everyone by total_spending with RANK(). The result is effectively Problems 36, 51, 69, 70, and 95 all fused into a single report: Vikas Rana again tops the list at ₹316,810 across 15 orders, exactly consistent with every other spending-based ranking computed earlier in this section.
💻 Code example
WITH customer_metrics AS ( SELECT c.customer_id, c.customer_name, COUNT(o.order_id) AS total_orders, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 0) AS total_spending, COALESCE(AVG( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount END ), 0) AS average_order_value, MIN(o.order_date) AS first_order_date, MAX(o.order_date) AS latest_order_date FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.customer_name ) SELECT *, RANK() OVER ( ORDER BY total_spending DESC ) AS spending_rank FROM customer_metrics ORDER BY spending_rank, customer_id;
Want a visual for this concept?
Generate a diagram tailored to “SQL Practice — Expert & Real-World (Problems 111-150)” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →