Skip to content

SQL Practice Set for a 3–5 Year Developer

If you have 3–5 years of experience, you should already be comfortable with:

  • joins
  • aggregations
  • subqueries
  • CTEs
  • window functions
  • indexing concepts
  • query optimization basics
  • transactional thinking

If you struggle with more than half of these without Googling syntax every 2 minutes, your SQL depth is weaker than your experience level suggests.

Below are 25 interview-grade SQL problems that separate “CRUD developers” from engineers who actually understand data systems.


Sample Schema

Assume these tables exist:

Customers(
    customer_id INT,
    name VARCHAR(100),
    city VARCHAR(100),
    signup_date DATE
)

Orders(
    order_id INT,
    customer_id INT,
    order_date DATE,
    amount DECIMAL(10,2),
    status VARCHAR(20)
)

Employees(
    emp_id INT,
    emp_name VARCHAR(100),
    department_id INT,
    salary DECIMAL(10,2),
    manager_id INT,
    joining_date DATE
)

Departments(
    department_id INT,
    department_name VARCHAR(100)
)

Products(
    product_id INT,
    product_name VARCHAR(100),
    category VARCHAR(50),
    price DECIMAL(10,2)
)

Order_Items(
    order_item_id INT,
    order_id INT,
    product_id INT,
    quantity INT
)

1. Find Top 5 Customers by Total Purchase

SELECT
    c.customer_id,
    c.name,
    SUM(o.amount) AS total_purchase
FROM Customers c
JOIN Orders o
    ON c.customer_id = o.customer_id
WHERE o.status = 'Completed'
GROUP BY c.customer_id, c.name
ORDER BY total_purchase DESC
LIMIT 5;

2. Find Employees Earning More Than Their Manager

SELECT
    e.emp_name AS employee,
    m.emp_name AS manager,
    e.salary,
    m.salary AS manager_salary
FROM Employees e
JOIN Employees m
    ON e.manager_id = m.emp_id
WHERE e.salary > m.salary;

3. Find Duplicate Customers Based on Name and City

SELECT
    name,
    city,
    COUNT(*) AS duplicate_count
FROM Customers
GROUP BY name, city
HAVING COUNT(*) > 1;

4. Get Second Highest Salary

SELECT MAX(salary) AS second_highest_salary
FROM Employees
WHERE salary < (
    SELECT MAX(salary)
    FROM Employees
);

5. Find Department-wise Highest Paid Employee

SELECT *
FROM (
    SELECT
        e.emp_name,
        d.department_name,
        e.salary,
        RANK() OVER (
            PARTITION BY e.department_id
            ORDER BY e.salary DESC
        ) AS rnk
    FROM Employees e
    JOIN Departments d
        ON e.department_id = d.department_id
) t
WHERE rnk = 1;

6. Find Customers Who Never Ordered

SELECT
    c.customer_id,
    c.name
FROM Customers c
LEFT JOIN Orders o
    ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;

7. Monthly Revenue Report

SELECT
    TO_CHAR(order_date, 'YYYY-MM') AS month,
    SUM(amount) AS revenue
FROM Orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM')
ORDER BY month;

8. Find Running Total of Orders

SELECT
    order_id,
    order_date,
    amount,
    SUM(amount) OVER (
        ORDER BY order_date
    ) AS running_total
FROM Orders;

9. Find Customers with More Than 3 Orders

SELECT
    customer_id,
    COUNT(*) AS total_orders
FROM Orders
GROUP BY customer_id
HAVING COUNT(*) > 3;

10. Find Products Never Ordered

SELECT
    p.product_id,
    p.product_name
FROM Products p
LEFT JOIN Order_Items oi
    ON p.product_id = oi.product_id
WHERE oi.product_id IS NULL;

11. Find Order with Maximum Amount Per Customer

SELECT *
FROM (
    SELECT
        order_id,
        customer_id,
        amount,
        RANK() OVER (
            PARTITION BY customer_id
            ORDER BY amount DESC
        ) AS rnk
    FROM Orders
) t
WHERE rnk = 1;

12. Find Employees Joined in Last 6 Months

SELECT *
FROM Employees
WHERE joining_date >= CURRENT_DATE - INTERVAL '6 months';

13. Count Orders by Status

