SQL Practice — Advanced (Problems 71-110)
40 problems on window functions — RANK, DENSE_RANK, ROW_NUMBER, NTILE, LAG, LEAD, running totals, and moving averages — every one solved, run against real data, and explained.
Learning objectives
- Pick the right ranking function (RANK vs DENSE_RANK vs ROW_NUMBER) based on how ties should behave.
- Use PARTITION BY to reset a window calculation per group, and know when to omit it for a grand total.
- Build running totals, period-over-period comparisons, and moving averages with window frames.
- Recognize when a window function inside a CTE is required because a WHERE can't filter it directly.
P71 — Rank Employees by Salary · Advanced
Rank all employees from highest to lowest salary.
SQL
SELECT employee_id, first_name, salary, RANK() OVER (ORDER BY salary DESC) AS salary_rank FROM employees;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | salary | salary_rank |
|---|---|---|---|
| 1 | Arvind | 285,000 | 1 |
| 2 | Meera | 215,000 | 2 |
| 6 | Anil | 205,000 | 3 |
| 3 | Karan | 198,000 | 4 |
| 4 | Priya | 175,000 | 5 |
| 7 | Sunita | 168,000 | 6 |
| 14 | Rohan | 167,000 | 7 |
| 5 | Suresh | 160,000 | 8 |
| 8 | Rohan | 152,000 | 9 |
| 9 | Ananya | 148,000 | 10 |
(showing 10 of 40 rows)
Explanation
RANK() OVER (ORDER BY salary DESC) assigns every employee a position in the salary order without collapsing rows the way GROUP BY would — every one of the 40 employees still appears, just now carrying a rank number. RANK() specifically leaves gaps after ties: if two employees tied for rank 3, the next distinct salary would jump straight to rank 5, not 4 (contrast this with DENSE_RANK in Problem 74, which doesn't leave gaps).
💻 Code example
SELECT employee_id, first_name, salary, RANK() OVER (ORDER BY salary DESC) AS salary_rank FROM employees;
P72 — Rank Employees Within Department · Advanced
Rank employees by salary within each department.
SQL
SELECT employee_id, first_name, department_id, salary, RANK() OVER ( PARTITION BY department_id ORDER BY salary DESC ) AS department_rank FROM employees;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | department_id | salary | department_rank |
|---|---|---|---|---|
| 5 | Suresh | 4 | 160,000 | 1 |
| 35 | Varun | 4 | 90,680.58 | 2 |
| 23 | Tanvi | 4 | 43,454.56 | 3 |
| 17 | Rakesh | 4 | 39,926.01 | 4 |
| 29 | Aditi | 4 | 38,194.72 | 5 |
| 1 | Arvind | 1 | 285,000 | 1 |
| 2 | Meera | 1 | 215,000 | 2 |
| 14 | Rohan | 1 | 167,000 | 3 |
| 8 | Rohan | 1 | 152,000 | 4 |
| 9 | Ananya | 1 | 148,000 | 5 |
(showing 10 of 40 rows)
Explanation
Adding PARTITION BY department_id restarts the ranking from 1 at the start of every department, instead of ranking across the whole company — so each department gets its own #1 earner. Interestingly, HR's top earner (Suresh Iyer, ₹160,000) doesn't come close to Engineering's top earner (Arvind, ₹285,000), which is exactly why 'rank #1 in your department' and 'rank #1 company-wide' (Problem 71) can point to very different people.
💻 Code example
SELECT employee_id, first_name, department_id, salary, RANK() OVER ( PARTITION BY department_id ORDER BY salary DESC ) AS department_rank FROM employees;
P73 — Top 3 Employees Per Department · Advanced
Return the top three salary earners from every department.
SQL
WITH ranked AS ( SELECT employee_id, first_name, department_id, salary, ROW_NUMBER() OVER ( PARTITION BY department_id ORDER BY salary DESC ) AS rn FROM employees ) SELECT * FROM ranked WHERE rn <= 3;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | department_id | salary | rn |
|---|---|---|---|---|
| 1 | Arvind | 1 | 285,000 | 1 |
| 2 | Meera | 1 | 215,000 | 2 |
| 3 | Karan | 2 | 198,000 | 1 |
| 4 | Priya | 3 | 175,000 | 1 |
| 5 | Suresh | 4 | 160,000 | 1 |
| 6 | Anil | 5 | 205,000 | 1 |
| 7 | Sunita | 6 | 168,000 | 1 |
| 10 | Vikram | 2 | 131,000 | 2 |
| 11 | Kavya | 3 | 118,000 | 2 |
| 12 | Deepak | 5 | 142,000 | 2 |
(showing 10 of 18 rows)
Explanation
Window functions like RANK() can't be filtered directly in the same SELECT they're computed in (a WHERE rn <= 3 right after the window function is a syntax error), so the ranking has to be computed inside a CTE first and then filtered in an outer query. ROW_NUMBER() is used here instead of RANK() specifically because the goal is 'give me exactly 3 rows per department' — RANK() could return more than 3 rows for a department with a 3-way tie at the top, since tied rows share a rank number.
💻 Code example
WITH ranked AS ( SELECT employee_id, first_name, department_id, salary, ROW_NUMBER() OVER ( PARTITION BY department_id ORDER BY salary DESC ) AS rn FROM employees ) SELECT * FROM ranked WHERE rn <= 3;
P74 — Dense Salary Ranking · Advanced
Assign dense salary ranks across all employees.
SQL
SELECT employee_id, first_name, salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank FROM employees;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | salary | dense_rank |
|---|---|---|---|
| 1 | Arvind | 285,000 | 1 |
| 2 | Meera | 215,000 | 2 |
| 6 | Anil | 205,000 | 3 |
| 3 | Karan | 198,000 | 4 |
| 4 | Priya | 175,000 | 5 |
| 7 | Sunita | 168,000 | 6 |
| 14 | Rohan | 167,000 | 7 |
| 5 | Suresh | 160,000 | 8 |
| 8 | Rohan | 152,000 | 9 |
| 9 | Ananya | 148,000 | 10 |
(showing 10 of 40 rows)
Explanation
DENSE_RANK() behaves like RANK() but never skips a number after a tie — if two people tied for rank 1, the very next distinct salary gets rank 2, not 3. In this dataset, since all 40 salaries happen to be distinct, DENSE_RANK and RANK (Problem 71) produce identical results here; the difference only becomes visible once real ties exist, as it does within some departments in Problem 72.
💻 Code example
SELECT employee_id, first_name, salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank FROM employees;
P75 — Running Revenue Total · Advanced
Calculate cumulative order revenue over time.
SQL
SELECT order_date, order_id, total_amount, SUM(total_amount) OVER ( ORDER BY order_date, order_id ) AS running_revenue FROM orders WHERE status <> 'Cancelled';
Output (computed against this section's live 9-table dataset)
| order_date | order_id | total_amount | running_revenue |
|---|---|---|---|
| 2024-01-05 | 126 | 6,596 | 6,596 |
| 2024-01-11 | 127 | 11,189 | 17,785 |
| 2024-01-16 | 99 | 36,989 | 54,774 |
| 2024-01-21 | 35 | 14,791 | 69,565 |
| 2024-02-10 | 144 | 9,095 | 78,660 |
| 2024-02-20 | 96 | 14,392 | 93,052 |
| 2024-02-25 | 111 | 2,398 | 95,450 |
| 2024-03-03 | 115 | 4,495 | 99,945 |
| 2024-03-04 | 20 | 5,294 | 105,239 |
| 2024-03-04 | 146 | 17,789 | 123,028 |
(showing 10 of 124 rows)
Explanation
SUM(total_amount) OVER (ORDER BY order_date, order_id) (with no PARTITION BY) computes a cumulative total across all non-cancelled orders in chronological order — each row's running_revenue is the sum of every order up to and including that one. The order_id tiebreaker in the ORDER BY matters because several orders can share the same order_date; without a secondary sort key, ties would have an undefined order and the running total could differ between runs.
💻 Code example
SELECT order_date, order_id, total_amount, SUM(total_amount) OVER ( ORDER BY order_date, order_id ) AS running_revenue FROM orders WHERE status <> 'Cancelled';
P76 — Running Revenue Per Customer · Advanced
Calculate each customer's cumulative spending over their order history.
SQL
SELECT customer_id, order_id, order_date, total_amount, SUM(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS running_customer_spend FROM orders WHERE status <> 'Cancelled';
Output (computed against this section's live 9-table dataset)
| customer_id | order_id | order_date | total_amount | running_customer_spend |
|---|---|---|---|---|
| 3 | 146 | 2024-03-04 | 17,789 | 17,789 |
| 3 | 23 | 2024-05-06 | 38,588 | 56,377 |
| 3 | 132 | 2024-06-10 | 17,489 | 73,866 |
| 3 | 95 | 2024-07-24 | 17,789 | 91,655 |
| 3 | 41 | 2024-08-11 | 23,996 | 115,651 |
| 3 | 55 | 2024-10-15 | 29,787 | 145,438 |
| 3 | 15 | 2024-11-25 | 52,588 | 198,026 |
| 3 | 128 | 2025-04-17 | 60,191 | 258,217 |
| 3 | 130 | 2025-05-20 | 5,994 | 264,211 |
| 3 | 49 | 2025-07-14 | 7,797 | 272,008 |
(showing 10 of 124 rows)
Explanation
Adding PARTITION BY customer_id to Problem 75's pattern resets the running total to zero at the start of every customer's order history, so each customer accumulates their own spending independently instead of sharing one company-wide total. Customer 3's first order (₹17,789) establishes their running total, and each subsequent order adds on top of it — this is the building block behind lifetime-value and lifetime-order-count tracking.
💻 Code example
SELECT customer_id, order_id, order_date, total_amount, SUM(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS running_customer_spend FROM orders WHERE status <> 'Cancelled';
P77 — Previous Order Amount · Advanced
Show each customer's previous order amount.
SQL
SELECT customer_id, order_id, order_date, total_amount, LAG(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS previous_order_amount FROM orders;
Output (computed against this section's live 9-table dataset)
| customer_id | order_id | order_date | total_amount | previous_order_amount |
|---|---|---|---|---|
| 3 | 146 | 2024-03-04 | 17,789 | NULL |
| 3 | 23 | 2024-05-06 | 38,588 | 17,789 |
| 3 | 132 | 2024-06-10 | 17,489 | 38,588 |
| 3 | 95 | 2024-07-24 | 17,789 | 17,489 |
| 3 | 41 | 2024-08-11 | 23,996 | 17,789 |
| 3 | 55 | 2024-10-15 | 29,787 | 23,996 |
| 3 | 15 | 2024-11-25 | 52,588 | 29,787 |
| 3 | 128 | 2025-04-17 | 60,191 | 52,588 |
| 3 | 130 | 2025-05-20 | 5,994 | 60,191 |
| 3 | 49 | 2025-07-14 | 7,797 | 5,994 |
(showing 10 of 149 rows)
Explanation
LAG(total_amount) OVER (PARTITION BY customer_id ORDER BY order_date, order_id) looks one row back within each customer's own chronologically ordered orders — the very first order for any customer has nothing before it, so LAG correctly returns NULL there (visible for customer 3's first order), and every subsequent order shows the amount that came immediately before it.
💻 Code example
SELECT customer_id, order_id, order_date, total_amount, LAG(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS previous_order_amount FROM orders;
P78 — Next Order Date · Advanced
Show the next order date for every customer order.
SQL
SELECT customer_id, order_id, order_date, LEAD(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS next_order_date FROM orders;
Output (computed against this section's live 9-table dataset)
| customer_id | order_id | order_date | next_order_date |
|---|---|---|---|
| 4 | 96 | 2024-02-20 | 2024-03-11 |
| 4 | 34 | 2024-03-11 | 2024-06-11 |
| 4 | 133 | 2024-06-11 | 2024-07-19 |
| 4 | 121 | 2024-07-19 | 2024-08-19 |
| 4 | 142 | 2024-08-19 | 2024-09-23 |
| 4 | 16 | 2024-09-23 | 2025-02-27 |
| 4 | 46 | 2025-02-27 | 2025-04-12 |
| 4 | 53 | 2025-04-12 | 2025-06-26 |
| 4 | 59 | 2025-06-26 | 2025-10-16 |
| 4 | 43 | 2025-10-16 | NULL |
(showing 10 of 149 rows)
Explanation
LEAD is the mirror image of LAG: it looks one row forward instead of back. For customer 4's order on 2024-02-20, LEAD(order_date) returns 2024-03-11 — the date of their very next order — which is exactly the kind of 'time until next purchase' signal that feeds into repeat-purchase and churn analysis later on (P132, P146).
💻 Code example
SELECT customer_id, order_id, order_date, LEAD(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS next_order_date FROM orders;
P79 — Difference From Previous Order · Advanced
Calculate the difference between the current order amount and the previous order amount for each customer.
SQL
SELECT customer_id, order_id, total_amount, total_amount - LAG(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS amount_difference FROM orders;
Output (computed against this section's live 9-table dataset)
| customer_id | order_id | total_amount | amount_difference |
|---|---|---|---|
| 3 | 146 | 17,789 | NULL |
| 3 | 23 | 38,588 | 20,799 |
| 3 | 132 | 17,489 | -21,099 |
| 3 | 95 | 17,789 | 300 |
| 3 | 41 | 23,996 | 6,207 |
| 3 | 55 | 29,787 | 5,791 |
| 3 | 15 | 52,588 | 22,801 |
| 3 | 128 | 60,191 | 7,603 |
| 3 | 130 | 5,994 | -54,197 |
| 3 | 49 | 7,797 | 1,803 |
(showing 10 of 149 rows)
Explanation
Subtracting LAG(total_amount) from the current row's total_amount turns Problem 77's 'previous amount' into a period-over-period change — a positive amount_difference means the customer spent more than last time, negative means less. Just like LAG itself, the very first order for each customer has no previous value to subtract, so its difference is NULL rather than some fallback like 0 — a NULL here genuinely means 'not applicable', not 'zero change'.
💻 Code example
SELECT customer_id, order_id, total_amount, total_amount - LAG(total_amount) OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS amount_difference FROM orders;
P80 — Department Salary Percentage · Advanced
Find what percentage of total department salary each employee earns.
SQL
SELECT employee_id, first_name, department_id, salary, ROUND( 100.0 * salary / SUM(salary) OVER (PARTITION BY department_id), 2 ) AS salary_percentage FROM employees;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | department_id | salary | salary_percentage |
|---|---|---|---|---|
| 1 | Arvind | 1 | 285,000 | 22.85 |
| 2 | Meera | 1 | 215,000 | 17.24 |
| 3 | Karan | 2 | 198,000 | 30.51 |
| 4 | Priya | 3 | 175,000 | 25.22 |
| 5 | Suresh | 4 | 160,000 | 42.98 |
| 6 | Anil | 5 | 205,000 | 35.31 |
| 7 | Sunita | 6 | 168,000 | 31.11 |
| 8 | Rohan | 1 | 152,000 | 12.19 |
| 9 | Ananya | 1 | 148,000 | 11.87 |
| 10 | Vikram | 2 | 131,000 | 20.19 |
(showing 10 of 40 rows)
Explanation
SUM(salary) OVER (PARTITION BY department_id) computes each department's total payroll without collapsing the individual employee rows — unlike a GROUP BY, every employee still appears individually, just now also carrying their department's total as a repeated value on every row in that partition. Dividing each employee's own salary by that shared total (and multiplying by 100) shows how much of the department's payroll budget one person represents: Karan Malhotra alone accounts for 30.51% of Sales' total payroll, reflecting how few employees are in that department relative to how senior he is.
💻 Code example
SELECT employee_id, first_name, department_id, salary, ROUND( 100.0 * salary / SUM(salary) OVER (PARTITION BY department_id), 2 ) AS salary_percentage FROM employees;
P81 — Highest Paid Employee Per Department · Advanced
Return the highest-paid employee from each department.
SQL
WITH ranked AS ( SELECT e.*, ROW_NUMBER() OVER ( PARTITION BY department_id ORDER BY salary DESC, employee_id ) AS rn FROM employees e ) SELECT * FROM ranked WHERE rn = 1;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | last_name | department_id | manager_id | salary | hire_date | city | rn | |
|---|---|---|---|---|---|---|---|---|---|
| 5 | Suresh | Iyer | suresh.iyer@company.com | 4 | 1 | 160,000 | 2019-11-05 | Delhi | 1 |
| 1 | Arvind | Krishnan | arvind.krishnan@company.com | 1 | NULL | 285,000 | 2019-01-10 | Bengaluru | 1 |
| 4 | Priya | Chopra | priya.chopra@company.com | 3 | 1 | 175,000 | 2020-02-20 | Mumbai | 1 |
| 7 | Sunita | Rao | sunita.rao@company.com | 6 | 1 | 168,000 | 2020-03-10 | Hyderabad | 1 |
| 3 | Karan | Malhotra | karan.malhotra@company.com | 2 | 1 | 198,000 | 2019-08-01 | Mumbai | 1 |
| 6 | Anil | Bhatt | anil.bhatt@company.com | 5 | 1 | 205,000 | 2020-01-15 | Pune | 1 |
(6 rows total)
Explanation
This is Problem 73's pattern applied without a category-size cap — ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC, employee_id) gives exactly one 'rank 1' row per department, and filtering WHERE rn = 1 returns exactly 6 rows, one highest-paid employee per department. The employee_id tiebreaker after salary DESC guarantees a single deterministic winner even if two people in the same department were ever tied on salary.
💻 Code example
WITH ranked AS ( SELECT e.*, ROW_NUMBER() OVER ( PARTITION BY department_id ORDER BY salary DESC, employee_id ) AS rn FROM employees e ) SELECT * FROM ranked WHERE rn = 1;
P82 — Second Highest Salary Per Department · Advanced
Find the second highest distinct salary in each department.
SQL
WITH ranked AS ( SELECT department_id, salary, DENSE_RANK() OVER ( PARTITION BY department_id ORDER BY salary DESC ) AS rnk FROM employees ) SELECT department_id, salary FROM ranked WHERE rnk = 2;
Output (computed against this section's live 9-table dataset)
| department_id | salary |
|---|---|
| 1 | 215,000 |
| 5 | 142,000 |
| 3 | 118,000 |
| 6 | 109,000 |
| 4 | 90,680.58 |
| 2 | 131,000 |
(6 rows total)
Explanation
Using DENSE_RANK instead of ROW_NUMBER here matters: if two people in the same department were tied for the highest salary, ROW_NUMBER would arbitrarily assign one of them rank 2 (making them the 'second highest' even though they're actually tied for first), whereas DENSE_RANK correctly treats the tie as both being rank 1 and moves the true second-highest distinct salary to rank 2. All 6 departments have a well-defined second-highest salary in this dataset.
💻 Code example
WITH ranked AS ( SELECT department_id, salary, DENSE_RANK() OVER ( PARTITION BY department_id ORDER BY salary DESC ) AS rnk FROM employees ) SELECT department_id, salary FROM ranked WHERE rnk = 2;
P83 — Employees Above Department Average · Advanced
Use a window function to find employees earning above their department average.
SQL
SELECT * FROM ( SELECT employee_id, first_name, department_id, salary, AVG(salary) OVER ( PARTITION BY department_id ) AS department_avg FROM employees ) x WHERE salary > department_avg;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | department_id | salary | department_avg |
|---|---|---|---|---|
| 1 | Arvind | 1 | 285,000 | 138,588.51 |
| 2 | Meera | 1 | 215,000 | 138,588.51 |
| 3 | Karan | 2 | 198,000 | 92,711.24 |
| 4 | Priya | 3 | 175,000 | 99,118.03 |
| 5 | Suresh | 4 | 160,000 | 74,451.17 |
| 6 | Anil | 5 | 205,000 | 96,749.44 |
| 7 | Sunita | 6 | 168,000 | 90,014.29 |
| 8 | Rohan | 1 | 152,000 | 138,588.51 |
| 9 | Ananya | 1 | 148,000 | 138,588.51 |
| 10 | Vikram | 2 | 131,000 | 92,711.24 |
(showing 10 of 15 rows)
Explanation
This uses a window function (AVG(salary) OVER (PARTITION BY department_id)) inside a subquery so the department average can be attached to every employee row, then filters in an outer query on salary > department_avg — functionally the same result as the correlated subquery in Problem 42, but computed differently: the window function calculates every department's average once and broadcasts it to all rows in that partition, rather than re-running a subquery once per employee. 15 employees clear their department's average, matching Problem 42 exactly.
💻 Code example
SELECT * FROM ( SELECT employee_id, first_name, department_id, salary, AVG(salary) OVER ( PARTITION BY department_id ) AS department_avg FROM employees ) x WHERE salary > department_avg;
P84 — Customer Order Number · Advanced
Assign a sequential order number to every customer's orders.
SQL
SELECT customer_id, order_id, order_date, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS customer_order_number FROM orders;
Output (computed against this section's live 9-table dataset)
| customer_id | order_id | order_date | customer_order_number |
|---|---|---|---|
| 3 | 146 | 2024-03-04 | 1 |
| 3 | 23 | 2024-05-06 | 2 |
| 3 | 132 | 2024-06-10 | 3 |
| 3 | 95 | 2024-07-24 | 4 |
| 3 | 41 | 2024-08-11 | 5 |
| 3 | 55 | 2024-10-15 | 6 |
| 3 | 15 | 2024-11-25 | 7 |
| 3 | 128 | 2025-04-17 | 8 |
| 3 | 130 | 2025-05-20 | 9 |
| 3 | 49 | 2025-07-14 | 10 |
(showing 10 of 149 rows)
Explanation
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date, order_id) numbers each customer's own orders 1, 2, 3... in chronological order — customer 3's earliest order (2024-03-04) is their order #1, the next their #2, and so on. This numbering is exactly what Problem 85 and Problem 86 filter on to isolate a single order (first or latest) per customer.
💻 Code example
SELECT customer_id, order_id, order_date, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS customer_order_number FROM orders;
P85 — First Order for Each Customer · Advanced
Return the complete row for each customer's first order.
SQL
WITH ranked AS ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS rn FROM orders o ) SELECT * FROM ranked WHERE rn = 1;
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | employee_id | order_date | status | total_amount | rn |
|---|---|---|---|---|---|---|
| 61 | 12 | 33 | 2024-12-01 | Shipped | 24,388 | 1 |
| 30 | 20 | 15 | 2024-07-12 | Cancelled | 24,688 | 1 |
| 126 | 11 | 33 | 2024-01-05 | Delivered | 6,596 | 1 |
| 35 | 16 | 15 | 2024-01-21 | Delivered | 14,791 | 1 |
| 99 | 26 | 3 | 2024-01-16 | Shipped | 36,989 | 1 |
| 144 | 5 | 10 | 2024-02-10 | Delivered | 9,095 | 1 |
| 40 | 9 | 15 | 2025-04-13 | Cancelled | 6,396 | 1 |
| 85 | 14 | 33 | 2024-04-05 | Delivered | 19,595 | 1 |
| 48 | 17 | 27 | 2024-09-12 | Cancelled | 8,093 | 1 |
| 92 | 15 | 27 | 2024-09-17 | Pending | 6,598 | 1 |
(showing 10 of 28 rows)
Explanation
Filtering Problem 84's numbering down to rn = 1 returns the complete row (every column, via o.*) for each customer's very first order — not just the date, but the full order record including status and amount. 28 rows come back, one per customer who has placed at least one order.
💻 Code example
WITH ranked AS ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS rn FROM orders o ) SELECT * FROM ranked WHERE rn = 1;
P86 — Latest Order for Each Customer · Advanced
Return the complete latest order row for each customer.
SQL
WITH ranked AS ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC ) AS rn FROM orders o ) SELECT * FROM ranked WHERE rn = 1;
Output (computed against this section's live 9-table dataset)
| order_id | customer_id | employee_id | order_date | status | total_amount | rn |
|---|---|---|---|---|---|---|
| 9 | 2 | 39 | 2025-11-18 | Cancelled | 13,891 | 1 |
| 149 | 9,999 | 3 | 2025-08-05 | Pending | 2,499 | 1 |
| 134 | 3 | 21 | 2025-11-03 | Cancelled | 12,495 | 1 |
| 60 | 6 | 15 | 2025-06-09 | Shipped | 11,290 | 1 |
| 51 | 11 | 33 | 2024-04-19 | Delivered | 8,892 | 1 |
| 71 | 16 | 15 | 2025-11-17 | Delivered | 11,794 | 1 |
| 99 | 26 | 3 | 2024-01-16 | Shipped | 36,989 | 1 |
| 62 | 1 | 21 | 2025-09-02 | Delivered | 17,591 | 1 |
| 129 | 15 | 27 | 2025-12-06 | Delivered | 45,590 | 1 |
| 90 | 22 | 27 | 2025-12-04 | Pending | 9,691 | 1 |
(showing 10 of 28 rows)
Explanation
Reversing the ORDER BY to order_date DESC, order_id DESC before numbering flips which order gets rn = 1 — now it's each customer's most recent order instead of their earliest. Interestingly, this also picks up the orphan order (customer_id = 9999, which doesn't match any real row in customers) as its own 'customer's' latest order — a reminder that this query only ever looks at orders in isolation, so a customer_id that doesn't actually exist in the customers table can still show up here.
💻 Code example
WITH ranked AS ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC ) AS rn FROM orders o ) SELECT * FROM ranked WHERE rn = 1;
P87 — Top 10 Percent Employees · Advanced
Find employees belonging to the top 10% by salary.
SQL
WITH ranked AS ( SELECT e.*, NTILE(10) OVER (ORDER BY salary DESC) AS salary_bucket FROM employees e ) SELECT * FROM ranked WHERE salary_bucket = 1;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | last_name | department_id | manager_id | salary | hire_date | city | salary_bucket | |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Arvind | Krishnan | arvind.krishnan@company.com | 1 | NULL | 285,000 | 2019-01-10 | Bengaluru | 1 |
| 2 | Meera | Nair | meera.nair@company.com | 1 | 1 | 215,000 | 2019-06-15 | Bengaluru | 1 |
| 6 | Anil | Bhatt | anil.bhatt@company.com | 5 | 1 | 205,000 | 2020-01-15 | Pune | 1 |
| 3 | Karan | Malhotra | karan.malhotra@company.com | 2 | 1 | 198,000 | 2019-08-01 | Mumbai | 1 |
(4 rows total)
Explanation
NTILE(10) splits the entire ordered result into 10 roughly-equal-sized buckets, numbered 1 (highest salaries, since the ordering is DESC) through 10 (lowest) — with 40 employees split 10 ways, each bucket holds exactly 4. Filtering WHERE salary_bucket = 1 returns the top decile: the 4 highest-paid people in the company, led by Arvind, Meera, and Anil Bhatt.
💻 Code example
WITH ranked AS ( SELECT e.*, NTILE(10) OVER (ORDER BY salary DESC) AS salary_bucket FROM employees e ) SELECT * FROM ranked WHERE salary_bucket = 1;
P88 — Quartile of Product Prices · Advanced
Divide products into four price groups.
SQL
SELECT product_id, product_name, price, NTILE(4) OVER (ORDER BY price) AS price_quartile FROM products;
Output (computed against this section's live 9-table dataset)
| product_id | product_name | price | price_quartile |
|---|---|---|---|
| 26 | Atomic Habits | 499 | 1 |
| 28 | Sapiens | 599 | 1 |
| 15 | Non-stick Frying Pan | 699 | 1 |
| 1 | Wireless Mouse | 799 | 1 |
| 10 | Yoga Mat | 899 | 1 |
| 25 | The Pragmatic Programmer | 899 | 1 |
| 24 | Running Track Pants | 999 | 1 |
| 21 | Men's Casual Shirt | 1,099 | 2 |
| 16 | Electric Kettle | 1,199 | 2 |
| 4 | USB-C Hub | 1,299 | 2 |
(showing 10 of 28 rows)
Explanation
NTILE(4) divides the 28 products into 4 roughly-equal price quartiles (7 products each) after sorting by price ascending — quartile 1 holds the cheapest products (starting with Atomic Habits at ₹499), while quartile 4 would hold the most expensive. This is a common way to bucket a continuous value like price into a small number of discrete tiers for reporting or filtering.
💻 Code example
SELECT product_id, product_name, price, NTILE(4) OVER (ORDER BY price) AS price_quartile FROM products;
P89 — Monthly Revenue With Previous Month · Advanced
Show monthly revenue and the previous month's revenue.
SQL
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ) SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS previous_month_revenue FROM monthly ORDER BY month;
Output (computed against this section's live 9-table dataset)
| month | revenue | previous_month_revenue |
|---|---|---|
| 2024-01-01 | 69,565 | NULL |
| 2024-02-01 | 25,885 | 69,565 |
| 2024-03-01 | 73,656 | 25,885 |
| 2024-04-01 | 34,483 | 73,656 |
| 2024-05-01 | 69,063 | 34,483 |
| 2024-06-01 | 45,770 | 69,063 |
| 2024-07-01 | 166,142 | 45,770 |
| 2024-08-01 | 141,448 | 166,142 |
| 2024-09-01 | 94,961 | 141,448 |
| 2024-10-01 | 247,511 | 94,961 |
(showing 10 of 24 rows)
Explanation
The CTE first collapses orders into one revenue figure per month, then LAG(revenue) OVER (ORDER BY month) — with no PARTITION BY, since there's only one revenue series here — looks back to the immediately preceding month's total. January 2024 (this dataset's first month) correctly has no previous_month_revenue at all (NULL), since there's no month before it in the data; February's previous_month_revenue is populated with January's ₹69,565.
💻 Code example
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ) SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS previous_month_revenue FROM monthly ORDER BY month;
P90 — Month-over-Month Growth · Advanced
Calculate monthly revenue growth percentage.
SQL
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ), comparison AS ( SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS previous_revenue FROM monthly ) SELECT month, revenue, ROUND( 100.0 * (revenue - previous_revenue) / NULLIF(previous_revenue, 0), 2 ) AS growth_percentage FROM comparison ORDER BY month;
Output (computed against this section's live 9-table dataset)
| month | revenue | growth_percentage |
|---|---|---|
| 2024-01-01 | 69,565 | NULL |
| 2024-02-01 | 25,885 | -62.79 |
| 2024-03-01 | 73,656 | 184.55 |
| 2024-04-01 | 34,483 | -53.18 |
| 2024-05-01 | 69,063 | 100.28 |
| 2024-06-01 | 45,770 | -33.73 |
| 2024-07-01 | 166,142 | 262.99 |
| 2024-08-01 | 141,448 | -14.86 |
| 2024-09-01 | 94,961 | -32.87 |
| 2024-10-01 | 247,511 | 160.64 |
(showing 10 of 24 rows)
Explanation
Building on Problem 89's LAG, this computes (revenue - previous_revenue) / previous_revenue * 100 to express the change as a percentage rather than a raw difference — NULLIF(previous_revenue, 0) guards against a divide-by-zero error in case any month had exactly ₹0 in prior revenue (none did here, but it's cheap insurance). Revenue actually fell 62.79% from January to February 2024 in this dataset before rebounding sharply (+184.55%) in March — real month-to-month swings like this are exactly why growth percentages, not raw revenue, are often what gets tracked on a dashboard.
💻 Code example
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ), comparison AS ( SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS previous_revenue FROM monthly ) SELECT month, revenue, ROUND( 100.0 * (revenue - previous_revenue) / NULLIF(previous_revenue, 0), 2 ) AS growth_percentage FROM comparison ORDER BY month;
P91 — Three-Month Moving Average · Advanced
Calculate a three-month moving average of revenue.
SQL
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ) SELECT month, revenue, AVG(revenue) OVER ( ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_average FROM monthly;
Output (computed against this section's live 9-table dataset)
| month | revenue | moving_average |
|---|---|---|
| 2024-01-01 | 69,565 | 69,565 |
| 2024-02-01 | 25,885 | 47,725 |
| 2024-03-01 | 73,656 | 56,368.67 |
| 2024-04-01 | 34,483 | 44,674.67 |
| 2024-05-01 | 69,063 | 59,067.33 |
| 2024-06-01 | 45,770 | 49,772 |
| 2024-07-01 | 166,142 | 93,658.33 |
| 2024-08-01 | 141,448 | 117,786.67 |
| 2024-09-01 | 94,961 | 134,183.67 |
| 2024-10-01 | 247,511 | 161,306.67 |
(showing 10 of 24 rows)
Explanation
AVG(revenue) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) computes a genuine 3-month rolling window: for any given month, it averages that month's revenue together with the two months immediately before it. The very first month (January 2024) only has itself to average (no prior months exist yet), so its moving average equals its own revenue exactly; only from the third month onward does the window contain a full three data points, which smooths out month-to-month noise like the sharp February dip seen in Problem 90.
💻 Code example
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY DATE_TRUNC('month', order_date) ) SELECT month, revenue, AVG(revenue) OVER ( ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_average FROM monthly;
P92 — Customers With Consecutive Orders · Advanced
Find customers who placed orders on consecutive calendar days.
SQL
WITH x AS ( SELECT customer_id, order_date, LAG(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS previous_date FROM orders ) SELECT DISTINCT customer_id FROM x WHERE order_date = previous_date + INTERVAL '1 day';
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 1 |
(1 row total)
Explanation
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) finds each customer's immediately preceding order date, and the outer query checks whether the current order landed exactly one calendar day later. Only customer 1 qualifies in this dataset — they placed one order on 2025-03-14 and another the very next day, 2025-03-15, a pattern deliberately built into the data to demonstrate this exact technique.
💻 Code example
WITH x AS ( SELECT customer_id, order_date, LAG(order_date) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS previous_date FROM orders ) SELECT DISTINCT customer_id FROM x WHERE order_date = previous_date + INTERVAL '1 day';
P93 — Duplicate Orders by Customer and Date · Advanced
Find customers who placed more than one order on the same date.
SQL
SELECT customer_id, order_date, COUNT(*) AS order_count FROM orders GROUP BY customer_id, order_date HAVING COUNT(*) > 1;
Output (computed against this section's live 9-table dataset)
| customer_id | order_date | order_count |
|---|---|---|
| 2 | 2025-06-10 | 2 |
(1 row total)
Explanation
Unlike Problem 92 (which looks for orders exactly one day apart), this groups by both customer_id and the exact same order_date to catch same-day duplicates — a signal that could indicate a genuine repeat purchase, a data-entry duplicate, or a retried checkout. Customer 2 placed two separate orders on 2025-06-10, deliberately included in this dataset to exercise exactly this check.
💻 Code example
SELECT customer_id, order_date, COUNT(*) AS order_count FROM orders GROUP BY customer_id, order_date HAVING COUNT(*) > 1;
P94 — Products With Revenue Above Category Average · Advanced
Find products whose revenue is above the average product revenue in their category.
SQL
WITH product_revenue 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 ) SELECT * FROM ( SELECT pr.*, AVG(revenue) OVER (PARTITION BY category) AS category_avg FROM product_revenue pr ) x WHERE revenue > category_avg;
Output (computed against this section's live 9-table dataset)
| product_id | product_name | category | revenue | category_avg |
|---|---|---|---|---|
| 3 | 27-inch 4K Monitor | Electronics | 849,966 | 219,770 |
| 25 | The Pragmatic Programmer | Books | 25,172 | 20,953 |
| 27 | Designing Data-Intensive Applications | Books | 40,473 | 20,953 |
| 20 | Ceramic Dinner Set | Home & Kitchen | 85,761 | 79,504.50 |
| 19 | Mixer Grinder | Home & Kitchen | 95,671 | 79,504.50 |
| 17 | Air Fryer | Home & Kitchen | 215,964 | 79,504.50 |
| 23 | Denim Jacket | Clothing | 82,467 | 38,701 |
| 11 | Dumbbell Set 10kg | Sports | 96,163 | 37,779.17 |
(8 rows total)
Explanation
Computing per-product revenue with conditional aggregation first (so cancelled orders never contribute), the outer window function then attaches each category's average product revenue to every row in that category via AVG(revenue) OVER (PARTITION BY category), and the final WHERE keeps only products beating their own category's average. The 27-inch 4K Monitor dominates Electronics so heavily (₹849,966 vs. a category average of ₹219,770) that it single-handedly makes the category average hard for any other Electronics product to clear.
💻 Code example
WITH product_revenue 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 ) SELECT * FROM ( SELECT pr.*, AVG(revenue) OVER (PARTITION BY category) AS category_avg FROM product_revenue pr ) x WHERE revenue > category_avg;
P95 — Customer Spending Rank · Advanced
Rank customers by total spending.
SQL
WITH spending AS ( SELECT c.customer_id, c.customer_name, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 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 ) SELECT *, RANK() OVER (ORDER BY total_spent DESC) AS spending_rank FROM spending;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | total_spent | spending_rank |
|---|---|---|---|
| 5 | Vikas Rana | 316,810 | 1 |
| 7 | Rajesh Pandey | 301,121 | 2 |
| 3 | Amit Bhatia | 272,008 | 3 |
| 2 | Sneha Nair | 257,915 | 4 |
| 4 | Pooja Sinha | 213,137 | 5 |
| 1 | Rahul Agarwal | 194,986 | 6 |
| 6 | Neha Ghosh | 168,306 | 7 |
| 8 | Swati Yadav | 146,433 | 8 |
| 19 | Kunal Malhotra | 136,660 | 9 |
| 21 | John Smith | 122,783 | 10 |
(showing 10 of 30 rows)
Explanation
This wraps Problem 70's lifetime-spending calculation in a RANK() OVER (ORDER BY total_spent DESC) — since total_spent is computed first inside the spending CTE, the ranking operates on the already-aggregated per-customer totals rather than raw order rows. Vikas Rana ranks #1 at ₹316,810, matching the top of Problem 70's list exactly, since both queries compute lifetime value the same way.
💻 Code example
WITH spending AS ( SELECT c.customer_id, c.customer_name, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 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 ) SELECT *, RANK() OVER (ORDER BY total_spent DESC) AS spending_rank FROM spending;
P96 — Top 3 Customers Per Country · Advanced
Find the three highest-spending customers in each country.
SQL
WITH spending AS ( SELECT c.customer_id, c.customer_name, c.country, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 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, c.country ), ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY country ORDER BY total_spent DESC ) AS rn FROM spending ) SELECT * FROM ranked WHERE rn <= 3;
Output (computed against this section's live 9-table dataset)
| customer_id | customer_name | country | total_spent | rn |
|---|---|---|---|---|
| 23 | Michael Johnson | Canada | 5,794 | 1 |
| 30 | Grace Clark | Canada | 0 | 2 |
| 21 | John Smith | USA | 122,783 | 1 |
| 28 | Sofia Rossi | USA | 0 | 2 |
| 26 | Fatima Ali | Singapore | 36,989 | 1 |
| 22 | Emma Brown | UK | 120,877 | 1 |
| 29 | David Miller | UK | 0 | 2 |
| 5 | Vikas Rana | India | 316,810 | 1 |
| 7 | Rajesh Pandey | India | 301,121 | 2 |
| 3 | Amit Bhatia | India | 272,008 | 3 |
(showing 10 of 13 rows)
Explanation
Layering PARTITION BY country onto the ranking from Problem 95 restarts the rank at 1 within every country, and filtering rn <= 3 keeps each country's top 3 spenders — small countries with only 1-2 customers (like Canada or the UK) simply return all of their customers, correctly ranked, since there aren't three to compete for the podium. Notably, some customers with ₹0 in lifetime spending still appear here (rank 2 in Canada, rank 2 in the UK) — they have zero spend but are still their country's second-highest, which is a fair answer, just a low bar.
💻 Code example
WITH spending AS ( SELECT c.customer_id, c.customer_name, c.country, COALESCE(SUM( CASE WHEN o.status <> 'Cancelled' THEN o.total_amount ELSE 0 END ), 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, c.country ), ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY country ORDER BY total_spent DESC ) AS rn FROM spending ) SELECT * FROM ranked WHERE rn <= 3;
P97 — Employee Project Hours · Advanced
Calculate total project hours for each employee.
SQL
SELECT e.employee_id, e.first_name, COALESCE(SUM(ep.hours_worked), 0) AS total_hours FROM employees e LEFT JOIN employee_projects ep ON e.employee_id = ep.employee_id GROUP BY e.employee_id, e.first_name;
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | total_hours |
|---|---|---|
| 14 | Rohan | 400 |
| 38 | Riya | 390 |
| 22 | Arjun | 0 |
| 6 | Anil | 60 |
| 1 | Arvind | 0 |
| 19 | Vikram | 0 |
| 30 | Manish | 55 |
| 13 | Riya | 0 |
| 35 | Varun | 0 |
| 4 | Priya | 130 |
(showing 10 of 40 rows)
Explanation
LEFT JOIN plus COALESCE(SUM(...), 0) — the same pattern from Problem 36 — ensures every employee appears with a real number, including the many who were never assigned to any project at all (they show 0.0 total hours rather than being silently dropped). Rohan (employee 14) leads with 400 hours logged across his project assignments.
💻 Code example
SELECT e.employee_id, e.first_name, COALESCE(SUM(ep.hours_worked), 0) AS total_hours FROM employees e LEFT JOIN employee_projects ep ON e.employee_id = ep.employee_id GROUP BY e.employee_id, e.first_name;
P98 — Most Active Project · Advanced
Find the project with the highest total employee hours.
SQL
SELECT p.project_id, p.project_name, SUM(ep.hours_worked) AS total_hours FROM projects p JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name ORDER BY total_hours DESC LIMIT 1;
Output (computed against this section's live 9-table dataset)
| project_id | project_name | total_hours |
|---|---|---|
| 2 | Data Warehouse Migration | 1,190 |
(1 row total)
Explanation
Grouping employee_projects by project_id (via a join to projects for the name) and summing hours_worked, then sorting descending and keeping just the top row, finds the single project that consumed the most person-hours. The Data Warehouse Migration project leads by a wide margin at 1,190 total hours — unsurprising given it also carries the dataset's largest team.
💻 Code example
SELECT p.project_id, p.project_name, SUM(ep.hours_worked) AS total_hours FROM projects p JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name ORDER BY total_hours DESC LIMIT 1;
P99 — Employees Working on Multiple Projects · Advanced
Find employees assigned to at least two projects.
SQL
SELECT employee_id, COUNT(DISTINCT project_id) AS project_count FROM employee_projects GROUP BY employee_id HAVING COUNT(DISTINCT project_id) >= 2;
Output (computed against this section's live 9-table dataset)
| employee_id | project_count |
|---|---|
| 32 | 2 |
| 6 | 2 |
| 2 | 2 |
| 38 | 2 |
| 14 | 2 |
| 9 | 2 |
| 8 | 2 |
| 4 | 2 |
(8 rows total)
Explanation
COUNT(DISTINCT project_id) per employee (note: DISTINCT matters in principle, though employee_projects has no duplicate employee/project pairs here) filtered by HAVING >= 2 finds employees stretched across multiple projects at once. 8 employees qualify, each juggling exactly two project assignments in this dataset — a realistic cross-section of a company's most in-demand engineers and managers.
💻 Code example
SELECT employee_id, COUNT(DISTINCT project_id) AS project_count FROM employee_projects GROUP BY employee_id HAVING COUNT(DISTINCT project_id) >= 2;
P100 — Projects With No Employees · Advanced
Find projects that have no employee assignments.
SQL
SELECT p.project_id, p.project_name FROM projects p LEFT JOIN employee_projects ep ON p.project_id = ep.project_id WHERE ep.employee_id IS NULL;
Output (computed against this section's live 9-table dataset)
| project_id | project_name |
|---|---|
| 8 | Legacy System Decommission |
(1 row total)
Explanation
The same LEFT JOIN ... WHERE ... IS NULL 'find unmatched rows' pattern used for products (P35) and customers (P33), now applied to projects against employee_projects. Exactly one project — Legacy System Decommission — has no employee assignments at all, deliberately left that way in this dataset as a completed, already-wound-down initiative.
💻 Code example
SELECT p.project_id, p.project_name FROM projects p LEFT JOIN employee_projects ep ON p.project_id = ep.project_id WHERE ep.employee_id IS NULL;
P101 — Employees Who Worked on the Largest Project · Advanced
Find employees assigned to the project with the highest budget.
SQL
SELECT DISTINCT e.employee_id, e.first_name, p.project_name, p.budget FROM employees e JOIN employee_projects ep ON e.employee_id = ep.employee_id JOIN projects p ON ep.project_id = p.project_id WHERE p.budget = ( SELECT MAX(budget) FROM projects );
Output (computed against this section's live 9-table dataset)
| employee_id | first_name | project_name | budget |
|---|---|---|---|
| 9 | Ananya | Fraud Detection Engine | 3,200,000 |
| 38 | Riya | Fraud Detection Engine | 3,200,000 |
| 6 | Anil | Fraud Detection Engine | 3,200,000 |
| 32 | Saanvi | Fraud Detection Engine | 3,200,000 |
(4 rows total)
Explanation
The subquery SELECT MAX(budget) FROM projects finds the single largest budget (₹3,200,000, belonging to the Fraud Detection Engine), and the outer query then finds every employee who worked on that specific project — SELECT DISTINCT matters because an employee could in principle have multiple employee_projects rows for the same project, which would otherwise duplicate them in the output. 4 employees worked on it, including manager Ananya Singh.
💻 Code example
SELECT DISTINCT e.employee_id, e.first_name, p.project_name, p.budget FROM employees e JOIN employee_projects ep ON e.employee_id = ep.employee_id JOIN projects p ON ep.project_id = p.project_id WHERE p.budget = ( SELECT MAX(budget) FROM projects );
P102 — Employee Count Per Project · Advanced
Show every project and the number of assigned employees.
SQL
SELECT p.project_id, p.project_name, COUNT(DISTINCT ep.employee_id) AS employee_count FROM projects p LEFT JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name;
Output (computed against this section's live 9-table dataset)
| project_id | project_name | employee_count |
|---|---|---|
| 5 | Internal HR Portal | 3 |
| 1 | Customer Portal Revamp | 4 |
| 2 | Data Warehouse Migration | 5 |
| 3 | Mobile App Launch | 4 |
| 6 | Fraud Detection Engine | 4 |
| 8 | Legacy System Decommission | 0 |
| 4 | Marketing Automation | 3 |
| 7 | Vendor Payment Automation | 2 |
(8 rows total)
Explanation
LEFT JOIN plus COUNT(DISTINCT ep.employee_id) ensures every project appears in the result, even Legacy System Decommission (Problem 100's zero-employee project) — for that row, the COUNT correctly returns 0 rather than the row disappearing entirely, because COUNT (unlike SUM) counts non-null values, and COUNT(*) would incorrectly count 1 due to the single unmatched NULL row the LEFT JOIN produces.
💻 Code example
SELECT p.project_id, p.project_name, COUNT(DISTINCT ep.employee_id) AS employee_count FROM projects p LEFT JOIN employee_projects ep ON p.project_id = ep.project_id GROUP BY p.project_id, p.project_name;
P103 — Department With Highest Average Salary · Advanced
Find the department with the highest average employee salary.
SQL
SELECT department_id, AVG(salary) AS average_salary FROM employees GROUP BY department_id ORDER BY average_salary DESC LIMIT 1;
Output (computed against this section's live 9-table dataset)
| department_id | average_salary |
|---|---|
| 1 | 138,588.51 |
(1 row total)
Explanation
This reuses Problem 26's per-department average salary calculation, sorts it descending, and keeps only the top row — Engineering has both the highest average salary (~₹138,589) and, as seen in Problem 67, the widest range, since it houses both the CEO and several of the newest, lowest-paid hires.
💻 Code example
SELECT department_id, AVG(salary) AS average_salary FROM employees GROUP BY department_id ORDER BY average_salary DESC LIMIT 1;
P104 — Customer's First and Last Order · Advanced
Show first order date, last order date, and order count per customer.
SQL
SELECT customer_id, MIN(order_date) AS first_order, MAX(order_date) AS last_order, COUNT(*) AS order_count FROM orders GROUP BY customer_id;
Output (computed against this section's live 9-table dataset)
| customer_id | first_order | last_order | order_count |
|---|---|---|---|
| 1 | 2024-02-03 | 2025-09-02 | 14 |
| 17 | 2024-09-12 | 2025-11-17 | 2 |
| 12 | 2024-12-01 | 2025-08-03 | 2 |
| 27 | 2025-09-12 | 2025-09-12 | 1 |
| 16 | 2024-01-21 | 2025-11-17 | 4 |
| 23 | 2024-07-11 | 2024-07-11 | 1 |
| 6 | 2024-03-22 | 2025-06-09 | 12 |
| 10 | 2024-03-06 | 2025-03-11 | 3 |
| 15 | 2024-09-17 | 2025-12-06 | 4 |
| 18 | 2024-08-25 | 2025-06-17 | 3 |
(showing 10 of 28 rows)
Explanation
Three aggregates computed together — MIN, MAX, and COUNT — summarize each customer's entire order history in one row: when they first ordered, when they most recently ordered, and how many orders they've placed in between. Customer 1 stands out with 14 orders spanning from February 2024 to September 2025, one of this dataset's most active buyers.
💻 Code example
SELECT customer_id, MIN(order_date) AS first_order, MAX(order_date) AS last_order, COUNT(*) AS order_count FROM orders GROUP BY customer_id;
P105 — Customer Retention After First Order · Advanced
Find customers who placed another order after their first order.
SQL
WITH order_history AS ( SELECT customer_id, order_date, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS rn FROM orders ) SELECT customer_id FROM order_history GROUP BY customer_id HAVING COUNT(*) > 1;
Output (computed against this section's live 9-table dataset)
| customer_id |
|---|
| 17 |
| 1 |
| 12 |
| 8 |
| 3 |
| 2 |
| 15 |
| 6 |
| 10 |
| 18 |
(showing 10 of 22 rows)
Explanation
The CTE numbers each customer's orders chronologically with ROW_NUMBER() (identical setup to Problem 84), and the outer query groups by customer and keeps anyone with more than one row — meaning they placed at least a second order after their first. 22 of the 28 customers with any order history qualify as 'retained' by this simple definition.
💻 Code example
WITH order_history AS ( SELECT customer_id, order_date, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date, order_id ) AS rn FROM orders ) SELECT customer_id FROM order_history GROUP BY customer_id HAVING COUNT(*) > 1;
P106 — Cancellation Rate · Advanced
Calculate the percentage of orders that were cancelled.
SQL
SELECT ROUND( 100.0 * SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) / COUNT(*), 2 ) AS cancellation_rate FROM orders;
Output (computed against this section's live 9-table dataset)
| cancellation_rate |
|---|
| 16.78 |
(1 row total)
Explanation
The same conditional-aggregation pattern as Problem 57's payment success rate, applied to order status instead of payment status: SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) / COUNT(*). 16.78% of all 149 orders in this dataset were cancelled — close to, but not exactly, the ~20% weighting used when the data was generated, since randomness doesn't hit its target exactly over a finite sample.
💻 Code example
SELECT ROUND( 100.0 * SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) / COUNT(*), 2 ) AS cancellation_rate FROM orders;
P107 — Revenue by Customer Country · Advanced
Calculate total non-cancelled revenue for each customer country.
SQL
SELECT c.country, 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 c.country ORDER BY revenue DESC;
Output (computed against this section's live 9-table dataset)
| country | revenue |
|---|---|
| India | 2,348,500 |
| USA | 122,783 |
| UK | 120,877 |
| Germany | 60,289 |
| Singapore | 36,989 |
| Canada | 5,794 |
| Australia | 3,897 |
| UAE | 3,397 |
(8 rows total)
Explanation
Joining customers to orders and excluding cancelled orders before grouping by country shows where real revenue actually comes from geographically. India dominates at ₹2,348,500 — completely unsurprising given 20 of the 30 customers are based there — with the USA (₹122,783) a distant second among the international markets.
💻 Code example
SELECT c.country, 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 c.country ORDER BY revenue DESC;
P108 — Revenue Contribution Percentage · Advanced
Find each customer's percentage contribution to total revenue.
SQL
WITH spending AS ( SELECT customer_id, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY customer_id ) SELECT customer_id, revenue, ROUND( 100.0 * revenue / SUM(revenue) OVER (), 2 ) AS revenue_percentage FROM spending;
Output (computed against this section's live 9-table dataset)
| customer_id | revenue | revenue_percentage |
|---|---|---|
| 1 | 194,986 | 7.21 |
| 17 | 10,388 | 0.38 |
| 12 | 24,388 | 0.90 |
| 27 | 3,397 | 0.13 |
| 16 | 32,379 | 1.20 |
| 23 | 5,794 | 0.21 |
| 6 | 168,306 | 6.22 |
| 10 | 17,583 | 0.65 |
| 15 | 64,780 | 2.39 |
| 18 | 32,584 | 1.20 |
(showing 10 of 28 rows)
Explanation
SUM(revenue) OVER () with empty parentheses — no PARTITION BY, no ORDER BY — computes one grand total across the entire result set and repeats that same value on every row, which is exactly what's needed as the denominator for a percentage-of-total calculation. Customer 1 alone contributes 7.21% of all revenue in this dataset, the single largest share of any individual customer.
💻 Code example
WITH spending AS ( SELECT customer_id, SUM(total_amount) AS revenue FROM orders WHERE status <> 'Cancelled' GROUP BY customer_id ) SELECT customer_id, revenue, ROUND( 100.0 * revenue / SUM(revenue) OVER (), 2 ) AS revenue_percentage FROM spending;
P109 — Products With More Than 100 Units Sold · Advanced
Find products whose total quantity sold exceeds 100 units.
SQL
SELECT product_id, SUM(quantity) AS units_sold FROM order_items GROUP BY product_id HAVING SUM(quantity) > 100;
Output (computed against this section's live 9-table dataset)
| product_id | units_sold |
|---|---|
| 1 | 207 |
| 4 | 145 |
(2 rows total)
Explanation
Summing quantity from order_items grouped by product_id and filtering HAVING SUM(quantity) > 100 finds genuinely high-volume movers — only 2 products clear that bar: the Wireless Mouse (207 units) and the USB-C Hub (145 units), both of which this dataset deliberately biases toward being purchased together far more often than other products (see Problem 113).
💻 Code example
SELECT product_id, SUM(quantity) AS units_sold FROM order_items GROUP BY product_id HAVING SUM(quantity) > 100;
P110 — Order Value Reconciliation · Advanced
Find orders where the stored total amount differs from the calculated item total.
SQL
SELECT o.order_id, o.total_amount AS stored_total, SUM(oi.quantity * oi.unit_price) AS calculated_total FROM orders o JOIN order_items oi ON o.order_id = oi.order_id GROUP BY o.order_id, o.total_amount HAVING o.total_amount <> SUM(oi.quantity * oi.unit_price);
Output (computed against this section's live 9-table dataset)
| order_id | stored_total | calculated_total |
|---|---|---|
| 76 | 10,991 | 10,491 |
| 58 | 3,147 | 3,397 |
| 148 | 62,891 | 61,892 |
| 75 | -499 | 14,190 |
| 69 | 10,296 | 9,996 |
| 115 | 4,495 | 4,995 |
| 36 | 87,790 | 86,791 |
| 68 | 0 | 13,693 |
(8 rows total)
Explanation
Comparing the orders.total_amount stored on the parent row against a freshly recalculated SUM(quantity * unit_price) from order_items catches exactly the kind of silent data drift that happens in real systems when a price changes after an order was placed, or when a total gets updated in one place but not another. 8 orders show a mismatch in this dataset — 6 of them were deliberately corrupted by a small random amount for this exercise, and 2 more are the same orders Problem 149 flags for having a zero or negative total_amount, since those, too, obviously disagree with their real item total.
💻 Code example
SELECT o.order_id, o.total_amount AS stored_total, SUM(oi.quantity * oi.unit_price) AS calculated_total FROM orders o JOIN order_items oi ON o.order_id = oi.order_id GROUP BY o.order_id, o.total_amount HAVING o.total_amount <> SUM(oi.quantity * oi.unit_price);
Want a visual for this concept?
Generate a diagram tailored to “SQL Practice — Advanced (Problems 71-110)” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →