Practice Questions — SQL Interview Masterclass - Top 30 Questions Solved
← Back to NotesTopic-Specific Questions
Question 1
Easy
Find the 4th highest distinct salary from employees. Use DENSE_RANK.
DENSE_RANK handles ties.
SELECT DISTINCT salary FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) t WHERE rnk = 4;Question 2
Easy
Find all products whose price is above the overall average.
Subquery in WHERE.
SELECT id, name, price FROM products
WHERE price > (SELECT AVG(price) FROM products);Question 3
Easy
List employees hired in March 2025.
BETWEEN or YEAR/MONTH extract.
SELECT id, name, hire_date FROM employees
WHERE hire_date BETWEEN '2025-03-01' AND '2025-03-31';Question 4
Easy
Find duplicate emails in the users table along with their count.
GROUP BY + HAVING.
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;Question 5
Easy
Return the count of employees per department, ordered by count descending.
GROUP BY + ORDER BY.
SELECT dept, COUNT(*) AS n
FROM employees
GROUP BY dept
ORDER BY n DESC;Question 6
Medium
List employees whose name starts with 'A' or 'P' and whose salary is in the top 3 of their department.
Combine WHERE with a per-group window.
SELECT dept, name, salary FROM (
SELECT dept, name, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
WHERE name LIKE 'A%' OR name LIKE 'P%'
) t
WHERE rn <= 3;Question 7
Medium
For each department, return the average salary and the number of employees whose salary is above that average.
Join a grouped summary back to the base table.
WITH avgs AS (
SELECT dept, AVG(salary) AS avg_sal FROM employees GROUP BY dept
)
SELECT e.dept, a.avg_sal, COUNT(*) AS above_avg_count
FROM employees e
JOIN avgs a ON a.dept = e.dept
WHERE e.salary > a.avg_sal
GROUP BY e.dept, a.avg_sal;Question 8
Medium
Find the employee(s) with the longest tenure (earliest hire_date).
Subquery for MIN, then filter.
SELECT id, name, hire_date
FROM employees
WHERE hire_date = (SELECT MIN(hire_date) FROM employees);Question 9
Medium
Return the top 2 highest-paid employees per department (break ties arbitrarily).
ROW_NUMBER with PARTITION BY dept.
SELECT dept, name, salary FROM (
SELECT dept, name, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
) t
WHERE rn <= 2
ORDER BY dept, rn;Question 10
Medium
For each order, show the total and the percentage it contributes to the grand total.
Window SUM without PARTITION.
SELECT id, total,
ROUND(100.0 * total / SUM(total) OVER (), 2) AS pct
FROM orders;Question 11
Medium
List customers who ordered products from more than 3 distinct categories.
JOIN + GROUP BY customer + COUNT DISTINCT category_id.
SELECT o.customer_id
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.customer_id
HAVING COUNT(DISTINCT p.category_id) > 3;Question 12
Hard
For each user, find the longest gap (in days) between consecutive login_dates.
LEAD or LAG to get next/previous login, then DATEDIFF.
WITH gaps AS (
SELECT user_id, login_date,
DATEDIFF(LEAD(login_date) OVER (PARTITION BY user_id ORDER BY login_date),
login_date) AS gap_days
FROM logins
)
SELECT user_id, MAX(gap_days) AS longest_gap
FROM gaps
WHERE gap_days IS NOT NULL
GROUP BY user_id;Question 13
Hard
Calculate the 14-day rolling sum of daily_sales.
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW.
SELECT sale_date, daily_total,
SUM(daily_total) OVER (
ORDER BY sale_date
ROWS BETWEEN 13 PRECEDING AND CURRENT ROW
) AS rolling_14d
FROM daily_sales
ORDER BY sale_date;Question 14
Hard
Find 'active' customers: placed at least one order in each of the last 3 months.
Cross-join a calendar of the last 3 months with customers, then verify every month is covered.
WITH last3 AS (
SELECT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 0 MONTH), '%Y-%m') AS m UNION ALL
SELECT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m') UNION ALL
SELECT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 2 MONTH), '%Y-%m')
)
SELECT customer_id
FROM orders
WHERE DATE_FORMAT(order_date, '%Y-%m') IN (SELECT m FROM last3)
GROUP BY customer_id
HAVING COUNT(DISTINCT DATE_FORMAT(order_date, '%Y-%m')) = 3;Question 15
Hard
Find products that were ordered in both 2024 AND 2025 but not in 2026.
INTERSECT-like pattern using GROUP BY + HAVING.
SELECT product_id
FROM order_items oi JOIN orders o ON o.id = oi.order_id
GROUP BY product_id
HAVING SUM(CASE WHEN YEAR(o.order_date) = 2024 THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN YEAR(o.order_date) = 2025 THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN YEAR(o.order_date) = 2026 THEN 1 ELSE 0 END) = 0;Question 16
Hard
For a products table with (id, name, price, category_id), return the second most expensive product per category. If only one product exists in a category, return NULL for that row.
DENSE_RANK with PARTITION BY category_id.
WITH ranked AS (
SELECT category_id, name, price,
DENSE_RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS rnk
FROM products
)
SELECT c.id AS category_id, r.name AS second_most_expensive
FROM (SELECT DISTINCT category_id AS id FROM products) c
LEFT JOIN ranked r ON r.category_id = c.id AND r.rnk = 2;Question 17
Hard
A user is a 'churned' user if their last order was more than 180 days ago. Return one row per churned user with last_order_date.
MAX(order_date) per user in HAVING.
SELECT customer_id, MAX(order_date) AS last_order_date
FROM orders
GROUP BY customer_id
HAVING MAX(order_date) < DATE_SUB(CURDATE(), INTERVAL 180 DAY);Mixed & Application Questions
Question 1
Easy
Count the number of employees in each department.
GROUP BY dept.
SELECT dept, COUNT(*) AS n FROM employees GROUP BY dept;Question 2
Easy
Return employees whose salary is within 10% of the dept average.
Dept average via window function.
SELECT * FROM (
SELECT e.*, AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees e
) t
WHERE salary BETWEEN dept_avg * 0.9 AND dept_avg * 1.1;Question 3
Easy
Return the earliest login per user.
MIN(login_date) per user.
SELECT user_id, MIN(login_date) AS first_login
FROM logins GROUP BY user_id;Question 4
Medium
For each post, return the number of comments and the number of likes.
Left join both, count each separately with DISTINCT.
SELECT p.id,
COUNT(DISTINCT c.id) AS comment_count,
COUNT(DISTINCT l.user_id) AS like_count
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
LEFT JOIN likes l ON l.post_id = p.id
GROUP BY p.id;Question 5
Medium
Find products priced within the top 20% of their category.
NTILE partitions into buckets.
SELECT category_id, name, price FROM (
SELECT category_id, name, price,
NTILE(5) OVER (PARTITION BY category_id ORDER BY price DESC) AS bucket
FROM products
) t
WHERE bucket = 1;Question 6
Medium
For every order, return the difference (in rupees) from the average total of the previous 5 orders (by order_date).
AVG with a ROWS frame.
SELECT id, order_date, total,
AVG(total) OVER (
ORDER BY order_date
ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING
) AS avg_prev_5,
total - AVG(total) OVER (
ORDER BY order_date
ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING
) AS diff
FROM orders;Question 7
Medium
List all employees whose manager is in a different department.
Self-join + inequality.
SELECT e.name AS emp, e.dept AS emp_dept,
m.name AS manager, m.dept AS mgr_dept
FROM employees e
JOIN employees m ON m.id = e.manager_id
WHERE e.dept <> m.dept;Question 8
Medium
Return the median order total.
ROW_NUMBER + COUNT OVER () + FLOOR/CEIL.
SELECT AVG(total) AS median
FROM (
SELECT total,
ROW_NUMBER() OVER (ORDER BY total) AS rn,
COUNT(*) OVER () AS n
FROM orders
) t
WHERE rn IN (FLOOR((n + 1) / 2), CEIL((n + 1) / 2));Question 9
Hard
A post is 'trending' if it received more than 100 likes in the last 24 hours. Return all trending post ids.
Filter likes by timestamp, group by post.
SELECT post_id
FROM likes
WHERE liked_at >= NOW() - INTERVAL 24 HOUR
GROUP BY post_id
HAVING COUNT(*) > 100;Question 10
Hard
Count, for each customer, the number of orders they placed on weekends.
DAYOFWEEK: 1=Sunday, 7=Saturday.
SELECT customer_id,
SUM(CASE WHEN DAYOFWEEK(order_date) IN (1, 7) THEN 1 ELSE 0 END) AS weekend_orders
FROM orders
GROUP BY customer_id;Question 11
Hard
For each user, find the time (in minutes) between their first and second login.
Window function to number logins, self-join or pivot.
WITH ranked AS (
SELECT user_id, login_ts,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_ts) AS rn
FROM logins
)
SELECT r1.user_id,
TIMESTAMPDIFF(MINUTE, r1.login_ts, r2.login_ts) AS minutes_between
FROM ranked r1
JOIN ranked r2 ON r1.user_id = r2.user_id AND r1.rn = 1 AND r2.rn = 2;Question 12
Hard
For each employee, list the names of their direct reports, comma-separated.
GROUP_CONCAT with self-join.
SELECT m.name AS manager,
GROUP_CONCAT(e.name ORDER BY e.name SEPARATOR ', ') AS reports
FROM employees e
JOIN employees m ON m.id = e.manager_id
GROUP BY m.id, m.name;Question 13
Hard
Pivot: for each customer, show the total they spent in each of the last 3 calendar months.
Conditional aggregation with DATE_FORMAT.
SELECT customer_id,
SUM(CASE WHEN DATE_FORMAT(order_date,'%Y-%m') = DATE_FORMAT(CURDATE(),'%Y-%m') THEN total ELSE 0 END) AS this_month,
SUM(CASE WHEN DATE_FORMAT(order_date,'%Y-%m') = DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH),'%Y-%m') THEN total ELSE 0 END) AS last_month,
SUM(CASE WHEN DATE_FORMAT(order_date,'%Y-%m') = DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 2 MONTH),'%Y-%m') THEN total ELSE 0 END) AS two_months_ago
FROM orders
WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH)
GROUP BY customer_id;Question 14
Hard
Return the percentage of orders that have at least one item costing more than Rs. 1000.
CASE with aggregation over a subquery/EXISTS.
SELECT 100.0 * SUM(
CASE WHEN EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.order_id = o.id AND oi.unit_price > 1000
) THEN 1 ELSE 0 END
) / COUNT(*) AS pct
FROM orders o;Question 15
Hard
For each employee, show their salary, the average salary of the whole company, and the difference.
Window function with empty frame.
SELECT name, salary,
AVG(salary) OVER () AS company_avg,
salary - AVG(salary) OVER () AS diff
FROM employees;Question 16
Hard
Identify employees whose salary is higher than the average salary of every other department.
ALL subquery.
SELECT name, dept, salary FROM employees e
WHERE salary > ALL (
SELECT AVG(salary) FROM employees e2
WHERE e2.dept <> e.dept
GROUP BY e2.dept
);Multiple Choice Questions
MCQ 1
Which function handles ties naturally when finding the Nth highest salary?
Answer: B
B is correct. DENSE_RANK assigns the same rank to tied salaries and does not skip — ideal for 'Nth distinct'.
B is correct. DENSE_RANK assigns the same rank to tied salaries and does not skip — ideal for 'Nth distinct'.
MCQ 2
Which clause filters groups in a GROUP BY query?
Answer: B
B is correct. HAVING filters after aggregation. WHERE filters rows before aggregation.
B is correct. HAVING filters after aggregation. WHERE filters rows before aggregation.
MCQ 3
What does LAG(x) OVER (ORDER BY t) return on the first row?
Answer: C
C is correct. There is no previous row for row 1, so LAG returns NULL. Use LAG(x, 1, 0) to default to 0.
C is correct. There is no previous row for row 1, so LAG returns NULL. Use LAG(x, 1, 0) to default to 0.
MCQ 4
Which is the correct way to find customers who never placed an order?
Answer: D
D is correct. NOT IN with NULL in the subquery silently returns zero rows. Prefer NOT EXISTS or LEFT JOIN ... IS NULL.
D is correct. NOT IN with NULL in the subquery silently returns zero rows. Prefer NOT EXISTS or LEFT JOIN ... IS NULL.
MCQ 5
What does SUM(total) OVER () compute?
Answer: B
B is correct. An empty window is the whole result set — SUM over the whole set, placed on every row.
B is correct. An empty window is the whole result set — SUM over the whole set, placed on every row.
MCQ 6
Which window function breaks ties arbitrarily (no two rows share a rank)?
Answer: C
C is correct. ROW_NUMBER assigns distinct sequential numbers; ties are broken by the query optimiser.
C is correct. ROW_NUMBER assigns distinct sequential numbers; ties are broken by the query optimiser.
MCQ 7
Which function pivots rows into columns in MySQL?
Answer: B
B is correct. MySQL has no PIVOT keyword. SUM(CASE WHEN condition THEN value ELSE 0 END) is the universal pivot.
B is correct. MySQL has no PIVOT keyword. SUM(CASE WHEN condition THEN value ELSE 0 END) is the universal pivot.
MCQ 8
How do you find the median of a column without a MEDIAN function?
Answer: B
B is correct. Number the sorted rows, pick position FLOOR((n+1)/2) and CEIL((n+1)/2), AVG the result.
B is correct. Number the sorted rows, pick position FLOOR((n+1)/2) and CEIL((n+1)/2), AVG the result.
MCQ 9
Which is TRUE about ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW?
Answer: B
B is correct. This is the canonical cumulative / running total frame.
B is correct. This is the canonical cumulative / running total frame.
MCQ 10
A correlated subquery is one that:
Answer: B
B is correct. Correlation = row-by-row dependency. Powerful but potentially slow — measure before using on large tables.
B is correct. Correlation = row-by-row dependency. Powerful but potentially slow — measure before using on large tables.
MCQ 11
Which is TRUE about GROUP_CONCAT?
Answer: B
B is correct. MySQL's answer to string_agg. Watch
B is correct. MySQL's answer to string_agg. Watch
group_concat_max_len for large groups.MCQ 12
To find employees earning more than their manager, you use:
Answer: B
B is correct. Self-join with aliases is the classic pattern.
B is correct. Self-join with aliases is the classic pattern.
MCQ 13
Why is the 'date minus row_number' trick used for consecutive-day problems?
Answer: B
B is correct. The difference is constant for consecutive runs; GROUP BY that constant gives you each run.
B is correct. The difference is constant for consecutive runs; GROUP BY that constant gives you each run.
MCQ 14
Which is the correct way to get top 3 per group (ties broken arbitrarily)?
Answer: C
C is correct. ROW_NUMBER always yields exactly 3 rows per group. RANK / DENSE_RANK can give more than 3 if there are ties.
C is correct. ROW_NUMBER always yields exactly 3 rows per group. RANK / DENSE_RANK can give more than 3 if there are ties.
MCQ 15
Why should you use NOT EXISTS over NOT IN when the subquery column can contain NULL?
Answer: B
B is correct. NOT IN ... NULL yields UNKNOWN for every comparison, excluding all outer rows.
B is correct. NOT IN ... NULL yields UNKNOWN for every comparison, excluding all outer rows.
MCQ 16
Which is NOT a valid way to compute a 7-day moving average?
Answer: D
D is correct. The others compute a moving average; D is a nonsense construct.
D is correct. The others compute a moving average; D is a nonsense construct.
MCQ 17
When is a cumulative-percentage query useful?
Answer: B
B is correct. Cumulative percentages are the fastest way to spot which N items drive most of the total.
B is correct. Cumulative percentages are the fastest way to spot which N items drive most of the total.
Coding Challenges
Challenge 1: Top Customer Per City
EasyGiven
customers(id, name, city) and orders(customer_id, total), return the highest-spending customer in each city along with their total spend.Sample Input
customers: (1,'Aarav','Pune'),(2,'Priya','Pune'),(3,'Rohan','Mumbai')
orders: (1,5000),(1,2000),(2,4000),(3,9000)
Sample Output
Pune: Aarav 7000; Mumbai: Rohan 9000
Use window function partitioned by city.
WITH totals AS (
SELECT c.city, c.name, SUM(o.total) AS spend
FROM customers c JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.city, c.name
),
ranked AS (
SELECT city, name, spend,
ROW_NUMBER() OVER (PARTITION BY city ORDER BY spend DESC) AS rn
FROM totals
)
SELECT city, name, spend FROM ranked WHERE rn = 1;Challenge 2: Department Salary Band
EasyFor each department, return min, avg, and max salary.
Sample Input
employees with dept and salary.
Sample Output
ENG 50000 75000 110000 etc.
Single GROUP BY.
SELECT dept,
MIN(salary) AS min_sal,
ROUND(AVG(salary), 2) AS avg_sal,
MAX(salary) AS max_sal
FROM employees
GROUP BY dept;Challenge 3: Email Domain Popularity
EasyGiven
users(id, email), return the top 3 email domains by user count.Sample Input
emails like 'aarav@gmail.com', 'priya@yahoo.com', 'rohan@gmail.com'...
Sample Output
gmail.com 2, yahoo.com 1
Use SUBSTRING_INDEX on '@'.
SELECT SUBSTRING_INDEX(email, '@', -1) AS domain, COUNT(*) AS n
FROM users
GROUP BY domain
ORDER BY n DESC
LIMIT 3;Challenge 4: Active Users Last 30 Days
MediumReturn distinct users who logged in in the last 30 days, along with their login count.
Sample Input
logins table with user_id and login_date.
Sample Output
user_id, login_count
Use DATE_SUB.
SELECT user_id, COUNT(*) AS login_count
FROM logins
WHERE login_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY user_id
ORDER BY login_count DESC;Challenge 5: Manager With Most Direct Reports
MediumFind the manager who directly manages the largest number of employees.
Sample Input
employees(id, name, manager_id).
Sample Output
Aarav manages 3 reports.
Self-join; GROUP BY manager; ORDER BY COUNT DESC; LIMIT 1.
SELECT m.id AS manager_id, m.name AS manager_name, COUNT(*) AS n_reports
FROM employees e
JOIN employees m ON m.id = e.manager_id
GROUP BY m.id, m.name
ORDER BY n_reports DESC
LIMIT 1;Challenge 6: New vs Returning Customers Per Month
MediumFor each month in 2025, count how many orders came from first-time customers vs returning customers. A customer is 'new' in the month of their very first order.
Sample Input
orders(customer_id, order_date).
Sample Output
month | new_orders | returning_orders
Compute each customer's first-order month, then classify.
WITH first_orders AS (
SELECT customer_id, DATE_FORMAT(MIN(order_date), '%Y-%m') AS first_month
FROM orders
GROUP BY customer_id
)
SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month,
SUM(CASE WHEN DATE_FORMAT(o.order_date,'%Y-%m') = f.first_month THEN 1 ELSE 0 END) AS new_orders,
SUM(CASE WHEN DATE_FORMAT(o.order_date,'%Y-%m') > f.first_month THEN 1 ELSE 0 END) AS returning_orders
FROM orders o
JOIN first_orders f ON f.customer_id = o.customer_id
WHERE YEAR(o.order_date) = 2025
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
ORDER BY month;Challenge 7: Products Whose Revenue Grew Month-over-Month
HardFor each product, return the months where its revenue grew vs the previous month (strictly). Output product, month, growth_pct.
Sample Input
order_items + orders joined on order_id, grouped by product and month.
Sample Output
product | month | growth_pct
Use LAG with PARTITION BY product.
WITH monthly AS (
SELECT oi.product_id,
DATE_FORMAT(o.order_date, '%Y-%m') AS month,
SUM(oi.qty * oi.unit_price) AS rev
FROM order_items oi JOIN orders o ON o.id = oi.order_id
GROUP BY oi.product_id, DATE_FORMAT(o.order_date, '%Y-%m')
),
lagged AS (
SELECT product_id, month, rev,
LAG(rev) OVER (PARTITION BY product_id ORDER BY month) AS prev_rev
FROM monthly
)
SELECT product_id, month,
ROUND((rev - prev_rev) * 100.0 / prev_rev, 2) AS growth_pct
FROM lagged
WHERE prev_rev IS NOT NULL AND rev > prev_rev
ORDER BY product_id, month;Challenge 8: Session Detection: 30-Minute Gap Rule
HardGiven
events(user_id, event_ts), assign each event a session number such that a gap of more than 30 minutes between consecutive events starts a new session.Sample Input
events for a user at 10:00, 10:05, 11:00, 11:10, 12:00
Sample Output
10:00 -> session 1; 10:05 -> session 1; 11:00 -> session 2; 11:10 -> session 2; 12:00 -> session 3.
Use LAG + cumulative SUM of a 'new-session' flag.
WITH flagged AS (
SELECT user_id, event_ts,
CASE WHEN TIMESTAMPDIFF(MINUTE,
LAG(event_ts) OVER (PARTITION BY user_id ORDER BY event_ts),
event_ts) > 30
THEN 1 ELSE 0 END AS new_session
FROM events
)
SELECT user_id, event_ts,
1 + SUM(new_session) OVER (
PARTITION BY user_id ORDER BY event_ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_id
FROM flagged
ORDER BY user_id, event_ts;Need to Review the Concepts?
Go back to the detailed notes for this chapter.
Read Chapter NotesWant to master SQL and databases with a mentor?
Explore our MySQL Masterclass