SELECT
    status,
    COUNT(*) AS total_orders
FROM Orders
GROUP BY status;

14. Find Average Salary Department-wise

SELECT
    d.department_name,
    AVG(e.salary) AS avg_salary
FROM Employees e
JOIN Departments d
    ON e.department_id = d.department_id
GROUP BY d.department_name;

15. Find Consecutive Login Days (Advanced Pattern)

Assume:

User_Logins(
    user_id INT,
    login_date DATE
)
SELECT
    user_id,
    login_date,
    login_date - (ROW_NUMBER() OVER (
        PARTITION BY user_id
        ORDER BY login_date
    )::int * INTERVAL '1 day') AS grp
FROM User_Logins;

This is a classic gaps-and-islands problem. If you don't know this pattern, learn it.


16. Find Percentage Contribution of Each Product

SELECT
    p.product_name,
    SUM(oi.quantity * p.price) AS revenue,
    ROUND(
        100 * SUM(oi.quantity * p.price) /
        SUM(SUM(oi.quantity * p.price)) OVER (),
        2
    ) AS contribution_pct
FROM Products p
JOIN Order_Items oi
    ON p.product_id = oi.product_id
GROUP BY p.product_name;

17. Delete Duplicate Rows

DELETE FROM Customers c1
USING Customers c2
WHERE c1.name = c2.name
  AND c1.city = c2.city
  AND c1.customer_id > c2.customer_id;

18. Find Nth Highest Salary

SELECT DISTINCT salary
FROM Employees e1
WHERE 3 = (
    SELECT COUNT(DISTINCT salary)
    FROM Employees e2
    WHERE e2.salary >= e1.salary
);

19. Pivot Order Status Counts

SELECT
    customer_id,
    SUM(CASE WHEN status = 'Completed' THEN 1 ELSE 0 END) completed_orders,
    SUM(CASE WHEN status = 'Pending' THEN 1 ELSE 0 END) pending_orders,
    SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) cancelled_orders
FROM Orders
GROUP BY customer_id;

20. Find Latest Order per Customer

SELECT *
FROM (
    SELECT
        order_id,
        customer_id,
        order_date,
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY order_date DESC
        ) AS rn
    FROM Orders
) t
WHERE rn = 1;

21. Find Customers with Orders on Consecutive Days

SELECT DISTINCT o1.customer_id
FROM Orders o1
JOIN Orders o2
    ON o1.customer_id = o2.customer_id
    AND (o2.order_date - o1.order_date) = 1;

22. Calculate Employee Salary Rank Globally

SELECT
    emp_name,
    salary,
    DENSE_RANK() OVER (
        ORDER BY salary DESC
    ) AS salary_rank
FROM Employees;

23. Find Median Salary

WITH Ranked AS (
    SELECT
        salary,
        ROW_NUMBER() OVER (ORDER BY salary) AS rn,
        COUNT(*) OVER () AS total_count
    FROM Employees
)
SELECT AVG(salary) AS median_salary
FROM Ranked
WHERE rn IN (
    FLOOR((total_count + 1) / 2),
    FLOOR((total_count + 2) / 2)
);

24. Detect Missing IDs

SELECT t1.order_id + 1 AS missing_id
FROM Orders t1
LEFT JOIN Orders t2
    ON t1.order_id + 1 = t2.order_id
WHERE t2.order_id IS NULL;

25. Find Customers Whose Spending Increased Every Month

This is not trivial. It tests analytical thinking.

WITH monthly_spend AS (
    SELECT
        customer_id,
        TO_CHAR(order_date, 'YYYY-MM') AS month,
        SUM(amount) AS total_spend
    FROM Orders
    GROUP BY customer_id, TO_CHAR(order_date, 'YYYY-MM')
),
ranked AS (
    SELECT
        customer_id,
        month,
        total_spend,
        LAG(total_spend) OVER (
            PARTITION BY customer_id
            ORDER BY month
        ) AS prev_spend
    FROM monthly_spend
)
SELECT DISTINCT customer_id
FROM ranked
WHERE total_spend > prev_spend;

SQL Optimization Scenarios + EXISTS vs IN vs JOIN

Most developers claim they “know SQL optimization” because they added an index once and query time dropped.

