SQL Practice Questions: 25 Real Business Queries & Answers
Practice 25 real-world SQL queries with solutions, schema diagrams, and expected outputs. Master joins, window functions, and aggregations for interviews.
Landing a data analyst, analytics engineer, or business intelligence role requires more than reciting syntax definitions. Hiring managers evaluate your ability to translate ambiguous business requirements into resilient, optimized, production-grade queries.
In day-to-day analytics, you rarely run isolated queries on a single, cleanly formatted table. You navigate normalized schemas, handle messy customer contact records, diagnose join fan-out double-counting, and write modular analytical pipelines using Common Table Expressions (CTEs) and window functions.
This guide provides 25 real-world SQL practice questions organized across three progressive tiers:
- Beginner SQL Practice Questions: Foundational filtering, text pattern matching, multi-column sorting, and aggregated groupings with
HAVING. - Intermediate SQL Queries: Multi-table relational joins (
INNER,LEFT,SELF), correlated subqueries,CASE WHENconditional aggregations, and missing-data sanitization. - Advanced Analytical Queries: Analytical window functions (
ROW_NUMBER,DENSE_RANK,LAG,LEAD,NTILE), recursive hierarchical CTEs, moving window frames, and the classic Gaps and Islands problem.
Every practice query includes a realistic Business Problem, production-ready PostgreSQL Solution, detailed Logic Breakdown, and Expected Output Table.
You can run these queries directly in our interactive sandbox at Topfolio SQL Basics Course. Every course and exercise on Topfolio is 100% free to learn, with an optional ₹99 verified certificate to showcase your validated query expertise on LinkedIn.
Relational Schema Architecture
All 25 practice questions query the unified retail and human resources relational schema below. Take a moment to understand table primary keys (PK), foreign key relationships (FK), and cardinality before writing queries.
+----------------------------------------------------------------------------------------------------+
| ENTERPRISE RELATIONAL SCHEMA |
+----------------------------------------------------------------------------------------------------+
[ departments ] [ employees ]
+-------------------------+ +-------------------------+
| department_id (PK) |<----------------| department_id (FK) |
| department_name VARCHAR | | employee_id (PK) |
| budget NUMERIC | | first_name VARCHAR |
+-------------------------+ | last_name VARCHAR |
| salary NUMERIC |
| hire_date DATE |
| manager_id (FK)---------+--+ (Self-Referential)
+-------------------------+ |
^ |
+---------------+
[ customers ] [ orders ] [ order_items ] [ products ]
+--------------------+ +--------------------+ +---------------------+ +---------------------+
| customer_id (PK) |<----| customer_id (FK) |<----| order_id (FK) | | product_id (PK) |
| first_name VARCHAR | | order_id (PK) | | order_item_id (PK) |----->| product_id (FK) |
| last_name VARCHAR | | order_date DATE | | quantity INT | | product_name VARCHAR|
| email VARCHAR | | status VARCHAR | | unit_price NUMERIC | | category VARCHAR |
| city VARCHAR | | total_amount NUM | +---------------------+ | price NUMERIC |
| state CHAR(2) | +--------------------+ | stock_quantity INT |
| signup_date DATE | +---------------------+
+--------------------+
Schema Relationship Rules:
- One-to-Many (
departmentstoemployees): A department employs zero or more employees; each employee belongs to exactly one department. - Self-Join (
employeestoemployees): Every employee reports to a manager (manager_id), except the executive leader whosemanager_idisNULL. - One-to-Many (
customerstoorders): A customer can place zero or multiple orders over time. - Many-to-Many (
orderstoproductsviaorder_items): An order contains multiple line items; each line item references a distinct product.
SQL Query Complexity Matrix
The matrix below maps the query tiers, core PostgreSQL clauses, analytical applications, and frequent interview failure modes covered in this guide.
| Feature / Criteria |
|---|
Part 1: Beginner SQL Practice Questions
Beginner SQL practice tests your mastery of single-table data filtering, logical operator precedence, string searching, and grouped aggregations.
Question 1: Active High-Value Customer Filtering
Business Problem: The marketing team is launching a VIP promotional campaign in key geographic regions. Retrieve the first name, last name, email, and signup date of all customers who registered on or after January 1, 2024, reside in California (CA) or Texas (TX), ordered by signup date from newest to oldest.
-- Solution: Filtering by date and state membership
SELECT
first_name,
last_name,
email,
state,
signup_date
FROM customers
WHERE signup_date >= '2024-01-01'
AND state IN ('CA', 'TX')
ORDER BY signup_date DESC;Query Logic: The WHERE clause combines a temporal comparison with the IN operator for set membership. The ORDER BY signup_date DESC sorts records chronologically backwards so the latest signups appear first.
Expected Output:
| first_name | last_name | state | signup_date | |
|---|---|---|---|---|
| Sophia | Patel | sophia.p@example.com | CA | 2024-08-14 |
| Marcus | Vance | marcus.v@example.com | TX | 2024-06-02 |
| Elena | Rodriguez | elena.r@example.com | CA | 2024-03-22 |
| Jordan | Miller | j.miller@example.com | TX | 2024-01-15 |
Question 2: Product Search by Pattern and Price Boundaries
Business Problem: The product merchandising team needs a list of all items in inventory whose names contain the strings "Pro" or "Air" (case-insensitive) and whose unit price falls strictly between $50 and $500.
-- Solution: Pattern matching with ILIKE and BETWEEN
SELECT
product_id,
product_name,
category,
price
FROM products
WHERE (product_name ILIKE '%Pro%' OR product_name ILIKE '%Air%')
AND price BETWEEN 50.00 AND 500.00
ORDER BY price ASC;Query Logic: In PostgreSQL, ILIKE performs case-insensitive regex pattern matching. Note the explicit parentheses grouping the OR conditions. Without parentheses, AND evaluates before OR, which would corrupt the price boundary filter.
Expected Output:
| product_id | product_name | category | price |
|---|---|---|---|
| 104 | Wireless Air Buds | Audio | 89.99 |
| 208 | Ergonomic Pro Keyboard | Accessories | 149.50 |
| 312 | Ultra Pro Monitor 27-inch | Displays | 379.00 |
| 405 | CleanAir Office Filter | Appliances | 420.00 |
Question 3: Department Headcount and Compensation Summary
Business Problem: Human Resources requires a departmental breakdown of employee headcount and average compensation. Display the department ID, total employee headcount, and average salary (rounded to 2 decimal places) for all departments employing at least 3 individuals.
-- Solution: Aggregation with GROUP BY and HAVING
SELECT
department_id,
COUNT(employee_id) AS total_employees,
ROUND(AVG(salary), 2) AS average_salary
FROM employees
GROUP BY department_id
HAVING COUNT(employee_id) >= 3
ORDER BY total_employees DESC;Query Logic: GROUP BY department_id compresses individual employee rows into department buckets. To filter aggregated groups, you must use HAVING rather than WHERE, because WHERE filters rows before aggregation occurs. For a deep dive into execution order, read our guide on SQL GROUP BY vs HAVING.
Expected Output:
| department_id | total_employees | average_salary |
|---|---|---|
| 3 | 12 | 84250.00 |
| 1 | 8 | 105400.00 |
| 2 | 5 | 72100.50 |
| 4 | 3 | 91333.33 |
Question 4: Order Status and Transaction Thresholds
Business Problem: Finance needs to audit high-value completed or shipped transactions. Retrieve the order ID, customer ID, order date, status, and total amount for orders placed between February 1, 2024, and April 30, 2024, where the status is either 'completed' or 'shipped' and the total amount exceeds $250.00.
-- Solution: Multi-condition transactional filter
SELECT
order_id,
customer_id,
order_date,
status,
total_amount
FROM orders
WHERE order_date >= '2024-02-01'
AND order_date <= '2024-04-30'
AND status IN ('completed', 'shipped')
AND total_amount > 250.00
ORDER BY total_amount DESC
LIMIT 5;Query Logic: This query applies range-based date boundaries alongside categorical filtering. Sorting by total_amount DESC combined with LIMIT 5 produces the top 5 highest-value orders fulfilling the criteria.
Expected Output:
| order_id | customer_id | order_date | status | total_amount |
|---|---|---|---|---|
| 8942 | 1045 | 2024-03-12 | completed | 1420.50 |
| 8731 | 3021 | 2024-02-18 | shipped | 980.00 |
| 9104 | 4120 | 2024-04-05 | completed | 745.25 |
| 8890 | 1874 | 2024-02-28 | completed | 620.00 |
| 9215 | 2290 | 2024-04-22 | shipped | 512.80 |
Question 5: Total Inventory Valuation by Category
Business Problem: Supply Chain Management needs to evaluate capital tied up in warehouse stock. Calculate the total inventory value (price * stock_quantity) for each product category. Show the category name, total distinct products, and total inventory value, sorted by total value descending.
-- Solution: Column arithmetic inside SUM aggregation
SELECT
category,
COUNT(product_id) AS distinct_products,
ROUND(SUM(price * stock_quantity), 2) AS total_inventory_value
FROM products
GROUP BY category
ORDER BY total_inventory_value DESC;Query Logic: SQL allows row-level arithmetic (price * stock_quantity) inside the SUM() aggregate function. The database calculates the product of price and quantity for each row, then aggregates the sum across each category partition.
Expected Output:
| category | distinct_products | total_inventory_value |
|---|---|---|
| Electronics | 28 | 482950.00 |
| Furniture | 14 | 215400.50 |
| Audio | 19 | 143200.00 |
| Accessories | 35 | 98450.75 |
Question 6: Underperforming Department Salary Cap Detection
Business Problem: Leadership is reviewing compensation equity across business units. Identify all departments where no employee earns more than $75,000 (i.e. the maximum salary within the department is strictly less than 75,000).
-- Solution: Group filtering on MAX aggregate
SELECT
department_id,
COUNT(employee_id) AS employee_count,
MAX(salary) AS highest_salary,
ROUND(AVG(salary), 2) AS average_salary
FROM employees
GROUP BY department_id
HAVING MAX(salary) < 75000.00
ORDER BY highest_salary DESC;Query Logic: This question tests whether you recognize that extreme-value boundary conditions can be tested using MAX() < threshold within the HAVING clause. Departments with any executive or senior compensation exceeding 75,000 are automatically excluded.
Expected Output:
| department_id | employee_count | highest_salary | average_salary |
|---|---|---|---|
| 5 | 4 | 71500.00 | 64200.00 |
| 7 | 3 | 68000.00 | 59500.00 |
Question 7: Monthly Unique Purchasing Customer Volume
Business Problem: Growth leads need to track customer engagement trends over time. Calculate the number of unique customers who placed an order during each calendar month of 2024.
-- Solution: DATE_TRUNC with COUNT DISTINCT
SELECT
DATE_TRUNC('month', order_date)::DATE AS sales_month,
COUNT(order_id) AS total_orders,
COUNT(DISTINCT customer_id) AS unique_purchasing_customers
FROM orders
WHERE order_date >= '2024-01-01'
AND order_date < '2025-01-01'
GROUP BY DATE_TRUNC('month', order_date)::DATE
ORDER BY sales_month ASC;Query Logic: DATE_TRUNC('month', order_date) truncates dates to the first day of their respective month. We use COUNT(DISTINCT customer_id) rather than COUNT(customer_id) because a single customer may place multiple orders in a single month; failing to use DISTINCT measures order volume rather than active customer reach.
Expected Output:
| sales_month | total_orders | unique_purchasing_customers |
|---|---|---|
| 2024-01-01 | 342 | 289 |
| 2024-02-01 | 310 | 265 |
| 2024-03-01 | 425 | 360 |
| 2024-04-01 | 398 | 334 |
Question 8: Inventory Alert for Critical Stock or High-Margin Items
Business Problem: Warehouse supervisors need an operational alert list of products requiring immediate attention. Identify products where either the stock quantity has fallen below 15 units, or the unit price exceeds $400.00, while excluding any product whose category is 'Discontinued' or 'Archived'.
-- Solution: Complex boolean logic with exclusion
SELECT
product_id,
product_name,
category,
price,
stock_quantity
FROM products
WHERE (stock_quantity < 15 OR price > 400.00)
AND category NOT IN ('Discontinued', 'Archived')
ORDER BY stock_quantity ASC, price DESC;Query Logic: Demonstrates proper operator hierarchy. The parenthesis encapsulates the alternative alert trigger conditions (low stock OR high price), while the outer AND NOT IN prevents retired catalog items from triggering false alarms.
Expected Output:
| product_id | product_name | category | price | stock_quantity |
|---|---|---|---|---|
| 142 | Enterprise Server Rack | Hardware | 1250.00 | 2 |
| 205 | Premium Noise-Cancelling Headphones | Audio | 449.00 | 8 |
| 318 | Ergonomic Standing Desk | Furniture | 520.00 | 11 |
| 109 | Wireless USB Adapter | Accessories | 24.99 | 12 |
Part 2: Intermediate SQL Queries
Intermediate queries reflect everyday business analytics: combining tables via relational keys, identifying missing or orphan records, transposing row states with conditional aggregations, and utilizing subqueries.
Question 9: Customer Lifetime Spend (INNER JOIN with Aggregation)
Business Problem: Customer Success needs a lifetime value (LTV) report. Return each customer's full name, email address, total completed orders placed, and cumulative spend across all completed orders. Display the top 5 highest-spending customers.
-- Solution: Joining customers and orders with aggregation
SELECT
c.customer_id,
c.first_name || ' ' || c.last_name AS customer_name,
c.email,
COUNT(o.order_id) AS completed_orders_count,
ROUND(SUM(o.total_amount), 2) AS lifetime_spend
FROM customers AS c
INNER JOIN orders AS o ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.customer_id, c.first_name, c.last_name, c.email
ORDER BY lifetime_spend DESC
LIMIT 5;Query Logic: The INNER JOIN bridges customers to their corresponding transaction logs on customer_id. The string concatenation operator || merges first and last names. Notice that every column in the SELECT list that is not aggregated (customer_id, first_name, last_name, email) is included in the GROUP BY clause.
Expected Output:
| customer_id | customer_name | completed_orders_count | lifetime_spend | |
|---|---|---|---|---|
| 1045 | David Miller | david.m@example.com | 18 | 8420.50 |
| 2180 | Samantha Cruz | s.cruz@example.com | 14 | 6940.00 |
| 1892 | Robert Chen | rchen@example.com | 11 | 5812.25 |
| 3041 | Amanda White | amanda.w@example.com | 9 | 5120.80 |
| 1120 | Jason Bourne | jbourne@example.com | 12 | 4995.00 |
Question 10: Inactive Customer Detection (LEFT JOIN / Anti-Join)
Business Problem: The CRM lifecycle marketing team is planning a reactivation campaign. Find all registered customers who have never placed an order in the database. Return their customer ID, full name, email, and registration date.
-- Solution: Anti-join via LEFT JOIN ... WHERE NULL
SELECT
c.customer_id,
c.first_name,
c.last_name,
c.email,
c.signup_date
FROM customers AS c
LEFT JOIN orders AS o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL
ORDER BY c.signup_date ASC;Query Logic: A LEFT JOIN preserves all records from the left table (customers) regardless of whether matching records exist in orders. For customers with zero orders, the joined columns from orders evaluate to NULL. The predicate WHERE o.order_id IS NULL isolates these non-purchasing accounts. This is a classic interview question testing the anti-join pattern.
Expected Output:
| customer_id | first_name | last_name | signup_date | |
|---|---|---|---|---|
| 451 | Jennifer | Aniston | jennifer.a@example.com | 2023-04-12 |
| 489 | Daniel | Craig | d.craig@example.com | 2023-05-19 |
| 512 | Natalie | Portman | natalie.p@example.com | 2023-07-01 |
| 620 | Bruce | Wayne | bwayne@example.com | 2023-11-28 |
Practice SQL Queries in Your Live Browser Sandbox
Run queries against real databases with instant automated feedback on Topfolio. Free to learn, optional ₹99 verified certificate.
Start Free SQL CourseQuestion 11: Managerial Reporting Hierarchy (SELF JOIN)
Business Problem: Organizational Operations needs a company directory showing every employee alongside the full name of their direct manager. If an employee is the CEO or top executive without a manager, display 'Executive / None'.
-- Solution: Self-join with COALESCE fallback
SELECT
e.employee_id,
e.first_name || ' ' || e.last_name AS employee_name,
d.department_name,
COALESCE(m.first_name || ' ' || m.last_name, 'Executive / None') AS manager_name
FROM employees AS e
LEFT JOIN employees AS m ON e.manager_id = m.employee_id
LEFT JOIN departments AS d ON e.department_id = d.department_id
ORDER BY e.department_id, e.employee_id;Query Logic: A table can join to itself. Here, employees e represents the subordinate, while employees m represents the manager, linked by e.manager_id = m.employee_id. We use a LEFT JOIN so the top executive whose manager_id IS NULL is not discarded. COALESCE handles the null value with descriptive fallback text.
Expected Output:
| employee_id | employee_name | department_name | manager_name |
|---|---|---|---|
| 1 | Sarah Connor | Executive | Executive / None |
| 2 | John Reese | Engineering | Sarah Connor |
| 3 | Harold Finch | Engineering | John Reese |
| 4 | Root Groves | Analytics | Sarah Connor |
| 5 | Sameen Shaw | Analytics | Root Groves |
Question 12: Transposing Order Status with Conditional Aggregation
Business Problem: Financial operations wants to pivot order values by status into columns. For each customer, display their customer ID, count of completed orders, and the dollar sum for completed orders, shipped orders, and cancelled orders.
-- Solution: Conditional aggregation with CASE WHEN
SELECT
customer_id,
COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed_orders_count,
ROUND(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 2) AS completed_revenue,
ROUND(SUM(CASE WHEN status = 'shipped' THEN total_amount ELSE 0 END), 2) AS in_transit_revenue,
ROUND(SUM(CASE WHEN status = 'cancelled' THEN total_amount ELSE 0 END), 2) AS lost_cancelled_revenue
FROM orders
GROUP BY customer_id
HAVING COUNT(order_id) >= 5
ORDER BY completed_revenue DESC
LIMIT 5;Query Logic: This pattern, known as conditional aggregation, simulates a pivot table directly in SQL. Inside SUM(), CASE WHEN status = 'completed' THEN total_amount ELSE 0 END evaluates the amount when the condition matches and adds zero otherwise.
Expected Output:
| customer_id | completed_orders_count | completed_revenue | in_transit_revenue | lost_cancelled_revenue |
|---|---|---|---|---|
| 1045 | 18 | 8420.50 | 320.00 | 110.00 |
| 2180 | 14 | 6940.00 | 0.00 | 450.00 |
| 1892 | 11 | 5812.25 | 215.50 | 0.00 |
| 3041 | 9 | 5120.80 | 145.00 | 85.00 |
| 1120 | 12 | 4995.00 | 0.00 | 230.00 |
Question 13: Departmental Above-Average Earners (Correlated Subquery)
Business Problem: Total Rewards compensation analysts need to identify employees who earn more than the average salary of their respective department. Display the employee ID, full name, department ID, and salary.
-- Solution: Correlated subquery in WHERE
SELECT
e1.employee_id,
e1.first_name || ' ' || e1.last_name AS employee_name,
e1.department_id,
e1.salary
FROM employees AS e1
WHERE e1.salary > (
SELECT AVG(e2.salary)
FROM employees AS e2
WHERE e2.department_id = e1.department_id
)
ORDER BY e1.department_id, e1.salary DESC;Query Logic: In a correlated subquery, the inner query references values from the outer query row (e2.department_id = e1.department_id). For every employee evaluated in e1, PostgreSQL calculates the dynamic average salary of their department and tests if e1.salary exceeds it.
Expected Output:
| employee_id | employee_name | department_id | salary |
|---|---|---|---|
| 12 | Alex Mercer | 1 | 125000.00 |
| 15 | Dana Scully | 1 | 118000.00 |
| 28 | Fox Mulder | 2 | 89000.00 |
| 34 | Walter Bishop | 3 | 98500.00 |
Question 14: Multi-Year Cohort Retention via EXISTS
Business Problem: The retention team wants to isolate persistent multi-year customers. Retrieve all customers who placed at least one order in calendar year 2023 AND placed at least one order in calendar year 2024.
-- Solution: Set intersection using paired EXISTS subqueries
SELECT
c.customer_id,
c.first_name,
c.last_name,
c.email
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o23
WHERE o23.customer_id = c.customer_id
AND o23.order_date >= '2023-01-01'
AND o23.order_date < '2024-01-01'
)
AND EXISTS (
SELECT 1
FROM orders AS o24
WHERE o24.customer_id = c.customer_id
AND o24.order_date >= '2024-01-01'
AND o24.order_date < '2025-01-01'
)
ORDER BY c.customer_id ASC;Query Logic: EXISTS checks for the presence of rows satisfying a condition and terminates evaluation as soon as the first match is found (short-circuit evaluation). Pairing two EXISTS blocks joined by AND guarantees that the customer completed transactions in both distinct years.
Expected Output:
| customer_id | first_name | last_name | |
|---|---|---|---|
| 102 | Bruce | Banner | b.banner@example.com |
| 118 | Natasha | Romanoff | natasha.r@example.com |
| 205 | Tony | Stark | tony@stark.com |
| 241 | Steve | Rogers | cap@avengers.com |
Question 15: Missing Contact Sanitization with COALESCE and NULLIF
Business Problem: Customer support systems frequently receive dirty input. Clean the customer phone directory. If a customer has a mobile_phone, use it. If mobile_phone is null or empty string '', fallback to work_phone. If both are unavailable, output 'Phone Not Provided'.
-- Solution: Data cleaning using NULLIF and COALESCE
SELECT
customer_id,
first_name || ' ' || last_name AS customer_name,
COALESCE(
NULLIF(TRIM(mobile_phone), ''),
NULLIF(TRIM(work_phone), ''),
'Phone Not Provided'
) AS primary_contact_phone
FROM customers
LIMIT 5;Query Logic: NULLIF(val, '') converts whitespace or empty strings into true SQL NULLs. COALESCE scans values from left to right and returns the first non-null expression. This defensive pattern prevents whitespace strings from bypassing null checks.
Expected Output:
| customer_id | customer_name | primary_contact_phone |
|---|---|---|
| 101 | Arthur Dent | +1-555-0192 |
| 102 | Ford Prefect | +1-555-8831 |
| 103 | Tricia McMillan | Phone Not Provided |
| 104 | Zaphod Beeblebrox | +1-555-4242 |
| 105 | Marvin Paranoid | Phone Not Provided |
Question 16: Multi-Table Revenue Attribution without Join Fan-Out
Business Problem: E-commerce analysts need to calculate total gross sales value (quantity * unit_price) broken down by product category. Be careful not to double-count parent order amounts across multiple child order items.
-- Solution: Safe multi-table join on line-item grain
SELECT
p.category,
COUNT(DISTINCT oi.order_id) AS total_orders_containing_category,
SUM(oi.quantity) AS total_units_sold,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS gross_category_revenue
FROM products AS p
INNER JOIN order_items AS oi ON p.product_id = oi.product_id
INNER JOIN orders AS o ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY p.category
ORDER BY gross_category_revenue DESC;Query Logic: When joining orders, order_items, and products, line-level metrics must be computed at the child item grain (order_items). If you sum o.total_amount here, it would multiply across line items. For a complete analysis of this risk, read our comprehensive guide on SQL Join Fan-Out & Double Counting.
Expected Output:
| category | total_orders_containing_category | total_units_sold | gross_category_revenue |
|---|---|---|---|
| Electronics | 1420 | 2840 | 684200.00 |
| Audio | 980 | 1850 | 245900.50 |
| Furniture | 610 | 820 | 198400.00 |
| Accessories | 2100 | 5400 | 142300.75 |
Part 3: Advanced Analytical Queries
Advanced SQL queries distinguish senior analysts. These questions explore window functions, Common Table Expressions (CTEs), recursive trees, and temporal sequence algorithms.
Question 17: Top 2 Highest-Paid Employees per Department (DENSE_RANK)
Business Problem: Executive leadership wants to benchmark compensation tiers. Identify the top 2 highest-paid employees within each department. If employees tie for second place, include all tied individuals without skipping ranks.
-- Solution: Window ranking inside a Common Table Expression (CTE)
WITH ranked_employees AS (
SELECT
e.employee_id,
e.first_name || ' ' || e.last_name AS employee_name,
d.department_name,
e.salary,
DENSE_RANK() OVER (
PARTITION BY e.department_id
ORDER BY e.salary DESC
) AS salary_rank
FROM employees AS e
INNER JOIN departments AS d ON e.department_id = d.department_id
)
SELECT
employee_id,
employee_name,
department_name,
salary,
salary_rank
FROM ranked_employees
WHERE salary_rank <= 2
ORDER BY department_name, salary_rank ASC;Query Logic: In SQL logical query processing, window functions evaluate during the SELECT phase, which happens after WHERE. Consequently, you cannot filter window outputs directly in the same query block (e.g. WHERE DENSE_RANK() <= 2 throws a syntax error).
Wrapping the query in a CTE (WITH ranked_employees AS (...)) materializes the ranking, allowing the outer query to filter cleanly. We select DENSE_RANK() instead of ROW_NUMBER() or RANK() because it preserves ties without skipping subsequent rank integers. Compare ranking behaviors in our tutorial on SQL ROW_NUMBER, RANK, and DENSE_RANK and learn more CTE patterns in our SQL CTE Guide.
Expected Output:
| employee_id | employee_name | department_name | salary | salary_rank |
|---|---|---|---|---|
| 12 | Alex Mercer | Engineering | 125000.00 | 1 |
| 15 | Dana Scully | Engineering | 118000.00 | 2 |
| 4 | Root Groves | Analytics | 108000.00 | 1 |
| 5 | Sameen Shaw | Analytics | 99500.00 | 2 |
| 28 | Fox Mulder | Marketing | 89000.00 | 1 |
| 31 | Walter Skinner | Marketing | 89000.00 | 1 |
| 35 | Monica Reyes | Marketing | 82000.00 | 2 |
Question 18: Month-over-Month (MoM) Revenue Growth Rate (LAG)
Business Problem: Financial planning and analysis (FP&A) requires a monthly revenue scorecard showing total monthly revenue, prior month revenue, and the month-over-month (MoM) percentage growth rate.
-- Solution: LAG window function across grouped monthly aggregates
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', order_date)::DATE AS sales_month,
ROUND(SUM(total_amount), 2) AS current_month_revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', order_date)::DATE
)
SELECT
sales_month,
current_month_revenue,
LAG(current_month_revenue, 1) OVER (ORDER BY sales_month ASC) AS prior_month_revenue,
ROUND(
(current_month_revenue - LAG(current_month_revenue, 1) OVER (ORDER BY sales_month ASC))
/ NULLIF(LAG(current_month_revenue, 1) OVER (ORDER BY sales_month ASC), 0) * 100.0,
2
) AS mom_growth_percentage
FROM monthly_revenue
ORDER BY sales_month ASC;Query Logic: First, the CTE groups transactions into calendar months. Second, LAG(current_month_revenue, 1) OVER (ORDER BY sales_month ASC) accesses the revenue from the preceding month row without performing a self-join. Finally, NULLIF(..., 0) prevents division-by-zero runtime exceptions for baseline months.
Expected Output:
| sales_month | current_month_revenue | prior_month_revenue | mom_growth_percentage |
|---|---|---|---|
| 2024-01-01 | 145200.00 | NULL | NULL |
| 2024-02-01 | 158400.50 | 145200.00 | 9.09 |
| 2024-03-01 | 182100.00 | 158400.50 | 14.96 |
| 2024-04-01 | 171300.25 | 182100.00 | -5.93 |
Question 19: Running Cumulative Cash Flow (SUM OVER)
Business Problem: The Treasury team tracks daily cumulative revenue to forecast cash runway. Calculate the daily transaction total and the cumulative running total revenue across all days in March 2024.
-- Solution: Cumulative window aggregation with explicit frame
WITH daily_revenue AS (
SELECT
order_date,
ROUND(SUM(total_amount), 2) AS daily_total
FROM orders
WHERE order_date BETWEEN '2024-03-01' AND '2024-03-05'
AND status = 'completed'
GROUP BY order_date
)
SELECT
order_date,
daily_total,
SUM(daily_total) OVER (
ORDER BY order_date ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_cumulative_revenue
FROM daily_revenue
ORDER BY order_date ASC;Query Logic: The window specification ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW instructs the engine to sum all rows from the beginning of the partition up through the active row. Specifying ROWS rather than RANGE provides superior query execution performance and avoids grouping peer timestamps together.
Expected Output:
| order_date | daily_total | running_cumulative_revenue |
|---|---|---|
| 2024-03-01 | 4250.00 | 4250.00 |
| 2024-03-02 | 6120.50 | 10370.50 |
| 2024-03-03 | 5840.00 | 16210.50 |
| 2024-03-04 | 7910.25 | 24120.75 |
| 2024-03-05 | 6430.00 | 30550.75 |
Question 20: Purchase Velocity and Repeat Order Cadence (LEAD)
Business Problem: Customer Retention needs to know how many days elapse between repeat orders. For customers with multiple transactions, calculate the number of days between each order and their subsequent order.
-- Solution: LEAD window function with date subtraction
SELECT
customer_id,
order_id,
order_date AS current_order_date,
LEAD(order_date, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date ASC
) AS next_order_date,
LEAD(order_date, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date ASC
) - order_date AS days_until_next_purchase
FROM orders
WHERE status = 'completed'
ORDER BY customer_id, order_date ASC
LIMIT 6;Query Logic: PARTITION BY customer_id resets the window boundary for every customer. LEAD(order_date, 1) pulls the timestamp from the subsequent row. In PostgreSQL, subtracting two DATE types yields an integer representing the elapsed number of days. If the customer has no subsequent order, LEAD returns NULL.
Expected Output:
| customer_id | order_id | current_order_date | next_order_date | days_until_next_purchase |
|---|---|---|---|---|
| 1045 | 5012 | 2024-01-10 | 2024-01-28 | 18 |
| 1045 | 5480 | 2024-01-28 | 2024-02-15 | 18 |
| 1045 | 6102 | 2024-02-15 | NULL | NULL |
| 2180 | 4890 | 2024-01-05 | 2024-02-22 | 48 |
| 2180 | 6241 | 2024-02-22 | NULL | NULL |
Question 21: Customer Spend Quartile Segmentation (NTILE)
Business Problem: Marketing needs to divide all purchasing customers into 4 equal quartiles based on their lifetime spend. Quartile 1 represents the highest spenders (VIPs), while Quartile 4 represents low-tier purchasers.
-- Solution: NTILE statistical bucket distribution
WITH customer_spend AS (
SELECT
customer_id,
ROUND(SUM(total_amount), 2) AS total_spent
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
)
SELECT
customer_id,
total_spent,
NTILE(4) OVER (ORDER BY total_spent DESC) AS spend_quartile
FROM customer_spend
ORDER BY spend_quartile ASC, total_spent DESC;Query Logic: NTILE(4) calculates the total row count of the partition and evenly distributes rows across 4 buckets numbered 1 through 4. Because the window is ordered by total_spent DESC, the top 25% of spenders receive bucket 1.
Expected Output:
| customer_id | total_spent | spend_quartile |
|---|---|---|
| 1045 | 8420.50 | 1 |
| 2180 | 6940.00 | 1 |
| 1892 | 5812.25 | 1 |
| 3421 | 2450.00 | 2 |
| 1980 | 2310.50 | 2 |
| 4501 | 890.00 | 3 |
| 5122 | 340.00 | 4 |
Question 22: First-Order Baseline vs Repeat Spending (FIRST_VALUE)
Business Problem: Growth analysts want to determine if a customer's first purchase value correlates with their average lifetime order value (AOV). For each completed order, return the customer ID, order date, current order amount, and the value of their very first order.
-- Solution: FIRST_VALUE window function
SELECT
customer_id,
order_id,
order_date,
total_amount,
FIRST_VALUE(total_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS initial_acquisition_order_amount
FROM orders
WHERE status = 'completed'
ORDER BY customer_id, order_date ASC
LIMIT 5;Query Logic: FIRST_VALUE(total_amount) extracts the initial order value for that customer partition. Expanding the frame to ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ensures that the function evaluates across the entire partition regardless of where the current cursor sits.
Expected Output:
| customer_id | order_id | order_date | total_amount | initial_acquisition_order_amount |
|---|---|---|---|---|
| 1045 | 5012 | 2024-01-10 | 120.00 | 120.00 |
| 1045 | 5480 | 2024-01-28 | 450.50 | 120.00 |
| 1045 | 6102 | 2024-02-15 | 890.00 | 120.00 |
| 2180 | 4890 | 2024-01-05 | 340.00 | 340.00 |
| 2180 | 6241 | 2024-02-22 | 610.00 | 340.00 |
Question 23: Recursive Organizational Hierarchy Mapping (WITH RECURSIVE)
Business Problem: Human Resources needs a full organizational chart depicting employee reporting depth levels. Start with executive leaders who report to no one (manager_id IS NULL) as Level 1, and recursively traverse down each management tier.
-- Solution: Recursive Common Table Expression
WITH RECURSIVE org_hierarchy AS (
-- Anchor Member: Top-level executives with no manager
SELECT
employee_id,
first_name || ' ' || last_name AS employee_name,
manager_id,
1 AS management_level,
first_name || ' ' || last_name AS reporting_path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive Member: Subordinates joining to prior level
SELECT
e.employee_id,
e.first_name || ' ' || e.last_name AS employee_name,
e.manager_id,
oh.management_level + 1 AS management_level,
oh.reporting_path || ' -> ' || (e.first_name || ' ' || e.last_name) AS reporting_path
FROM employees AS e
INNER JOIN org_hierarchy AS oh ON e.manager_id = oh.employee_id
)
SELECT
employee_id,
employee_name,
management_level,
reporting_path
FROM org_hierarchy
ORDER BY management_level ASC, employee_id ASC;Query Logic: Recursive CTEs contain two queries united by UNION ALL:
- The Anchor Query: Initializes the base result set (where
manager_id IS NULL). - The Recursive Query: Iteratively joins
employeesback to the growing results oforg_hierarchyuntil no more matching subordinate rows are found. It tracks tier levels by incrementingmanagement_level + 1.
Expected Output:
| employee_id | employee_name | management_level | reporting_path |
|---|---|---|---|
| 1 | Sarah Connor | 1 | Sarah Connor |
| 2 | John Reese | 2 | Sarah Connor → John Reese |
| 4 | Root Groves | 2 | Sarah Connor → Root Groves |
| 3 | Harold Finch | 3 | Sarah Connor → John Reese → Harold Finch |
| 5 | Sameen Shaw | 3 | Sarah Connor → Root Groves → Sameen Shaw |
Question 24: Consecutive Activity Streaks (Gaps and Islands)
Business Problem: Product analytics wants to reward loyal daily users. Given daily platform login/order dates, identify consecutive purchase streaks for customer 1045. A streak consists of 2 or more consecutive days of order activity.
-- Solution: The classic Row-Number Difference Gaps & Islands pattern
WITH distinct_customer_dates AS (
SELECT DISTINCT
customer_id,
order_date
FROM orders
WHERE customer_id = 1045
),
grouped_streaks AS (
SELECT
customer_id,
order_date,
order_date - (ROW_NUMBER() OVER (ORDER BY order_date ASC) * INTERVAL '1 day') AS streak_island_key
FROM distinct_customer_dates
)
SELECT
customer_id,
MIN(order_date) AS streak_start_date,
MAX(order_date) AS streak_end_date,
COUNT(*) AS consecutive_active_days
FROM grouped_streaks
GROUP BY customer_id, streak_island_key
HAVING COUNT(*) >= 2
ORDER BY streak_start_date ASC;Query Logic: This demonstrates the industry-standard Gaps and Islands algorithm. When consecutive calendar dates are paired with consecutive row numbers (ROW_NUMBER()), subtracting (row_num * 1 day) from order_date produces a constant baseline date (streak_island_key) for every contiguous day in that streak. The moment a gap in dates occurs, the difference shifts to a new key value. Grouping by this key aggregates the streak length!
Expected Output:
| customer_id | streak_start_date | streak_end_date | consecutive_active_days |
|---|---|---|---|
| 1045 | 2024-03-12 | 2024-03-15 | 4 |
| 1045 | 2024-04-02 | 2024-04-04 | 3 |
Question 25: 3-Month Rolling Average Revenue per Category
Business Problem: Inventory and revenue planning teams must smooth seasonal sales spikes. Compute the 3-month rolling average monthly gross revenue for each product category (spanning the current month and preceding 2 months).
-- Solution: Moving window frame (ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
WITH monthly_category_sales AS (
SELECT
p.category,
DATE_TRUNC('month', o.order_date)::DATE AS sales_month,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS monthly_sales
FROM products AS p
INNER JOIN order_items AS oi ON p.product_id = oi.product_id
INNER JOIN orders AS o ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY p.category, DATE_TRUNC('month', o.order_date)::DATE
)
SELECT
category,
sales_month,
monthly_sales,
ROUND(
AVG(monthly_sales) OVER (
PARTITION BY category
ORDER BY sales_month ASC
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
),
2
) AS rolling_3_month_average_sales
FROM monthly_category_sales
ORDER BY category, sales_month ASC;Query Logic: The clause ROWS BETWEEN 2 PRECEDING AND CURRENT ROW instructs AVG() to calculate the arithmetic mean of at most 3 data points (the two prior calendar months plus the active month). When evaluated on the first month in the series, it computes the single-month average; by month 3, it produces the full 3-month smoothed moving metric.
Expected Output:
| category | sales_month | monthly_sales | rolling_3_month_average_sales |
|---|---|---|---|
| Electronics | 2024-01-01 | 85000.00 | 85000.00 |
| Electronics | 2024-02-01 | 92000.00 | 88500.00 |
| Electronics | 2024-03-01 | 110000.00 | 95666.67 |
| Electronics | 2024-04-01 | 98000.00 | 100000.00 |
5-Step Strategic Framework for Technical SQL Interviews
When faced with a live whiteboard or shared coding environment, top-performing candidates never jump straight into typing SQL. They apply a structured five-step framework:
- Clarify the Data Grain: Ask what represents one row in every referenced table. Is
ordersone row per order, or one row per line item? Identifying cardinality upfront prevents accidental fan-out errors. - Isolate Filtering Constraints: Determine whether criteria must filter rows before aggregation (
WHERE) or after aggregation (HAVING). - Plan the Join Path: Prefer explicit
INNER JOINorLEFT JOINsyntax. Avoid legacy comma-separated joins (FROM tableA, tableB) which obscure Cartesian products. - Decompose Complex Calculations into Modular CTEs: Build sub-metrics in readable CTE blocks. This makes debugging effortless and proves code maintainability to interviewers.
- Verify Edge Cases: Check for null values, division-by-zero risks (
NULLIF), tie-breaking criteria in ranking functions, and duplicate keys.
Remember that SQL engines execute clauses in a specific order:
FROM & JOINs --> WHERE --> GROUP BY --> HAVING --> SELECT --> DISTINCT --> WINDOW FUNCTIONS --> ORDER BY --> LIMIT
Understanding this execution order explains why column aliases defined in SELECT cannot be referenced in WHERE, and why window functions require a CTE or subquery to filter by rank. For an in-depth breakdown, read our guide to SQL Order of Execution.
Master SQL with Live Interactive Verification
Reading queries is only the first step. Building technical confidence requires typing code, handling database syntax errors, and inspecting query plans against real data.
On Topfolio, you can test every query pattern from this guide in our interactive browser sandbox. Practice multi-table joins, CTE aggregations, and window functions with instant automated feedback. All learning modules are 100% free to access, with an optional ₹99 verified certificate available to validate your skills to prospective employers.
Practice SQL Queries in Your Live Browser Sandbox
Run queries against real databases with instant automated feedback on Topfolio. Free to learn, optional ₹99 verified certificate.
Start Free SQL CourseFrequently Asked Questions
Where can I find free SQL practice questions with instant query validation?
Topfolio provides an interactive browser-based SQL sandbox where you can practice real-world queries on live PostgreSQL datasets with automated grading. All lessons are 100% free to learn, with an optional verified certificate available for ₹99.
What are the most common SQL queries tested in technical interviews?
Technical interviewers consistently prioritize multi-table INNER and LEFT JOINs, aggregation with GROUP BY and HAVING, conditional grouping via CASE WHEN, Common Table Expressions (CTEs), and ranking window functions such as ROW_NUMBER, DENSE_RANK, and LAG.
How should I structure my SQL practice to transition from beginner to advanced?
Master single-table filtering and aggregations first (SELECT, WHERE, GROUP BY, HAVING). Next, practice relational integrity and multi-table joins (INNER, LEFT, SELF JOIN). Finally, progress to analytical window functions (ROW_NUMBER, LAG/LEAD, running sums) and CTE modularization.
Why do PostgreSQL window functions require subqueries or CTEs to filter by rank?
Under SQL logical query processing order, the WHERE clause evaluates before window functions in the SELECT clause. Because the database engine calculates window ranks after filtering, you must wrap window calculations in a subquery or CTE to filter by rank.
What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
ROW_NUMBER assigns distinct consecutive integers (1, 2, 3) ignoring ties. RANK assigns identical values to ties and skips subsequent numbers (1, 2, 2, 4). DENSE_RANK assigns identical values to ties without skipping ranks (1, 2, 2, 3).
How can I practice SQL queries without installing database software locally?
You can write and execute SQL directly in your web browser using Topfolio's cloud-hosted interactive SQL sandbox, which runs actual PostgreSQL engines with zero local installation or Docker configuration required.

Written by
Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.
Related Articles
SQL Joins Practice Exercises: 15 Real Queries & Answers
Master SQL joins with 15 real business practice exercises. Solve INNER, LEFT, RIGHT, FULL OUTER, CROSS, and SELF JOINs with schemas and expected outputs.
Data Analyst Interview Questions 2026: Complete Preparation Guide
30+ real data analyst interview questions with schemas, solutions & pitfalls — SQL OAs vs live technical rounds, Python, modern data stack, product cases & behavioral.
SQL Interview Questions for Data Analyst (2026 Guide)
Master 2026 SQL interview questions for data analysts. Real queries, window functions, joins, common traps, and runnable code solutions.