Insights into Company
← SQL topics

5-Year Interview: Ranking, Joins & Aggregation

Part 1 of the 5-year SQL interview series: salary ranking, per-department top-N, duplicates, anti-joins, latest records and month-over-month growth.

Practice data on customers, products, orders, order_items, employees and departments

1. Practice Database Structure

Every question in this series uses the same six tables, so you can create them once and run each query yourself.

customers

customers — educational schema
ColumnType / constraint
idBIGINT PK
nameVARCHAR(100)
emailVARCHAR(190)
cityVARCHAR(100)
created_atTIMESTAMP

Used for duplicate emails (Q7) and customers without orders (Q8).

products

products — educational schema
ColumnType / constraint
idBIGINT PK
nameVARCHAR(150)
categoryVARCHAR(80)
priceDECIMAL(12,2)
stockINT

Catalogue joined through order_items.

orders

orders — educational schema
ColumnType / constraint
idBIGINT PK
customer_idBIGINT FK customers.id
statusVARCHAR(20)
total_amountDECIMAL(12,2)
order_dateDATE

Latest order (Q9), top customer (Q10) and monthly sales (Q11–Q12).

order_items

order_items — educational schema
ColumnType / constraint
idBIGINT PK
order_idBIGINT FK orders.id
product_idBIGINT FK products.id
quantityINT
priceDECIMAL(12,2)

One row per product line in an order.

employees

employees — educational schema
ColumnType / constraint
idBIGINT PK
nameVARCHAR(100)
department_idBIGINT FK departments.id
salaryDECIMAL(12,2)
manager_idBIGINT FK employees.id
joining_dateDATE

Salary ranking and department questions (Q1–Q6).

departments

departments — educational schema
ColumnType / constraint
idBIGINT PK
nameVARCHAR(100)

Joined to employees on department_id.

2. Q1. Find the Second-Highest Salary

employees
idnamesalary
1Amit50000
2Rahul80000
3Priya70000
4Neha80000
5Raj60000

Find the second-highest distinct salary.

Subquery solution

SELECT MAX(salary) AS second_highest_salary
FROM employees
WHERE salary < (
    SELECT MAX(salary)
    FROM employees
);
Output
second_highest_salary
70000

Better: window function

SELECT salary
FROM (
    SELECT
        salary,
        DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
    FROM employees
) x
WHERE rnk = 2;
How DENSE_RANK() ranks the salaries
salaryrank
800001
800001
700002
600003
500004
Why DENSE_RANK() instead of ROW_NUMBER()?

80000 occurs twice. ROW_NUMBER() would give the two 80000 rows ranks 1 and 2, so "rank 2" would wrongly return 80000. DENSE_RANK() gives both rank 1 and the next distinct salary (70000) rank 2.

3. Q2. Find the Third-Highest Salary

Solution

SELECT salary
FROM (
    SELECT
        salary,
        DENSE_RANK() OVER (
            ORDER BY salary DESC
        ) AS rnk
    FROM employees
) x
WHERE rnk = 3;
Output
salary
60000
The interviewer asks for the 5th highest salary. What do you write?

Do not write five nested subqueries. Change the filter to rnk = 5 on the DENSE_RANK() query, or use another appropriate ranking technique.

4. Q3. Highest-Paid Employee in Each Department

employees
idnamedepartment_idsalary
1Amit150000
2Rahul180000
3Priya170000
4Neha290000
5Raj260000
6Karan3100000

Solution

SELECT *
FROM (
    SELECT
        e.*,
        ROW_NUMBER() OVER (
            PARTITION BY department_id
            ORDER BY salary DESC
        ) AS rn
    FROM employees e
) x
WHERE rn = 1;
Output
namedepartment_idsalary
Rahul180000
Neha290000
Karan3100000
What does PARTITION BY department_id mean?

Restart the numbering for every department, so each department gets its own rank 1.

5. Q4. Top 3 Employees from Each Department

One of the most commonly asked questions at 5 years of experience.

Solution

SELECT *
FROM (
    SELECT
        e.*,
        DENSE_RANK() OVER (
            PARTITION BY department_id
            ORDER BY salary DESC
        ) AS rnk
    FROM employees e
) x
WHERE rnk <= 3;
Why DENSE_RANK() here?

If two employees have the same salary they should receive the same rank, so ties are not cut off arbitrarily.

6. Q5. Employees Earning More Than the Company Average

Solution

SELECT
    id,
    name,
    salary
FROM employees
WHERE salary > (
    SELECT AVG(salary)
    FROM employees
);

Follow-up: the same query with a CTE

WITH company_average AS (
    SELECT AVG(salary) AS avg_salary
    FROM employees
)
SELECT
    e.id,
    e.name,
    e.salary
FROM employees e
CROSS JOIN company_average a
WHERE e.salary > a.avg_salary;