That’s not optimization. That’s accidental success.

Real SQL optimization means:

  • understanding execution plans
  • minimizing scanned rows
  • reducing sort/hash operations
  • controlling join cardinality
  • avoiding unnecessary materialization
  • designing queries for scale, not sample data

Below are practical scenarios you should be able to reason through at a 3–5 year level.


PART 1 — SQL Optimization Scenarios


Scenario 1 — Query Suddenly Becomes Slow

Problem

```sql id="e0rv98" SELECT * FROM Orders WHERE customer_id = 1001;

Used to run in 50ms.
Now takes 8 seconds.

---

## What weak developers do

* Restart DB
* Increase server size
* Add random indexes
* Blame network

---

## What good engineers investigate

### 1. Check Execution Plan

```sql id="qvq4jg"
EXPLAIN ANALYZE
SELECT *
FROM Orders
WHERE customer_id = 1001;

Look for:

  • Full Table Scan
  • Index Scan
  • Rows examined
  • Cost
  • Hash Join
  • Temporary tables
  • Filesort

2. Check Missing Index

```sql id="qmgjlwm" CREATE INDEX idx_orders_customer ON Orders(customer_id);

---

### 3. Check Selectivity

If:

* 95% rows have same customer_id
* index becomes useless

Indexes help when filtering is selective.

---

# Scenario 2 — Composite Index Order Mistake

## Query

```sql id="bl9hmv"
SELECT *
FROM Orders
WHERE customer_id = 101
AND order_date = '2026-01-10';

Developer creates:

```sql id="m7p81p" CREATE INDEX idx_wrong ON Orders(order_date, customer_id);

---

## Problem

Index order matters.

Correct index depends on:

* filtering pattern
* cardinality
* leading column usage

Better:

```sql id="hjlwmn"
CREATE INDEX idx_correct
ON Orders(customer_id, order_date);


Rule

Composite indexes work left-to-right.

Index:

```sql id="54ew3y" (a, b, c)

Works for:

* a
* a,b
* a,b,c

Not efficiently for:

* b,c
* c only

---

# Scenario 3 — SELECT * Disaster

## Bad

```sql id="0g6k9k"
SELECT *
FROM Orders;

On a 200-column table.


Why it’s bad

  • unnecessary IO
  • network overhead
  • memory usage
  • prevents covering indexes

Better

```sql id="uwf4xy" SELECT order_id, customer_id, amount FROM Orders;

---

# Scenario 4 — Function on Indexed Column

## Bad

```sql id="8wby9r"
SELECT *
FROM Orders
WHERE EXTRACT(YEAR FROM order_date) = 2025;


Why bad

Function prevents index usage.

DB must compute YEAR() for every row.


Better

```sql id="g1t12q" SELECT * FROM Orders WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01';

This is called a SARGABLE query.

If you don't know SARGABLE, your optimization knowledge is incomplete.

---

# Scenario 5 — Pagination Disaster

## Bad

```sql id="9b41tm"
SELECT *
FROM Orders
ORDER BY order_id
LIMIT 20 OFFSET 100000;


Problem

DB scans/skips 100k rows first.


Better — Keyset Pagination

```sql id="o00sxj" SELECT * FROM Orders WHERE order_id > 100000 ORDER BY order_id LIMIT 20;

This scales far better.

---

# Scenario 6 — JOIN Explosion

## Problem

```sql id="udxuwx"
SELECT *
FROM Customers c
JOIN Orders o
ON c.customer_id = o.customer_id;

Customer has 5000 orders. Result duplicates customer rows 5000 times.


Symptoms

  • huge memory
  • slow sorting
  • duplicate amplification
  • incorrect aggregates

Better

Only join what you actually need.

```sql id="of7fgq" SELECT c.customer_id, c.name, COUNT(o.order_id) FROM Customers c LEFT JOIN Orders o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.name;

---

# Scenario 7 — OR Condition Killing Index

## Bad

```sql id="r19rnp"
SELECT *
FROM Orders
WHERE customer_id = 101
OR status = 'Pending';


Why bad

Optimizer may avoid indexes.


Sometimes Better

```sql id="9up1r2" SELECT * FROM Orders WHERE customer_id = 101

UNION

SELECT * FROM Orders WHERE status = 'Pending';

Depends on data distribution.

Always validate using execution plan.

---

# Scenario 8 — COUNT(*) on Huge Tables

## Problem

```sql id="u2bj4z"
SELECT COUNT(*)
FROM Orders;

On billion-row tables.


Better Approaches

  • approximate counts
  • materialized counters
  • partitioning
  • cached analytics tables

Production systems rarely run raw COUNT(*) repeatedly at huge scale.


Scenario 9 — NOT IN with NULL Bug

Dangerous

```sql id="zwjlwm" SELECT * FROM Customers WHERE customer_id NOT IN ( SELECT customer_id FROM Orders );

If subquery contains NULL:

* query may return ZERO rows

This destroys correctness.

---

## Better

```sql id="m9s3yo"
SELECT *
FROM Customers c
WHERE NOT EXISTS (
    SELECT 1
    FROM Orders o
    WHERE o.customer_id = c.customer_id
);


Scenario 10 — Unnecessary DISTINCT

Bad

```sql id="f5lc1x" SELECT DISTINCT c.name FROM Customers c JOIN Orders o ON c.customer_id = o.customer_id;

DISTINCT often hides bad joins.

---

## Correct Question

Why are duplicates happening?

Fix root cause instead of masking it.

---

# PART 2 — EXISTS vs IN vs JOIN

This is where many developers become cargo-cult programmers.

They memorize:

* EXISTS fast
* IN slow
* JOIN best

That thinking is shallow and often wrong.

You must understand semantics first.

---

# 1. EXISTS

## Purpose

Checks whether matching rows exist.

Returns TRUE/FALSE logically.

---

## Example

```sql id="pnv7xv"
SELECT *
FROM Customers c
WHERE EXISTS (
    SELECT 1
    FROM Orders o
    WHERE o.customer_id = c.customer_id
);


Best Use Cases

  • correlated checks
  • semi-joins
  • existence testing
  • large subqueries
  • NULL-safe logic

Key Characteristic

Stops searching after first match.

Very efficient in many cases.


2. IN

Purpose

Checks membership in a set.


Example

```sql id="0h7e95" SELECT * FROM Customers WHERE customer_id IN ( SELECT customer_id FROM Orders );

---

## Best Use Cases

* small subquery result
* static lists
* readable filtering

---

## Risk

`NOT IN` + NULL = dangerous behavior.

---

# 3. JOIN

## Purpose

Combines rows from tables.

---

## Example

```sql id="h2n2fd"
SELECT c.name, o.order_id
FROM Customers c
JOIN Orders o
ON c.customer_id = o.customer_id;


Important

JOIN changes row cardinality.

EXISTS does not.

This is a major conceptual difference.


Core Difference

Operation Returns Data? Changes Row Count? Best For
EXISTS No No existence check
IN No No membership check
JOIN Yes Yes fetching related data

Example of Wrong JOIN Usage

Developer writes:

```sql id="z3g17d" SELECT DISTINCT c.* FROM Customers c JOIN Orders o ON c.customer_id = o.customer_id;

This is actually existence logic.

Better:

```sql id="3ezjvx"
SELECT *
FROM Customers c
WHERE EXISTS (
    SELECT 1
    FROM Orders o
    WHERE o.customer_id = c.customer_id
);

Cleaner semantics. Often better execution.


Performance Reality

Modern optimizers may internally transform:

  • IN → EXISTS
  • EXISTS → SEMI JOIN

So blanket claims are nonsense.

You must evaluate:

  • data size
  • indexes
  • selectivity
  • execution plan
  • NULL behavior
  • cardinality

Rule of Thumb

Use EXISTS when:

  • checking existence
  • correlated logic
  • avoiding duplicates
  • large datasets

Use IN when:

  • small lists
  • readable filters
  • static membership

Use JOIN when:

  • retrieving columns from both tables
  • actual relational combination needed

Advanced Insight

This query:

```sql id="4f2u73" SELECT * FROM A WHERE EXISTS ( SELECT 1 FROM B WHERE B.id = A.id );

is conceptually a:

* SEMI JOIN

Whereas:

```sql id="5mqqo4"
SELECT *
FROM A
JOIN B
ON A.id = B.id;

is a:

  • FULL relational join