7. Q6. Employees Earning More Than Their Department Average

Solution

SELECT *
FROM (
    SELECT
        e.*,
        AVG(salary) OVER (
            PARTITION BY department_id
        ) AS department_avg
    FROM employees e
) x
WHERE salary > department_avg;
Department 1 example (average 66666.67)
namesalaryreturned
Amit50000No
Rahul80000Yes
Priya70000Yes

8. Q7. Find Duplicate Customer Emails

customers
idnameemail
1Amitamit@gmail.com
2Rajraj@gmail.com
3Nehaamit@gmail.com
4Karankaran@gmail.com

Solution

SELECT
    email,
    COUNT(*) AS total
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
Output
emailtotal
amit@gmail.com2

Follow-up: prevent duplicates at the database level

ALTER TABLE customers
ADD CONSTRAINT uk_customer_email UNIQUE (email);
Why a unique constraint instead of only application validation?

Two concurrent requests can both pass an application check; only the database constraint guarantees uniqueness.

9. Q8. Customers Who Never Placed an Order

Solution 1: LEFT JOIN

SELECT
    c.id,
    c.name
FROM customers c
LEFT JOIN orders o
    ON c.id = o.customer_id
WHERE o.id IS NULL;

Solution 2: NOT EXISTS

SELECT
    c.id,
    c.name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.customer_id = c.id
);
Which is faster, LEFT JOIN or NOT EXISTS?

There is no universal answer: the optimizer, indexes, engine, data distribution and query shape all matter. A good answer is "I would check the execution plan rather than assume one is always faster."

10. Q9. Latest Order of Every Customer

orders
idcustomer_idtotal_amountorder_date
110150002026-01-10
210170002026-02-15
310230002026-01-20
410290002026-03-10
510340002026-02-05

Solution

SELECT *
FROM (
    SELECT
        o.*,
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY order_date DESC, id DESC
        ) AS rn
    FROM orders o
) x
WHERE rn = 1;
Output
customer_idorder id
1012
1024
1035
Why add id DESC?

If two orders share exactly the same order_date, id DESC is a deterministic tie-breaker. Interviewers at this level look for that detail.

11. Q10. Customer with the Highest Total Purchase

Solution

SELECT
    customer_id,
    SUM(total_amount) AS total_purchase
FROM orders
GROUP BY customer_id
ORDER BY total_purchase DESC
LIMIT 1;

Better when ties matter

SELECT *
FROM (
    SELECT
        customer_id,
        SUM(total_amount) AS total_purchase,
        DENSE_RANK() OVER (
            ORDER BY SUM(total_amount) DESC
        ) AS rnk
    FROM orders
    GROUP BY customer_id
) x
WHERE rnk = 1;

If two customers both spent ₹5 lakh, the ranked version returns both, while LIMIT 1 silently drops one.

12. Q11 (Bonus). Monthly Sales

Solution

SELECT
    YEAR(order_date) AS year,
    MONTH(order_date) AS month,
    SUM(total_amount) AS total_sales
FROM orders
GROUP BY
    YEAR(order_date),
    MONTH(order_date)
ORDER BY
    year,
    month;
Result
yearmonthtotal_sales
20261250000
20262380000
20263450000

13. Q12 (Bonus). Month-over-Month Sales Growth

Step 1: monthly sales with the previous month

WITH monthly_sales AS (
    SELECT
        YEAR(order_date) AS year,
        MONTH(order_date) AS month,
        SUM(total_amount) AS sales
    FROM orders
    GROUP BY
        YEAR(order_date),
        MONTH(order_date)
)
SELECT
    year,
    month,
    sales,
    LAG(sales) OVER (
        ORDER BY year, month
    ) AS previous_month_sales
FROM monthly_sales;

Step 2: growth percentage

WITH monthly_sales AS (
    SELECT
        YEAR(order_date) AS year,
        MONTH(order_date) AS month,
        SUM(total_amount) AS sales
    FROM orders
    GROUP BY
        YEAR(order_date),
        MONTH(order_date)
),
sales_with_previous AS (
    SELECT
        year,
        month,
        sales,
        LAG(sales) OVER (
            ORDER BY year, month
        ) AS previous_sales
    FROM monthly_sales
)
SELECT
    year,
    month,
    sales,
    previous_sales,
    ROUND(
        ((sales - previous_sales) / previous_sales) * 100,
        2
    ) AS growth_percentage
FROM sales_with_previous;

14. 5-Year SQL Interview Progression

Series plan
PartTopicsQuestions
Part 1Ranking, joins, aggregation1–12
Part 2Complex joins, CTEs, subqueries13–20
Part 3Window functions21–30
Part 4Real-world business problems31–40
Part 5Transactions & concurrency41–45
Part 6Indexes & optimization46–50

MySQL locking reads · Laravel queues and transactions