SQL Query Fundamentals

SQL Logical Query Execution Order

SQL queries are logically processed in a specific sequence distinct from their written order:

1FROM & JOIN(Identify tables & merge)2ON(Join conditions)3WHERE(Filter raw rows)4GROUP BY(Aggregate rows)5HAVING(Filter groups)6SELECT(Choose columns)7DISTINCT(Remove duplicates)8ORDER BY(Sort result)9LIMIT / OFFSET(Paginate)
Figure: the nine logical steps of a SQL query, in the order the database processes them.

1. FROM & JOIN (Identify tables & merge) → 2. ON (Join conditions) → 3. WHERE (Filter raw rows) → 4. GROUP BY (Aggregate rows) → 5. HAVING (Filter groups) → 6. SELECT (Choose columns) → 7. DISTINCT (Remove duplicates) → 8. ORDER BY (Sort result) → 9. LIMIT / OFFSET (Paginate)

WHERE vs HAVING

This distinction appears frequently in interviews.

  • WHERE filters individual rows before grouping.
  • HAVING filters groups after aggregation.
Table rowsindividual rowsWHEREfilters individualrowsGROUP BYaggregate rowsinto groupsHAVINGfilters groupsafter aggregationResultbefore groupingafter aggregationrow levelgroup level
Figure: WHERE removes rows before grouping; HAVING removes groups after aggregation.

Learn to convert problem statements into SQL

When you read a SQL problem, don't start typing immediately. First, translate the question into a plan.

Use this six-step framework for almost every problem.

1Identify the outputwhich columns? one row per…2Identify source tablesneed to join?3Row-level conditionsdate, status… → WHERE4Is aggregation needed?counts, totals → GROUP BY5Ranking / comparisonsort, subquery, window fn6Check edge casesties, NULLs, duplicates
Figure: the six-step framework — plan before you type.
  1. Identify the output. What columns must the result contain? One row per employee, department, customer, or something else?
  2. Identify the source tables. Which table contains the information? Do you need to join multiple tables?
  3. Identify the row-level conditions. Does the question specify a date, salary threshold, status, or category? Think WHERE.
  4. Check whether aggregation is needed. Are you calculating counts, totals, averages, or one result per group? Think GROUP BY.
  5. Identify ranking or comparison requirements. Does it ask for the highest, second-highest, top three, latest, or previous row? Consider sorting, subqueries, or window functions.
  6. Check edge cases. What happens with ties, NULL values, duplicate rows, empty matches, or multiple records per person?

The problem-solving patterns you must master

These patterns matter more than memorizing hundreds of individual queries.

PatternTypical interview questionMain SQL tool
FilteringFind employees earning above 60,000WHERE
AggregationCount employees in each departmentGROUP BY
Group filteringFind departments with more than five employeesHAVING
Anti-joinFind customers who never placed an orderLEFT JOIN + IS NULL or NOT EXISTS
Self joinFind employees earning more than their managersSelf JOIN
Top NFind the three highest-paid employees in each departmentDENSE_RANK()
Second-best valueFind the second-highest salarySubquery or window function
Duplicate detectionFind emails appearing multiple timesGROUP BY + HAVING
Conditional aggregationCount successful and failed transactions separatelyCASE + aggregates
Consecutive recordsFind users active for three consecutive daysWindow functions or date logic
Previous/next rowCompare today's sales with yesterday'sLAG() / LEAD()
Running totalsCalculate cumulative sales by dateWindowed SUM()
Relational divisionFind students enrolled in every required courseNOT EXISTS or grouping
Date-range analysisFind monthly revenue or inactive usersDate functions and aggregation

SQL Joins — Complete Interview & Problem-Solving Notes

What is a JOIN in SQL?

A JOIN combines rows from two or more tables based on a specified condition.

Consider two tables.

Employees

emp_idnamedept_idsalary
1Aman1080000
2Priya2060000
3Ravi1090000
4NehaNULL50000
5Karan4070000

Departments

dept_iddept_name
10IT
20HR
30Finance

Here, Employees.dept_id refers to Departments.dept_id, where a matching department exists.

Notice that:

  • Aman and Ravi belong to IT.
  • Priya belongs to HR.
  • Neha has no department assigned.
  • Karan references department 40, which doesn't exist in Departments.
  • Finance has no employees.

These unmatched rows will help us understand the different joins.

EmployeesDepartmentsemp_idnamedept_id1Aman102Priya203Ravi104NehaNULL5Karan4010IT20HR30Financedept_iddept_name✘ no department assigned✘ dept 40 doesn't existno employees ✘
Figure: which Employees rows find a matching Departments row (solid) and which do not (dashed).

Types of SQL joins

1. INNER JOIN

Returns only rows that have matching records in both tables.

SQL
SELECT e.name, d.dept_name
FROM Employees e
INNER JOIN Departments d
    ON e.dept_id = d.dept_id;

Use when only matched records are required.

EmployeesDepartmentsABAman–IT · Priya–HR · Ravi–IT
Figure: INNER JOIN keeps only the overlap — rows that match in both tables.

2. LEFT JOIN

Returns every row from the left table and matching rows from the right table. Unmatched right-side columns become NULL.

SQL
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d
    ON e.dept_id = d.dept_id;

Use when all employees must appear, even without a matching department.

EmployeesDepartmentsABall 5 employees (Neha, Karan → NULL)
Figure: LEFT JOIN keeps every row of the left table (unmatched right columns become NULL).

3. RIGHT JOIN

Returns every row from the right table and matching rows from the left table.

SQL
SELECT e.name, d.dept_name
FROM Employees e
RIGHT JOIN Departments d
    ON e.dept_id = d.dept_id;

Use when every department must appear, including departments without employees.

EmployeesDepartmentsABall 3 departments + matches
Figure: RIGHT JOIN keeps every row of the right table (Finance included).

4. FULL OUTER JOIN

Returns matched rows plus unmatched rows from both tables.

SQL
SELECT e.name, d.dept_name
FROM Employees e
FULL OUTER JOIN Departments d
    ON e.dept_id = d.dept_id;

Supported by PostgreSQL and SQL Server, but not directly by MySQL.

EmployeesDepartmentsABmatches + Neha, Karan + Finance
Figure: FULL OUTER JOIN keeps matched rows plus unmatched rows from both tables.

5. CROSS JOIN

Returns every possible combination of rows from the two tables.

SQL
SELECT e.name, d.dept_name
FROM Employees e
CROSS JOIN Departments d;

If the first table has 5 rows and the second has 3, the result has 15 rows.

ITHRFinanceAmanAman–ITAman–HRAman–FinancePriyaPriya–ITPriya–HRPriya–FinanceRaviRavi–ITRavi–HRRavi–FinanceNehaNeha–ITNeha–HRNeha–FinanceKaranKaran–ITKaran–HRKaran–Finance5 rows × 3 rows = 15 rows
Figure: CROSS JOIN pairs every employee with every department: 5 × 3 = 15 rows.
Interview tip

JOIN without a qualifier generally means INNER JOIN.

Understand the results of each join

Let's examine which employees appear when joining the two tables.

INNER JOIN output

SQL
SELECT e.name, d.dept_name
FROM Employees e
INNER JOIN Departments d
    ON e.dept_id = d.dept_id;
namedept_name
AmanIT
PriyaHR
RaviIT

Neha and Karan are excluded because they have no matching department. Finance is excluded because it has no matching employee.

LEFT JOIN output

SQL
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d
    ON e.dept_id = d.dept_id;
namedept_name
AmanIT
PriyaHR
RaviIT
NehaNULL
KaranNULL

All five employees appear.

RIGHT JOIN output

SQL
SELECT e.name, d.dept_name
FROM Employees e
RIGHT JOIN Departments d
    ON e.dept_id = d.dept_id;
namedept_name
AmanIT
PriyaHR
RaviIT
NULLFinance

Every department appears, including Finance.

FULL OUTER JOIN output

The result includes all matches and all unmatched rows from both tables.

namedept_name
AmanIT
PriyaHR
RaviIT
NehaNULL
KaranNULL
NULLFinance

The output row order is not guaranteed without ORDER BY.

A. Find employees who don't belong to any existing department

Use LEFT JOIN followed by IS NULL.

EmployeesDepartmentsABleft-only: Neha, Karan
Figure: anti-join — LEFT JOIN … WHERE d.dept_id IS NULL keeps only the left-only region.
SQL
SELECT e.*
FROM Employees e
LEFT JOIN Departments d
    ON e.dept_id = d.dept_id
WHERE d.dept_id IS NULL;

Output: Neha and Karan.

This works because unmatched employees receive NULL values for the right table's columns.

B. Find departments with no employees

Reverse the table order.

EmployeesDepartmentsABright-only: Finance
Figure: with the tables reversed, the same pattern returns the right-only region.
SQL
SELECT d.*
FROM Departments d
LEFT JOIN Employees e
    ON d.dept_id = e.dept_id
WHERE e.emp_id IS NULL;

Output: Finance.

The general pattern:

SQL
SELECT A.*
FROM A
LEFT JOIN B
    ON A.key = B.key
WHERE B.key IS NULL;

This is commonly called an anti-join pattern.

Choose a column from B that is guaranteed to be non-NULL for real rows, typically its primary ke

Self JOIN

A self join joins a table with itself. It is useful when rows in the same table have relationships with one another.

Consider an Employee table:

emp_idnamemanager_id
1AmanNULL
2Priya1
3Ravi1
4Neha2

Here, manager_id references another employee's emp_id.

1 Amanmanager_id = NULL2 Priyamanager_id = 13 Ravimanager_id = 14 Nehamanager_id = 2m (manager)e (employee)
Figure: manager_id points back to another row of the same table (a self join).

Problem: Find each employee and their manager

SQL
SELECT
    e.name AS employee,
    m.name AS manager
FROM Employee e
LEFT JOIN Employee m
    ON e.manager_id = m.emp_id;
Output:
employeemanager
AmanNULL
PriyaAman
RaviAman
NehaPriya

We use two aliases:

  • e represents the employee.
  • m represents the manager.

The LEFT JOIN keeps Aman even though he has no manager.

Common self-join interview questions

  • Find employees earning more than their managers.
  • Find employees reporting to the same manager.
  • Find pairs of users or products meeting a relationship condition.
  • Compare records from the same table.

Multiple JOINs

Real interview problems often require three or more tables.

Consider these tables:

  • Customers(customer_id, name)
  • Orders(order_id, customer_id)
  • OrderItems(order_id, product_id, quantity)
  • Products(product_id, product_name, price)

Problem: Find the total spending of each customer

SQL
SELECT
    c.customer_id,
    c.name,
    SUM(oi.quantity * p.price) AS total_spending
FROM Customers c
JOIN Orders o
    ON c.customer_id = o.customer_id
JOIN OrderItems oi
    ON o.order_id = oi.order_id
JOIN Products p
    ON oi.product_id = p.product_id
GROUP BY
    c.customer_id,
    c.name;

How to approach multiple joins

  1. Identify the entity requested in the output: customers.
  2. Identify where the necessary information exists.
  3. Follow the relationships from customers to orders, order items, and products.
  4. Calculate the spending for each order item.
  5. Group by customer and sum the item totals.

This query returns customers who have qualifying order records. To include customers who have never ordered, use appropriate LEFT JOINs and handle NULL totals with COALESCE().

Customerscustomer_idnamecustomerOrdersorder_idcustomer_idorderOrderItemsorder_idproduct_idquantityproductProductsproduct_idproduct_namepricerequested entityprice × quantity
Figure: follow the relationships from the requested entity (customers) to the data you need (prices).

JOIN condition: ON vs. WHERE

This is a common interview trap, particularly with outer joins.

Suppose we want all employees, but we only want department information when the department is IT.

Correct for this requirement:

SQL
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d
    ON e.dept_id = d.dept_id
   AND d.dept_name = 'IT';

All employees remain in the result. Employees who aren't matched to IT have NULL department information.

Different behavior:

SQL
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d
    ON e.dept_id = d.dept_id
WHERE d.dept_name = 'IT';

The WHERE clause removes rows where the department name is NULL, so employees without a matching IT department are removed. The query effectively behaves like an inner join for this condition.

Remember
  • ON determines which rows match.
  • WHERE filters the result after the join's matching and outer-row preservation.
ON e.dept_id = d.dept_id AND d.dept_name = 'IT'LEFT JOINkeeps all employeesON decideswhich rows matchAll employees remainnon-IT → NULL deptLEFT JOIN … ON e.dept_id = d.dept_id + WHERE d.dept_name = 'IT'LEFT JOINkeeps all employeesWHERE d.dept_name = 'IT'filters after the joinNULL rows removedacts like an inner join
Figure: a filter in ON only decides which rows match; a filter in WHERE removes rows after the join.

JOIN vs. subquery vs. EXISTS

There are multiple ways to solve the same problem.

Problem: Find customers who have placed at least one order

Using JOIN:

SQL
SELECT DISTINCT c.customer_id, c.name
FROM Customers c
JOIN Orders o
    ON c.customer_id = o.customer_id;

DISTINCT avoids repeated customers when they have multiple orders.

Using EXISTS:

SQL
SELECT c.customer_id, c.name
FROM Customers c
WHERE EXISTS (
    SELECT 1
    FROM Orders o
    WHERE o.customer_id = c.customer_id
);

EXISTS checks whether at least one matching order exists without multiplying the customer row in the output.

For questions that ask whether a matching record exists, EXISTS is often a clean choice. For questions that require columns from both tables, a join is usually more natural.

Important JOIN edge cases

Duplicate matches can multiply rows

Suppose one customer has three orders. Joining the customer with orders returns three rows for that customer.

This is expected behavior, not a bug.

Customer1 rowOrder 1row 1Order 2row 2Order 3row 3JOIN →
Figure: one-to-many joins repeat the “one” side — check what one row represents before aggregating.

Before writing an aggregate query, ask: What does one row represent after the join?

If you sum values after joining tables with one-to-many or many-to-many relationships, you may accidentally double-count data.

NULL values don't match with =

SQL
ON A.key = B.key

Two NULL values do not satisfy this condition in standard SQL. Use the database's NULL-safe comparison features only when the problem requires NULLs to match.

Prefer explicit JOIN syntax

Use:

SQL
SELECT *
FROM A
JOIN B ON A.id = B.id;

Avoid comma-separated table lists with conditions in WHERE for ordinary join problems. Explicit joins make relationships and outer-join behavior easier to understand.

SQL Views

What is a View in SQL?

A View is a virtual table created using the result of a SQL query. It does not normally store the actual result data; instead, it stores the query definition and retrieves data from the underlying tables when accessed.

A view can contain selected columns, filtered rows, joins, aggregate functions, and other SQL operations.

Employeesbase table(data is stored)IT_EmployeesVIEW = virtual tablestores the query onlySELECT *FROM IT_Employeesreadsquerieswhen accessed
Figure: a view stores only the query definition; rows are fetched from the underlying tables when it is queried.

Syntax

SQL
CREATE VIEW view_name AS
SELECT column1, column2, ...
FROM table_name
WHERE condition;

Example: Suppose we have an Employees table.

emp_idnamedepartmentsalary
1AmanIT80000
2PriyaHR60000
3RaviIT90000
4NehaFinance50000

Create a view containing only IT employees:

SQL
CREATE VIEW IT_Employees AS
SELECT emp_id, name, salary
FROM Employees
WHERE department = 'IT';

Query the view just like a table:

SQL
SELECT *
FROM IT_Employees;
Output:
emp_idnamesalary
1Aman80000
3Ravi90000

The view exposes only the selected columns and rows.

Why Do We Use Views?

Views are useful for:

  1. Security: Restrict access to sensitive columns such as salaries.
  2. Simplicity: Hide complex joins and calculations behind a simple query.
  3. Reusability: Write a complex query once and reuse it.
  4. Logical data independence: Provide a stable interface even when the underlying schema changes, provided the view remains valid.
  5. Consistent reporting: Allow multiple users to use the same predefined query logic.

Example: Hide sensitive salary information

SQL
CREATE VIEW Employee_Public AS
SELECT emp_id, name, department
FROM Employees;

Users who have access only to this view can query employee details without being given direct access to the salary column.

SQL Index Commands You Must Know

A. Create an index

Syntax

SQL
CREATE INDEX index_name
ON table_name (column_name);

Example:

SQL
CREATE INDEX idx_employee_name
ON Employees (name);

Now queries filtering by name may benefit from the index:

SQL
SELECT *
FROM Employees
WHERE name = 'Aman';

B. Create a unique index

A unique index prevents duplicate indexed values.

SQL
CREATE UNIQUE INDEX idx_employee_email
ON Employees (email);

This is useful when every employee must have a unique email address.

Note that how NULL values are handled by unique indexes depends on the database system. A UNIQUE index is not identical to a PRIMARY KEY.

C. Create a composite index

A composite index contains multiple columns.

SQL
CREATE INDEX idx_dept_salary
ON Employees (department, salary);

This index is particularly useful for queries such as:

SQL
SELECT *
FROM Employees
WHERE department = 'IT'
  AND salary > 70000;

The column order matters. An index on (department, salary) is generally most useful when a query filters or sorts by department, or filters by department and then salary.

It may not help as effectively for a query that filters only by salary.

D. Drop an index

The syntax varies by database.

MySQL:

SQL
DROP INDEX idx_employee_name
ON Employees;

PostgreSQL:

SQL
DROP INDEX idx_employee_name;

SQL Server:

SQL
DROP INDEX idx_employee_name
ON Employees;

E. Show indexes

MySQL:

SQL
SHOW INDEXES FROM Employees;

PostgreSQL — inspect index definitions:

SQL
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'employees';

SQL Server:

SQL
EXEC sp_helpindex 'Employees';

F. Check whether a query uses an index

MySQL:

SQL
EXPLAIN
SELECT *
FROM Employees
WHERE emp_id = 50000;

PostgreSQL:

SQL
EXPLAIN
SELECT *
FROM Employees
WHERE emp_id = 50000;

PostgreSQL can also execute and measure the query:

SQL
EXPLAIN ANALYZE
SELECT *
FROM Employees
WHERE emp_id = 50000;

EXPLAIN helps you understand the execution plan. EXPLAIN ANALYZE actually runs the query in PostgreSQL, so use it thoughtfully for expensive querie

Types of Indexes in DBMS

A. Primary Key Index

A primary key uniquely identifies each row.

SQL
CREATE TABLE Employees (
    emp_id INT PRIMARY KEY,
    name VARCHAR(100),
    salary DECIMAL(10, 2)
);

Most relational databases automatically create an index to enforce the primary key constraint.

Interview point

A primary key is a constraint; an index is a data structure. The database commonly implements the primary key using a unique index, but the concepts are different.

B. Unique Index

SQL
CREATE UNIQUE INDEX idx_email
ON Employees (email);
  • Prevents duplicate indexed values.
  • Can improve searches using email.
  • Does not necessarily prohibit multiple NULL values; this depends on the DBMS.

C. Non-Unique Index

SQL
CREATE INDEX idx_department
ON Employees (department);

Multiple employees can belong to the same department. The index helps locate rows for that department.

D. Clustered Index

A clustered index determines how table rows are organized according to the index key in database systems that support this organization.

For example, SQL Server supports clustered indexes. InnoDB in MySQL organizes table data using the primary key as its clustered index by default when a primary key exists.

SQL
-- SQL Server example
CREATE CLUSTERED INDEX idx_emp_id
ON Employees (emp_id);

Important points:

  • A table can have at most one clustered index because there can be only one physical organization of its rows.
  • The clustered index key can affect the performance of range queries.
  • Clustered-index behavior is database-specific.

E. Non-Clustered Index

A non-clustered index stores index keys and references to the corresponding table rows.

SQL
-- SQL Server example
CREATE NONCLUSTERED INDEX idx_salary
ON Employees (salary);

A table can have multiple non-clustered indexes, subject to DBMS limits.

Interview comparison

Clustered indexNon-clustered index
Determines table-row organization in supporting systemsSeparate index structure references table rows
At most one per tableMultiple can exist
Useful for suitable range and lookup queriesUseful for filtering, sorting, and lookups
Changes row organizationAdds a separate index structure

Do not assume that every DBMS implements clustered and non-clustered indexes identically.

Clustered indexdetermines how rows are organizedemp_id 1 | row dataemp_id 2 | row dataemp_id 3 | row dataemp_id 4 | row dataindex key = physical row orderat most ONE per tableNon-clustered indexseparate structure, references rowssalary 30krow (emp 4)salary 50krow (emp 2)salary 80krow (emp 1)index keystable rowsadds a separate index structuremultiple can exist
Figure: a clustered index organizes the rows themselves; a non-clustered index is a separate structure that points to rows.

F. Composite Index

A composite index is built on two or more columns.

SQL
CREATE INDEX idx_dept_salary
ON Employees (department, salary);

This is one of the most important index concepts for SQL interviews.

Suppose the index is:

SQL
CREATE INDEX idx_abc
ON Employees (A, B, C);

The index is generally useful for these leading-column combinations:

Query conditionExpected usefulness
WHERE A = 10Good
WHERE A = 10 AND B = 20Good
WHERE A = 10 AND B = 20 AND C = 30Good
WHERE A = 10 AND C = 30Can use the leading A portion; C may be less useful for index navigation
WHERE B = 20Usually cannot efficiently seek using the leftmost prefix
WHERE C = 30Usually cannot efficiently seek using the leftmost prefix

This is called the leftmost-prefix principle. Exact optimizer behavior depends on the database and index type.

Index (A, B, C)ABCWHERE A = 10✓––GoodWHERE A = 10 AND B = 20✓✓–GoodWHERE A = 10 AND B = 20 AND C = 30✓✓✓GoodWHERE A = 10 AND C = 30✓–lessA used; C less usefulWHERE B = 20–less–Usually cannot seekWHERE C = 30––lessUsually cannot seek✓ used for index navigation · less = filtered but less useful · – not filtered
Figure: an index on (A, B, C) is navigated from the leftmost column onwards.

G. Composite Index: Column Order Matters

Consider these two indexes:

SQL
CREATE INDEX idx_dept_salary
ON Employees (department, salary);
CREATE INDEX idx_salary_dept
ON Employees (salary, department);

They are not equivalent.

For this query:

SQL
SELECT *
FROM Employees
WHERE department = 'IT'
  AND salary > 70000;

The index beginning with department is often a natural choice because it can narrow down the department before applying the salary range.

For this query:

SQL
SELECT *
FROM Employees
WHERE salary > 70000;

An index beginning with salary may be more suitable.

How to decide the order:

  1. Identify the actual query patterns.
  2. Consider equality filters before range filters as a useful starting heuristic.
  3. Check whether the index supports ORDER BY or GROUP BY.
  4. Verify the execution plan instead of blindly following a rule.

Column selectivity matters, but it is not the only factor determining index order.

Common Table Expression (CTE)

Overview

A Common Table Expression (CTE) is a temporary, named result set that you define using the WITH clause and use within a single SQL statement.

Why do we need CTEs?

Consider the question:

Find employees whose salary is greater than the average salary of all employees.

Suppose we have an Employee table:

idnamedepartmentsalary
1AmanIT80000
2PriyaHR50000
3RaviIT90000
4NehaHR60000
5KaranIT70000

We could solve it using a subquery:

SQL
SELECT name, salary
FROM Employee
WHERE salary > (
    SELECT AVG(salary)
    FROM Employee
);

We can also write it using a CTE:

SQL
WITH AverageSalary AS (
    SELECT AVG(salary) AS avg_salary
    FROM Employee
)
SELECT e.name, e.salary
FROM Employee e
CROSS JOIN AverageSalary a
WHERE e.salary > a.avg_salary;

Both queries solve the same problem.

The CTE gives the average calculation a meaningful name, AverageSalary, which makes the overall query easier to read and extend.

CTE syntax

SQL
WITH cte_name AS (
    SELECT column1, column2
    FROM table_name
    WHERE condition
)
SELECT *
FROM cte_name;

Understand each part:

ComponentMeaning
WITHStarts the CTE definition
cte_nameName of the temporary result set
AS (...)Contains the query defining the result set
Main queryReads from the CTE

Important points:

  • A CTE exists only for the SQL statement in which it is defined.
  • You can reference it in the main query and, where permitted, in other CTE definitions within the same WITH clause.
  • A CTE is not a permanent database table.
  • A CTE does not automatically store its result in physical memory or a temporary table. The database optimizer decides how to execute it.
WITHcte_nameAS (SELECT column1, column2FROM table_nameWHERE condition)temporary, named result setSELECT *FROM cte_name;Main queryreadsexists only for this one SQL statement — not a permanent table
Figure: the WITH clause names a temporary result set that only the main query of the same statement can read.

Simple CTE example

Problem: Find employees earning more than ₹70,000.

SQL
WITH HighSalaryEmployees AS (
    SELECT id, name, department, salary
    FROM Employee
    WHERE salary > 70000
)
SELECT *
FROM HighSalaryEmployees;

The query works in two logical steps:

  1. HighSalaryEmployees defines the rows that satisfy the salary condition.
  2. The main query retrieves those rows.

You can think of the CTE as a named intermediate result that helps organize your query.

Multiple CTEs in one query

You can define multiple CTEs in the same WITH clause by separating their definitions with commas.

Problem: Find departments whose average salary is greater than the company's overall average salary.

SQL
WITH DepartmentAverage AS (
    SELECT
        department,
        AVG(salary) AS avg_salary
    FROM Employee
    GROUP BY department
),
CompanyAverage AS (
    SELECT AVG(salary) AS company_avg
    FROM Employee
)
SELECT
    d.department,
    d.avg_salary
FROM DepartmentAverage d
CROSS JOIN CompanyAverage c
WHERE d.avg_salary > c.company_avg;

How to approach it

  • First CTE: calculate one average per department.
  • Second CTE: calculate one average for the entire company.
  • Main query: compare each department's average with the company average.

This is a useful pattern for questions involving multiple levels of aggregation.

DepartmentAverageone average per departmentCompanyAverageone average for the companyCROSS JOINcompare eachdepartment with companyResultdepartments abovecompany average
Figure: each CTE computes one level of aggregation; the main query compares them.

CTE with window functions

This is one of the most important combinations for SQL interviews.

Problem: Find the second-highest distinct salary in each department

SQL
WITH RankedEmployees AS (
    SELECT
        id,
        name,
        department,
        salary,
        DENSE_RANK() OVER (
            PARTITION BY department
            ORDER BY salary DESC
        ) AS salary_rank
    FROM Employee
)
SELECT
    name,
    department,
    salary
FROM RankedEmployees
WHERE salary_rank = 2;

SQL Window Functions

What are window functions?

A window function performs a calculation across a set of related rows while keeping each individual row in the output.

For example, suppose we have an Employee table:

namedepartmentsalary
AndrewFinance50000
BrianFinance50000
CharlesFinance20000
DanielSales30000
EthanSales20000

Suppose the question asks us to display every employee along with their department's average salary.

SQL
SELECT
    name,
    department,
    salary,
    AVG(salary) OVER (
        PARTITION BY department
    ) AS avg_salary
FROM Employee;
Output:
namedepartmentsalaryavg_salary
AndrewFinance5000040000
BrianFinance5000040000
CharlesFinance2000040000
DanielSales3000025000
EthanSales2000025000

Notice that every employee remains in the output. The average is calculated separately for each department and displayed alongside each employee.

Window functions vs. GROUP BY

This is a fundamental interview distinction.

Using GROUP BY:

SQL
SELECT department, AVG(salary) AS avg_salary
FROM Employee
GROUP BY department;

Result: one row per department.

Using a window function:

SQL
SELECT
    name,
    department,
    salary,
    AVG(salary) OVER (
        PARTITION BY department
    ) AS avg_salary
FROM Employee;

Result: one row per employee, with the department average added.

Employee (5 rows)AndrewFinance50000BrianFinance50000CharlesFinance20000DanielSales30000EthanSales20000GROUP BY department → 2 rowsFinanceavg 40000Salesavg 25000one row per departmentAVG(salary) OVER (PARTITION BY department) → 5 rowsAndrewFinance50000avg_salary 40000BrianFinance50000avg_salary 40000CharlesFinance20000avg_salary 40000DanielSales30000avg_salary 25000EthanSales20000avg_salary 25000
Figure: GROUP BY collapses rows into one per group; a window function keeps every row and adds the group value.
FeatureGROUP BYWindow function
Main purposeAggregate rows into groupsCalculate across related rows
Individual rows retainedUsually noYes
Can calculate department averagesYesYes
Can rank individual rowsNo, not by itselfYes
Can calculate running totalsNot directlyYes

General syntax

SQL
SELECT
    column1,
    window_function(column2) OVER (
        PARTITION BY column3
        ORDER BY column4
    ) AS result
FROM table_name;

The OVER() clause defines the window used by the function.

ComponentMeaning
window_function()The calculation or ranking function
OVER()Defines the window of rows
PARTITION BYDivides rows into independent groups
ORDER BYDefines the order within each window
AS resultGives the calculated column a name

Each part is optional in some situations. For example, you can use OVER() without either PARTITION BY or ORDER BY.

Understand the three window configurations

A. OVER() — Entire result set

SQL
COUNT(*) OVER ()

Counts all rows in the window and displays that count on every row.

B. PARTITION BY — Separate groups

SQL
AVG(salary) OVER (
    PARTITION BY department
)

Calculates a separate average for each department without removing employee rows.

C. ORDER BY — Ordered calculations

SQL
ROW_NUMBER() OVER (
    ORDER BY salary DESC
)

Numbers rows in descending salary order.

OVER()entire result setCOUNT(*) OVER () — same count on every rowPARTITION BY departmentseparate groupsAVG(salary) OVER (PARTITION BY …) per groupORDER BY salary DESCordered calculations150000250000330000420000520000610000ROW_NUMBER() OVER (ORDER BY salary DESC)
Figure: OVER() uses the whole result set, PARTITION BY splits it into independent groups, ORDER BY orders rows inside the window.

Types of window functions

For practical problem-solving, learn these categories.

CategoryFunctionsCommon use
AggregateSUM, AVG, COUNT, MIN, MAXGroup statistics, running totals
RankingROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, NTILETop N, ranking, percentiles
Value and offsetLAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUEPrevious/next row, comparisons
Frame-based calculationsAggregate functions with ROWS / RANGEMoving averages, running calculations

Aggregate window functions

These functions calculate a summary across a window while preserving the original rows.

A. SUM()

Calculate the total salary of each department.

SQL
SELECT
    name,
    department,
    salary,
    SUM(salary) OVER (
        PARTITION BY department
    ) AS department_total
FROM Employee;

Finance total:

50000+50000+20000=12000050000 + 50000 + 20000 = 120000

Sales total:

30000+20000=5000030000 + 20000 = 50000

Every employee in Finance receives 120000 in department_total, and every employee in Sales receives 50000.

B. AVG()

SQL
SELECT
    name,
    department,
    salary,
    AVG(salary) OVER (
        PARTITION BY department
    ) AS department_avg
FROM Employee;

Use this when a question asks for each employee's salary compared with the average salary of their department.

C. COUNT()

SQL
SELECT
    name,
    department,
    COUNT(*) OVER (
        PARTITION BY department
    ) AS department_employee_count
FROM Employee;

This returns 3 for every Finance employee and 2 for every Sales employee.

D. MIN() and MAX()

SQL
SELECT
    name,
    department,
    salary,
    MAX(salary) OVER (
        PARTITION BY department
    ) AS highest_salary,
    MIN(salary) OVER (
        PARTITION BY department
    ) AS lowest_salary
FROM Employee;

This helps answer questions such as:

  • Find each employee's salary and their department's highest salary.
  • Compare an employee's salary with the department minimum.
  • Identify employees who earn the maximum salary in their department.

To return only the highest-paid employees, use a subquery or CTE to calculate the window result and then filter it in the outer query.

Ranking functions

Consider salaries ordered from highest to lowest:

EmployeeSalary
Andrew50000
Brian50000
Charles20000
David10000

A. ROW_NUMBER()

Assigns a unique sequential number to every row.

SQL
SELECT
    name,
    salary,
    ROW_NUMBER() OVER (
        ORDER BY salary DESC, name ASC
    ) AS row_num
FROM Employee;
EmployeeSalaryRow number
Andrew500001
Brian500002
Charles200003
David100004

Even if two employees have the same salary, they get different row numbers. The extra name ordering makes the result deterministic for these distinct names.

Use it for: selecting exactly one row per group, deduplication, and selecting the latest record per customer.

B. RANK()

Rows with equal ordering values receive the same rank. Subsequent ranks skip numbers.

SQL
SELECT
    name,
    salary,
    RANK() OVER (
        ORDER BY salary DESC
    ) AS salary_rank
FROM Employee;
EmployeeSalaryRank
Andrew500001
Brian500001
Charles200003
David100004

Because two employees share rank 1, rank 2 is skipped.

Use it for: competition-style rankings where tied rows share a position and later positions have gaps.

C. DENSE_RANK()

Equal values receive the same rank, but there are no gaps.

SQL
SELECT
    name,
    salary,
    DENSE_RANK() OVER (
        ORDER BY salary DESC
    ) AS salary_rank
FROM Employee;
EmployeeSalaryDense rank
Andrew500001
Brian500001
Charles200002
David100003

Use it for: the second-highest distinct salary, third-highest distinct salary, or top three distinct salary levels.

Compare the results

SalaryROW_NUMBER()*RANK()DENSE_RANK()
50000111
50000211
20000332
10000443

*The row numbers shown assume a tie-breaker such as employee name. Without a complete ordering, the order among tied rows is not guaranteed.

Remember this rule
  • ROW_NUMBER() → unique number for each row.
  • RANK() → ties share rank; gaps appear.
  • DENSE_RANK() → ties share rank; no gaps.
EmployeeSalaryROW_NUMBER()RANK()DENSE_RANK()Andrew50000111Brian50000211Charles20000332David10000443gap: 2 skippedno gapsalways unique
Figure: ties share a rank in RANK() and DENSE_RANK(); only RANK() leaves gaps.

D. PERCENT_RANK()

Calculates a row's relative rank within its window.

PERCENT_RANK=RANK−1Number of rows−1\text{PERCENT\_RANK} = \frac{\text{RANK} - 1}{\text{Number of rows} - 1}

For a partition containing at least two rows:

  • The first rank is generally 0.
  • The last rank is 1.
  • Intermediate ranks fall between 0 and 1.
SQL
SELECT
    name,
    salary,
    PERCENT_RANK() OVER (
        ORDER BY salary DESC
    ) AS relative_rank
FROM Employee;

For a partition with only one row, PERCENT_RANK() returns 0.

Use it for: relative-ranking analysis. For many standard interview questions about top N rows or distinct salary levels, RANK() and DENSE_RANK() are more useful.

The most important technique: PARTITION BY + ranking

This combination appears repeatedly in SQL interviews.

Suppose we want to rank employees within each department, rather than across the entire company.

SQL
SELECT
    name,
    department,
    salary,
    DENSE_RANK() OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS dept_rank
FROM Employee;

The ranking restarts for every department.

For example:

NameDepartmentSalaryDepartment rank
AndrewFinance500001
BrianFinance500001
CharlesFinance200002
DanielSales300001
EthanSales200002

Without PARTITION BY department, the ranking would be calculated across all employees together.

Interview problem: Find the second-highest salary in each department

SQL
WITH RankedEmployees AS (
    SELECT
        name,
        department,
        salary,
        DENSE_RANK() OVER (
            PARTITION BY department
            ORDER BY salary DESC
        ) AS salary_rank
    FROM Employee
)
SELECT name, department, salary
FROM RankedEmployees
WHERE salary_rank = 2;

How to approach this problem:

  1. The phrase in each department tells us to use PARTITION BY department.
  2. The phrase second-highest distinct salary tells us to use DENSE_RANK().
  3. ORDER BY salary DESC places the highest salary first.
  4. The CTE calculates the ranking.
  5. The outer query filters for rank 2.

Why use a CTE? In standard SQL, a window function's result cannot be filtered directly in the same query block's WHERE clause. The CTE lets us calculate the rank first and filter it afterward.

Window frames: running totals and moving averages

One additional concept is essential for mastering window functions: the window frame.

A window frame defines which rows within the ordered window contribute to a calculation for the current row.

Running total

Suppose we have a Sales table:

sale_dateamount
2026-01-01100
2026-01-02200
2026-01-03150

Query:

SQL
SELECT
    sale_date,
    amount,
    SUM(amount) OVER (
        ORDER BY sale_date
        ROWS BETWEEN UNBOUNDED PRECEDING
                 AND CURRENT ROW
    ) AS running_total
FROM Sales;
Output:
DateAmountRunning total
2026-01-01100100
2026-01-02200300
2026-01-03150450

The frame begins at the first row and extends through the current row.

Moving average

SQL
SELECT
    sale_date,
    amount,
    AVG(amount) OVER (
        ORDER BY sale_date
        ROWS BETWEEN 1 PRECEDING
                 AND CURRENT ROW
    ) AS moving_avg
FROM Sales;

This calculates the average of the current row and the immediately preceding row, when one exists.

DateAmountMoving average
2026-01-01100100
2026-01-02200150
2026-01-03150175

Use a unique ordering key when the row sequence matters. Also, specify the frame explicitly for running totals and moving calculations, because default frames can behave differently in the presence of tied ordering values.

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW2026-01-011002026-01-022002026-01-03150frame for the 3rd row: 100 + 200 + 150running_total100 → 300 → 450ROWS BETWEEN 1 PRECEDING AND CURRENT ROW2026-01-011002026-01-022002026-01-03150frame for the 3rd row: (200 + 150) / 2 = 175moving_avg100 → 150 → 175
Figure: the frame decides which rows around the current row feed the calculation.

LAG() — Access a previous row

LAG() retrieves a value from a previous row in the ordered window without performing a self join.

Syntax

SQL
LAG(expression, offset, default_value)
OVER (
    PARTITION BY column
    ORDER BY column
)
  • expression: The column whose value you want.
  • offset: How many rows backward to look; defaults to 1.
  • default_value: What to return if the requested preceding row doesn't exist; defaults to NULL.

Example: Compare today's sales with yesterday's

Suppose we have a Sales table:

sale_dateamount
2026-01-01100
2026-01-02150
2026-01-03120
2026-01-04200
SQL
SELECT
    sale_date,
    amount,
    LAG(amount) OVER (
        ORDER BY sale_date
    ) AS previous_amount
FROM Sales;
Output:
sale_dateamountprevious_amount
2026-01-01100NULL
2026-01-02150100
2026-01-03120150
2026-01-04200120

The first row has no preceding row, so the result is NULL.

Example: Calculate the change from the previous row

SQL
SELECT
    sale_date,
    amount,
    LAG(amount) OVER (
        ORDER BY sale_date
    ) AS previous_amount,
    amount - LAG(amount) OVER (
        ORDER BY sale_date
    ) AS sales_change
FROM Sales;

For January 3, the change is 120−150=−30120 - 150 = -30.

Access two rows backward

SQL
LAG(amount, 2, 0) OVER (
    ORDER BY sale_date
)

This accesses the value from two preceding rows. It returns 0 when that row doesn't exist.

Interview applications:

  • Compare current and previous day's sales.
  • Find employees whose salary increased compared with their previous record.
  • Detect changes in user activity.
  • Identify consecutive dates or gaps in sequences.

LEAD() — Access a next row

LEAD() is the opposite of LAG(): it retrieves a value from a subsequent row.

SQL
LEAD(expression, offset, default_value)
OVER (
    PARTITION BY column
    ORDER BY column
)

Example:

SQL
SELECT
    sale_date,
    amount,
    LEAD(amount) OVER (
        ORDER BY sale_date
    ) AS next_amount
FROM Sales;
Output:
sale_dateamountnext_amount
2026-01-01100150
2026-01-02150120
2026-01-03120200
2026-01-04200NULL

The last row has no subsequent row.

Interview applications:

  • Compare a current event with the next event.
  • Calculate the number of days until the next order.
  • Find the next salary or status for an employee.
  • Detect gaps between consecutive records.

LAG() vs. LEAD()

LAG()

Looks backward in the specified order.

Example: Today's sales versus yesterday's sales.

LEAD()

Looks forward in the specified order.

Example: Today's order versus the customer's next order.

2026-01-011002026-01-021502026-01-031202026-01-04200LAG(amount): value from the previous rowLEAD(amount): value from the next rowprevious: NULLnext: NULL
Figure: LAG() reads backwards and LEAD() reads forwards in the specified order.

FIRST_VALUE() — Get the first value in a window

Returns the value from the first row of the window frame.

SQL
SELECT
    name,
    department,
    salary,
    FIRST_VALUE(salary) OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS highest_salary
FROM Employee;

This displays the highest salary alongside every employee in each department, assuming the window starts with the highest-paid employee.

Use cases:

  • Find the first transaction in a group.
  • Display the earliest order date.
  • Compare every employee's salary against the highest salary in their department.

LAST_VALUE() — Get the last value in a window

Returns the value from the last row of the window frame.

One important trap: the default window frame with ORDER BY often ends at the current row's peer group, rather than the end of the entire partition. As a result, LAST_VALUE() may return the current row's value instead of the final value in the partition.

Default frame (ORDER BY): partition start → current rowrow 1row 2row 3row 4▲ current rowLAST_VALUE = current row’s valueROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGrow 1row 2row 3row 4▲ last row of partitionLAST_VALUE = true last value
Figure: with ORDER BY the default frame stops at the current row, so LAST_VALUE() often returns the current value.

Correct way to get the lowest salary in each department

SQL
SELECT
    name,
    department,
    salary,
    LAST_VALUE(salary) OVER (
        PARTITION BY department
        ORDER BY salary ASC
        ROWS BETWEEN UNBOUNDED PRECEDING
                 AND UNBOUNDED FOLLOWING
    ) AS lowest_salary
FROM Employee;

Here, the frame explicitly extends to the last row of the partition.

Interview tip

Whenever you need the last value in the entire partition, check the window frame carefully.

NTH_VALUE() — Get the Nth value

Returns the value from the Nth row of the window frame, if that row exists.

SQL
SELECT
    name,
    salary,
    NTH_VALUE(salary, 2) OVER (
        ORDER BY salary DESC
        ROWS BETWEEN UNBOUNDED PRECEDING
                 AND UNBOUNDED FOLLOWING
    ) AS second_salary
FROM Employee;

This retrieves the second salary in the ordered window frame. Because ties may exist, this does not necessarily mean the second-highest distinct salary.

Use cases:

  • Retrieve a value at a specific position.
  • Compare a record with the second or third ordered record.
  • Analyze ordered measurements.

For a second-highest distinct salary problem, DENSE_RANK() is usually a better choice.

NTILE(N) — Divide rows into buckets

Divides the ordered rows into up to N approximately equal-sized buckets, numbered from 1 through N.

rows ordered by salary DESCrow 1row 2row 3row 4row 5row 6row 7row 8bucket 1bucket 2bucket 3bucket 4NTILE(4) OVER (ORDER BY salary DESC) AS salary_quartile
Figure: NTILE(N) divides ordered rows (not distinct values) into N roughly equal buckets numbered 1 to N.
SQL
SELECT
    name,
    salary,
    NTILE(4) OVER (
        ORDER BY salary DESC
    ) AS salary_quartile
FROM Employee;

This distributes the employees into four groups based on salary order.

Use cases:

  • Divide customers into four groups.
  • Create salary bands based on ordered rows.
  • Split users into approximately equal-sized cohorts.

Note that NTILE() divides rows, not distinct salary values, so tied salaries can end up in different buckets.

Top 20 SQL CTE Patterns

Overview

A Common Table Expression (CTE) is a temporary named result set defined using WITH. It helps divide a complex SQL query into readable steps.

SQL
WITH CTE_Name AS (
    SELECT ...
)
SELECT *
FROM CTE_Name;

Pattern 1. Basic Readability — Replace Nested Subqueries

Use when

You want to name an intermediate result and simplify a complex query.

SQL
WITH HighSales AS (
    SELECT order_id, customer_id, amount
    FROM Orders
    WHERE amount > 1000
)
SELECT c.name, h.amount
FROM Customers c
JOIN HighSales h
    ON c.customer_id = h.customer_id;

How it works:

  1. HighSales selects orders above 1000.
  2. The main query joins those orders with customers.
  3. The CTE makes the logic easier to read and reuse within the statement.

Pattern 2. Multiple CTEs — Chain Logic Steps

Use when

A problem requires multiple intermediate calculations.

SQL
WITH Sales2023 AS (
    SELECT product_id, amount
    FROM Sales
    WHERE sale_date >= DATE '2023-01-01'
      AND sale_date < DATE '2024-01-01'
),
Sales2024 AS (
    SELECT product_id, amount
    FROM Sales
    WHERE sale_date >= DATE '2024-01-01'
      AND sale_date < DATE '2025-01-01'
)
SELECT a.product_id,
       a.amount AS sales_2023,
       b.amount AS sales_2024
FROM Sales2023 a
JOIN Sales2024 b
    ON a.product_id = b.product_id;
Remember

Separate CTEs with commas. Later CTEs can reference earlier CTEs. This example joins individual sales rows; if you need yearly product totals, aggregate each CTE by product_id first.

Pattern 3. Recursion — Generate Numbers from 1 to 10

Use when

You need a sequence, hierarchy traversal, or repeated processing of rows.

SQL
WITH RECURSIVE NumSeries AS (
    SELECT 1 AS n              -- Anchor
    UNION ALL
    SELECT n + 1               -- Recursive step
    FROM NumSeries
    WHERE n < 10               -- Stop condition
)
SELECT n
FROM NumSeries;

Output: 1, 2, 3, ..., 10

The recursive CTE has three parts:

AnchorSELECT 1 AS nstarts with 1Recursive stepSELECT n + 1FROM NumSeriesStop conditionWHERE n < 10repeat with new rowsstops after 10Output: 1, 2, 3, …, 10
Figure: the anchor seeds the result, the recursive step feeds on the previous output until the stop condition fails.
  • Anchor: Starts with 1.
  • Recursive step: Generates the next number.
  • Termination: Stops generating numbers after 10.

PostgreSQL supports WITH RECURSIVE. SQL Server uses WITH for recursive CTEs and generally does not use the RECURSIVE keyword.

Pattern 4. Manager Hierarchy — Who Reports to Whom?

Use when

An employee table contains a manager_id that references another employee.

Example structure:

idnamemanager_id
1AmanNULL
2Priya1
3Ravi1
4Neha2
SQL
WITH RECURSIVE OrgChart AS (
    -- Start with top-level managers
    SELECT id, name, manager_id, 1 AS lvl
    FROM Employees
    WHERE manager_id IS NULL

    UNION ALL

    -- Find employees reporting to each person
    SELECT e.id, e.name, e.manager_id, o.lvl + 1
    FROM Employees e
    JOIN OrgChart o
        ON e.manager_id = o.id
)
SELECT *
FROM OrgChart
ORDER BY lvl, id;
Intuition

Start at the root of the organization and repeatedly find employees whose manager_id matches the current employee's id.

lvl represents the employee's depth in the hierarchy.

1 Amanmanager_id = NULLlvl 12 Priyamanager_id = 1lvl 23 Ravimanager_id = 1lvl 24 Nehamanager_id = 2lvl 3
Figure: the hierarchy built level by level — lvl is the depth of each employee.

Pattern 5. Nth Highest Salary — DENSE_RANK() + CTE

Use when

You need the second-highest, third-highest, or Nth-highest distinct salary.

SQL
WITH RankedSalaries AS (
    SELECT salary,
           DENSE_RANK() OVER (
               ORDER BY salary DESC
           ) AS rnk
    FROM Employees
)
SELECT DISTINCT salary
FROM RankedSalaries
WHERE rnk = 2;

For the third-highest salary, change rnk = 2 to rnk = 3.

Why DENSE_RANK()?

SalaryDENSE_RANK()
1000001
1000001
800002
700003
  • ROW_NUMBER() assigns a different number to every row.
  • RANK() leaves gaps after ties.
  • DENSE_RANK() ranks distinct values without gaps.
Interview tip

If the question says Nth highest distinct salary, DENSE_RANK() is usually the right starting point. If the requested rank does not exist, the query returns no rows.

Pattern 6. Delete Duplicates — ROW_NUMBER()

Use when

You need to retain one row per email and remove the rest.

PostgreSQL example:

SQL
WITH RankedUsers AS (
    SELECT id,
           ROW_NUMBER() OVER (
               PARTITION BY email
               ORDER BY id
           ) AS rn
    FROM Users
)
DELETE FROM Users u
USING RankedUsers r
WHERE u.id = r.id
  AND r.rn > 1;

How it works:

  1. PARTITION BY email groups rows with the same email.
  2. ORDER BY id selects the row with the smallest ID as rn = 1.
  3. Rows with rn > 1 are duplicates.
  4. The DELETE removes only those duplicate rows.

The original image's DELETE FROM DupeRows pattern is not universally valid. In PostgreSQL, use DELETE ... USING; SQL Server supports deleting through an updatable CTE.

Before deleting: Run the ranking query as a SELECT first to verify which rows will be removed. Decide explicitly how NULL emails and case differences should be treated.

Pattern 7. Running Total — Cumulative Sum

Use when

You need cumulative sales, spending, balances, or counts over time.

Suppose Sales contains sale_date and amount.

SQL
WITH DailySales AS (
    SELECT sale_date,
           SUM(amount) AS daily_amount
    FROM Sales
    GROUP BY sale_date
)
SELECT sale_date,
       daily_amount,
       SUM(daily_amount) OVER (
           ORDER BY sale_date
           ROWS BETWEEN UNBOUNDED PRECEDING
                    AND CURRENT ROW
       ) AS running_total
FROM DailySales
ORDER BY sale_date;

Example:

DateDaily salesRunning total
Oct 1100100
Oct 2200300
Oct 350350
Key idea

The window sum includes all preceding rows and the current row. The explicit ROWS frame makes the intended cumulative behavior clear.

Pattern 8. Moving Average — Three-Row Rolling Average

Use when

You want to smooth daily sales, order counts, or other time-series measurements.

SQL
WITH DailyStats AS (
    SELECT sale_date,
           COUNT(*) AS orders
    FROM Sales
    GROUP BY sale_date
)
SELECT sale_date,
       orders,
       AVG(orders) OVER (
           ORDER BY sale_date
           ROWS BETWEEN 2 PRECEDING
                    AND CURRENT ROW
       ) AS moving_avg
FROM DailyStats
ORDER BY sale_date;

For order counts 3, 6, 9, the moving averages are:

DayOrdersMoving average
133.00
264.50
396.00

The first two averages use fewer than three rows because fewer rows are available.

Important

This calculates an average over the current row and the previous two rows, not necessarily the previous three calendar days. Generate a complete calendar first if missing dates must count as zero.

Pattern 9. Gaps and Islands — Group Consecutive Dates

Use when

You need to find consecutive login days, activity streaks, attendance periods, or continuous date ranges.

PostgreSQL example:

SQL
WITH NumberedDates AS (
    SELECT DISTINCT activity_date,
           activity_date
             - ROW_NUMBER() OVER (
                   ORDER BY activity_date
               )::int AS grp
    FROM ActiveDays
)
SELECT MIN(activity_date) AS start_date,
       MAX(activity_date) AS end_date,
       COUNT(*) AS consecutive_days
FROM NumberedDates
GROUP BY grp
ORDER BY start_date;
Intuition

For consecutive dates, subtracting the row number from each date produces the same grouping key.

Example:

Activity dateRow numberGrouping idea
Oct 11Same group
Oct 22Same group
Oct 33Same group
Oct 64New group
Oct 75Same new group

DISTINCT prevents duplicate activity dates from breaking the sequence.

activity daterow numbergrouping keyOct 11Same groupOct 22Same groupOct 33Same groupOct 64New groupOct 75Same newisland 1: Oct 1 – Oct 3 (3 days)island 2: Oct 6 – Oct 7
Figure: consecutive dates share the same “date − row number” key, so each island gets its own group.
Interview tip

The pattern also works for consecutive integer IDs. For streaks partitioned by user, calculate the row number separately for each user and group by both user_id and the derived grouping key.

Pattern 10. Date Generation — Fill Missing Dates

Use when

A report must include every date even when no sales occurred.

PostgreSQL recursive CTE example:

SQL
WITH RECURSIVE Calendar AS (
    SELECT DATE '2024-01-01' AS dt

    UNION ALL

    SELECT dt + 1
    FROM Calendar
    WHERE dt < DATE '2024-01-31'
),
DailySales AS (
    SELECT sale_date,
           SUM(amount) AS amount
    FROM Sales
    GROUP BY sale_date
)
SELECT c.dt,
       COALESCE(s.amount, 0) AS amount
FROM Calendar c
LEFT JOIN DailySales s
    ON c.dt = s.sale_date
ORDER BY c.dt;

Why LEFT JOIN? The calendar is the complete list of dates. Missing sales records become NULL, which COALESCE() converts to 0.

For PostgreSQL specifically, generate_series() is another convenient way to generate dates without recursion.

Pattern 11. Year-over-Year Growth — LAG() + CTE

Use when

You need to compare annual revenue, profit, or sales with the previous year.

SQL
WITH YearlySales AS (
    SELECT EXTRACT(YEAR FROM sale_date) AS yr,
           SUM(amount) AS total_sales
    FROM Sales
    GROUP BY EXTRACT(YEAR FROM sale_date)
),
Comparison AS (
    SELECT yr,
           total_sales,
           LAG(total_sales) OVER (
               ORDER BY yr
           ) AS prev_year_sales
    FROM YearlySales
)
SELECT yr,
       total_sales,
       prev_year_sales,
       ROUND(
           100.0 * (total_sales - prev_year_sales)
           / NULLIF(prev_year_sales, 0),
           2
       ) AS growth_pct
FROM Comparison
ORDER BY yr;

Formula:

Growth %=Current−PreviousPrevious×100\text{Growth \%}= \frac{\text{Current}-\text{Previous}} {\text{Previous}}\times100

Example: Previous year sales = 200, current year sales = 250.

250−200200×100=25%\frac{250-200}{200}\times100=25\%
  • LAG() gets the previous row's value.
  • NULLIF(previous, 0) prevents division by zero.
  • The first available year has no previous value, so its growth is NULL.
Important

LAG() compares the previous available row. If 2022 is followed by 2024 because 2023 has no sales, this query compares 2024 with 2022. If you need strictly consecutive calendar years, first generate a complete year series.

Pattern 12. Monthly Active Users — Retention Analysis

Use when

You need to count active users per month and determine how many return the following month.

Assume Logins(user_id, login_date).

SQL
WITH MonthlyUsers AS (
    SELECT DISTINCT
           user_id,
           DATE_TRUNC('month', login_date)::date AS month
    FROM Logins
)
SELECT m1.month,
       COUNT(*) AS active_users,
       COUNT(m2.user_id) AS retained_next_month
FROM MonthlyUsers m1
LEFT JOIN MonthlyUsers m2
    ON m1.user_id = m2.user_id
   AND m2.month = (m1.month + INTERVAL '1 month')::date
GROUP BY m1.month
ORDER BY m1.month;

How it works:

  1. DISTINCT ensures each user appears at most once per month.
  2. m1 represents users active in a particular month.
  3. m2 finds the same users in the next month.
  4. COUNT(*) counts current-month active users.
  5. COUNT(m2.user_id) counts users retained into the next month.

Retention rate can be calculated as:

SQL
ROUND(
    100.0 * COUNT(m2.user_id) / NULLIF(COUNT(*), 0),
    2
) AS retention_pct

Add this expression to the SELECT list above.

Pattern 13. Find Missing IDs — Gaps in a Sequence

Use when

You need to find absent IDs between 1 and the maximum ID present.

PostgreSQL example using recursion:

SQL
WITH RECURSIVE Expected AS (
    SELECT 1 AS id

    UNION ALL

    SELECT id + 1
    FROM Expected
    WHERE id < (SELECT MAX(id) FROM Users)
)
SELECT e.id
FROM Expected e
LEFT JOIN Users u
    ON e.id = u.id
WHERE u.id IS NULL
ORDER BY e.id;

Suppose Users contains IDs 1, 2, 4, 6.

Output:
Missing ID
3
5
Intuition

Generate the expected sequence, then use a LEFT JOIN to find values that have no matching row.

This assumes IDs start at 1. If the range starts elsewhere, change the anchor. For large sequences in PostgreSQL, generate_series() is generally more convenient than recursion.

Pattern 14. String Splitting — Convert a String into Rows

Use when

A string contains comma-separated values and you need one row per item.

Input: Apple,Banana,Orange

PostgreSQL solution:

SQL
SELECT TRIM(item) AS item
FROM UNNEST(
    STRING_TO_ARRAY('Apple,Banana,Orange', ',')
) AS t(item);
Output:
item
Apple
Banana
Orange

For values stored in a table, for example Strings(id, val):

SQL
SELECT s.id,
       TRIM(x.item) AS item
FROM Strings s
CROSS JOIN LATERAL
    UNNEST(STRING_TO_ARRAY(s.val, ',')) AS x(item);
Interview note

The recursive LEFT()/STUFF() approach shown in the supplied reference is SQL Server-specific, and the syntax in the reference is incomplete. SQL Server also has STRING_SPLIT() in supported versions. Prefer built-in functions when the database provides them.

Pattern 15. Basket Analysis — Products Bought Together

Use when

You need to find which product pairs most frequently appear in the same order.

Assume Order_Items(order_id, product_id).

SQL
WITH Pairs AS (
    SELECT a.product_id AS p1,
           b.product_id AS p2,
           a.order_id
    FROM Order_Items a
    JOIN Order_Items b
        ON a.order_id = b.order_id
       AND a.product_id < b.product_id
)
SELECT p1,
       p2,
       COUNT(DISTINCT order_id) AS frequency
FROM Pairs
GROUP BY p1, p2
ORDER BY frequency DESC;

Why a.product_id < b.product_id?

For a pair of products A and B, it avoids generating both (A, B) and (B, A), and prevents pairing a product with itself.

COUNT(DISTINCT order_id) avoids overcounting if the source contains repeated lines for the same product in one order.

Pattern 16. Pivot Preparation — Rows to Columns

Use when

You want quarterly sales displayed in separate columns.

Assume Sales(year, quarter, revenue).

SQL
WITH BaseData AS (
    SELECT year,
           quarter,
           SUM(revenue) AS revenue
    FROM Sales
    GROUP BY year, quarter
)
SELECT year,
       MAX(CASE WHEN quarter = 1 THEN revenue END) AS Q1,
       MAX(CASE WHEN quarter = 2 THEN revenue END) AS Q2,
       MAX(CASE WHEN quarter = 3 THEN revenue END) AS Q3,
       MAX(CASE WHEN quarter = 4 THEN revenue END) AS Q4
FROM BaseData
GROUP BY year
ORDER BY year;
Possible output:
YearQ1Q2Q3Q4
2024100150120200
2025130160140220
Intuition

CASE selects the revenue for a particular quarter. MAX() combines those selected values into one row per year.

This is called conditional aggregation. MAX() is appropriate here because the CTE already contains at most one row per year and quarter.

BaseData (rows)2024quarter 11002024quarter 21502024quarter 31202024quarter 4200CASE + MAX()one row per yearyear2024Q1100Q2150Q3120Q4200
Figure: CASE picks the right quarter for each column; MAX() collapses them into one row per year.

Pattern 17. Consecutive Wins — Find Three Wins in a Row

Use when

You need to identify players or teams with consecutive wins.

Assume Matches(id, player_id, match_date, result).

SQL
WITH Streaks AS (
    SELECT id,
           player_id,
           match_date,
           result,
           LAG(result, 1) OVER (
               PARTITION BY player_id
               ORDER BY match_date, id
           ) AS prev1,
           LAG(result, 2) OVER (
               PARTITION BY player_id
               ORDER BY match_date, id
           ) AS prev2
    FROM Matches
)
SELECT *
FROM Streaks
WHERE result = 'Win'
  AND prev1 = 'Win'
  AND prev2 = 'Win';

How it works:

  • prev1 gives the previous match result.
  • prev2 gives the result two matches earlier.
  • If all three results are Win, the current match completes a three-win streak.

PARTITION BY player_id prevents one player's result from being compared with another player's result.

If matches have gaps in dates, this still means three consecutive matches, not necessarily three consecutive calendar days.

Pattern 18. Update via CTE — Modify Data Safely

Use when

You want to identify rows with a CTE and update the underlying table.

PostgreSQL example:

SQL
WITH ToUpdate AS (
    SELECT id
    FROM Employees
    WHERE performance_score > 90
)
UPDATE Employees e
SET salary = e.salary * 1.10
FROM ToUpdate t
WHERE e.id = t.id;

This increases salaries by 10% for employees with a performance score above 90.

Important

A CTE is not automatically an independent temporary table. The UPDATE changes the underlying Employees table.

In SQL Server, an updatable CTE can also be used directly:

SQL
WITH ToUpdate AS (
    SELECT *
    FROM Employees
    WHERE performance_score > 90
)
UPDATE ToUpdate
SET salary = salary * 1.10;

Syntax and updateability depend on the database. For important data changes, inspect the target rows first and use a transaction where appropriate.

Pattern 19. Tree Path Generation — Build Breadcrumbs

Use when

You need full category paths such as Home > Electronics > TV.

Assume Categories(id, name, parent_id).

PostgreSQL example:

SQL
WITH RECURSIVE Paths AS (
    SELECT id,
           name,
           parent_id,
           name::text AS path
    FROM Categories
    WHERE parent_id IS NULL

    UNION ALL

    SELECT c.id,
           c.name,
           c.parent_id,
           p.path || ' > ' || c.name
    FROM Categories c
    JOIN Paths p
        ON c.parent_id = p.id
)
SELECT id, name, path
FROM Paths
ORDER BY id;

Example path:

Output
Home > Electronics > TV
Intuition
  1. The anchor selects root categories.
  2. The recursive step finds each category's children.
  3. The path is extended by appending the child's name.

The hierarchy should not contain cycles; otherwise, recursion may continue indefinitely unless you add cycle detection or a depth limit.

Pattern 20. Insert from CTE — ETL and Data Migration

Use when

You need to calculate or transform data before inserting aggregated results into another table.

Assume Raw_Sales(region, amount) and Summary_Table(region, total_sales).

SQL
WITH CalculatedData AS (
    SELECT region,
           SUM(amount) AS total_sales
    FROM Raw_Sales
    GROUP BY region
)
INSERT INTO Summary_Table (region, total_sales)
SELECT region, total_sales
FROM CalculatedData;

How it works:

  1. CalculatedData aggregates sales by region.
  2. INSERT INTO stores the calculated results in the summary table.

The destination table must exist and have compatible columns. Re-running this query may insert duplicate summary rows unless you truncate the table, enforce suitable uniqueness, or use an appropriate upsert strategy.

The position of WITH relative to INSERT varies by SQL dialect; the example uses PostgreSQL-compatible syntax.

SQL Practice

1. Find the second-highest salary

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

Logic:

  1. The subquery finds the highest salary.
  2. WHERE salary < ... excludes the highest salary.
  3. MAX(salary) returns the next-highest distinct salary.
Concept

Subquery and aggregate function.

Note

If multiple employees have the highest salary, this still returns the second-highest distinct salary.

2. Find department-wise average salary where departments have more than 5 employees

SQL
SELECT
    department_id,
    AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
HAVING COUNT(*) > 5;

Logic:

  1. GROUP BY creates one group per department.
  2. AVG(salary) calculates the average salary for each group.
  3. HAVING COUNT(*) > 5 keeps only departments with more than five employees.
Concept

GROUP BY, HAVING, and aggregate functions.

3. Find customers with more than 3 orders in the last 30 days

SQL
SELECT customer_id
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY customer_id
HAVING COUNT(*) > 3;

Logic:

  1. Filter orders to the last 30 days.
  2. Group orders by customer.
  3. Return customers with more than three qualifying orders.
Concept

Date filtering and aggregation.

Note

This interval syntax works in PostgreSQL. Date arithmetic differs across database systems.

4. Find duplicate combinations of multiple columns

SQL
SELECT
    col1,
    col2,
    COUNT(*) AS duplicate_count
FROM my_table
GROUP BY col1, col2
HAVING COUNT(*) > 1;

Logic:

  1. Group rows having the same col1 and col2.
  2. Count the rows in each group.
  3. Return groups appearing more than once.
Concept

Duplicate detection using GROUP BY and HAVING.

5. Transpose rows into columns using CASE

SQL
SELECT
    user_id,
    MAX(CASE WHEN month = 'Jan' THEN spend END) AS Jan,
    MAX(CASE WHEN month = 'Feb' THEN spend END) AS Feb
FROM user_spend
GROUP BY user_id;

Logic:

  1. CASE places each month's spending in its corresponding column.
  2. MAX() combines values for each user into one row.
  3. GROUP BY user_id produces one row per user.
Concept

Conditional aggregation, often used for pivoting data.

Important

This example assumes at most one relevant spend value per user per month. If multiple rows exist, MAX() selects the largest value rather than summing them.

6. Understand INNER JOIN vs. EXISTS

Using EXISTS to find customers who have placed at least one order:

SQL
SELECT c.name
FROM customers c
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.customer_id = c.id
);

Equivalent using INNER JOIN:

SQL
SELECT DISTINCT c.name
FROM customers c
INNER JOIN orders o
    ON o.customer_id = c.id;

Difference:

INNER JOINEXISTS
Combines matching rows from both tables.Checks whether a matching row exists.
Can produce multiple rows per customer.Returns each qualifying outer row once.
Useful when columns from both tables are needed.Useful when only existence matters.
Interview tip

EXISTS is often a natural choice for existence checks. Do not claim it always stops early or is always faster; the optimizer determines the execution strategy.

7. Find products that have never been sold

SQL
SELECT p.*
FROM products p
LEFT JOIN order_items o
    ON p.id = o.product_id
WHERE o.product_id IS NULL;

Logic:

  1. LEFT JOIN preserves every product.
  2. Products with matching order items receive matching values.
  3. Products without a match have NULL on the order-item side.

Alternative using NOT EXISTS:

SQL
SELECT p.*
FROM products p
WHERE NOT EXISTS (
    SELECT 1
    FROM order_items o
    WHERE o.product_id = p.id
);
Concept

Anti-join and missing-match detection.

8. Find the highest order value for each customer

SQL
SELECT o.*
FROM orders o
WHERE o.amount = (
    SELECT MAX(o2.amount)
    FROM orders o2
    WHERE o2.customer_id = o.customer_id
);

Logic:

  1. The inner query finds the maximum order amount for the current customer.
  2. The outer query selects orders matching that amount.
Concept

Correlated subquery.

Important

If a customer has multiple orders tied for the highest amount, all tied orders are returned.

9. Compare today's active users with yesterday's active users

Assume activity_date is a DATE column and active users are identified by distinct user_id values.

SQL
WITH daily_users AS (
    SELECT
        activity_date,
        COUNT(DISTINCT user_id) AS user_count
    FROM activity_log
    WHERE activity_date IN (
        CURRENT_DATE,
        CURRENT_DATE - 1
    )
    GROUP BY activity_date
)
SELECT
    MAX(CASE
        WHEN activity_date = CURRENT_DATE
        THEN user_count
    END) AS today_users,
    MAX(CASE
        WHEN activity_date = CURRENT_DATE - 1
        THEN user_count
    END) AS yesterday_users
FROM daily_users;

Logic:

  1. The CTE calculates distinct active users for each date.
  2. CASE places today's and yesterday's counts into separate columns.
  3. MAX() combines those values into one result row.
Concept

CTE and conditional aggregation.

Note

If a date has no activity, its count appears as NULL. Use COALESCE(..., 0) if you want zero instead.

10. Use a recursive CTE to retrieve an employee hierarchy

Assume employees.id identifies an employee and parent_id identifies their manager.

SQL
WITH RECURSIVE org_chart AS (
    -- Base case: top-level employees
    SELECT
        id,
        parent_id,
        1 AS level
    FROM employees
    WHERE parent_id IS NULL

    UNION ALL

    -- Recursive case: find each employee's reports
    SELECT
        e.id,
        e.parent_id,
        oc.level + 1
    FROM employees e
    JOIN org_chart oc
        ON e.parent_id = oc.id
)
SELECT *
FROM org_chart
ORDER BY level, id;

Logic:

  1. The anchor query finds top-level employees.
  2. The recursive query finds employees reporting to the previous level.
  3. level + 1 records the hierarchy depth.
  4. Recursion continues until no additional matching employees are found.
Concept

Recursive CTEs for hierarchical data.

Important

This example assumes a valid hierarchy. Cyclic reporting relationships require cycle prevention.

11. Rank products by sales within each category

SQL
SELECT
    product_id,
    category,
    total_sales,
    RANK() OVER (
        PARTITION BY category
        ORDER BY total_sales DESC
    ) AS rnk
FROM product_sales;

Logic:

  1. PARTITION BY category ranks products independently within each category.
  2. ORDER BY total_sales DESC places the highest-selling products first.
  3. RANK() assigns rankings, giving tied products the same rank and leaving gaps afterward.

Example:

ProductSalesRank
A10001
B8002
C8002
D5004
Concept

Window functions and ranking.

12. Calculate a 7-row rolling average of website visits

SQL
SELECT
    visit_date,
    visits,
    AVG(visits) OVER (
        ORDER BY visit_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS rolling_avg
FROM web_traffic;

Logic:

  1. Order rows by date.
  2. Include the current row and six preceding rows.
  3. Calculate their average visits.
Concept

Window frames and rolling aggregates.

Important

This is a seven-row moving average, not necessarily a seven-calendar-day average. Missing dates or multiple rows per date can change the meaning.

13. Find the first and last transaction for each user

SQL
SELECT *
FROM (
    SELECT
        t.*,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY tx_date ASC, transaction_id ASC
        ) AS rn_asc,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY tx_date DESC, transaction_id DESC
        ) AS rn_desc
    FROM transactions t
) ranked
WHERE rn_asc = 1
   OR rn_desc = 1;

Logic:

  1. rn_asc = 1 identifies the first transaction for each user.
  2. rn_desc = 1 identifies the last transaction.
  3. The outer query returns rows matching either condition.
Concept

ROW_NUMBER() and window functions.

Important

This assumes transaction_id is a unique tie-breaker. A user with only one transaction has the same row qualify as both first and last, so it appears only once.

14. Detect gaps between sequential dates

SQL
SELECT
    event_date,
    LAG(event_date) OVER (
        ORDER BY event_date
    ) AS prev_date,
    event_date - LAG(event_date) OVER (
        ORDER BY event_date
    ) AS gap
FROM events
ORDER BY event_date;

Logic:

  1. LAG() retrieves the previous row's date.
  2. Subtract the previous date from the current date.
  3. A gap greater than one day indicates missing calendar days, assuming one row per date and date values are consecutive at daily granularity.
Concept

LAG() and date arithmetic.

Note

This subtraction syntax works in PostgreSQL for DATE values. In other databases, use the appropriate date-difference function. If duplicate dates exist, deduplicate them first to detect calendar gaps correctly.

15. Calculate cumulative spend per customer per month

SQL
SELECT
    customer_id,
    month,
    spend,
    SUM(spend) OVER (
        PARTITION BY customer_id
        ORDER BY month
        ROWS BETWEEN UNBOUNDED PRECEDING
                 AND CURRENT ROW
    ) AS cumulative_spend
FROM monthly_spend
ORDER BY customer_id, month;

Logic:

  1. PARTITION BY customer_id starts a separate running total for each customer.
  2. ORDER BY month processes each customer's records chronologically.
  3. SUM() accumulates spend from the first row through the current row.
Concept

Running totals using window functions.

Important

This assumes one row per customer per month. If multiple rows can share the same month, aggregate them first or define a unique ordering.

SQL Interview Questions — Restaurant Order Management

1. Find the Top 5 Most-Ordered Dishes from a Restaurant

SQL
SELECT
    DishName,
    SUM(Quantity) AS TotalOrders
FROM Orders
WHERE RestaurantID = 'R101'
GROUP BY DishName
ORDER BY TotalOrders DESC
LIMIT 5;
Intuition

Filter the restaurant, group orders by dish, add up quantities, and return the five largest totals.

Expected output:
DishNameTotalOrders
Burger7
Pasta3
Note

SUM(Quantity) counts the total quantity sold, not the number of order records.

2. Retrieve Orders from the Last 30 Days for a Specific User

SQL
SELECT *
FROM Orders
WHERE UserID = 'U123'
  AND OrderDate >= CURDATE() - INTERVAL 30 DAY
  AND OrderDate < CURDATE() + INTERVAL 1 DAY;
Intuition

Filter by user and keep orders within the last 30 days, including today.

If OrderDate is a DATE column, the upper bound is optional. It is useful when OrderDate is a DATETIME and you want to include all of today's orders.

Important

The sample output in the question cannot be guaranteed without knowing the current date. If CURDATE() is in October 2026, the January 2025 records will not qualify.

3. Find Total Revenue Generated by All Restaurants in the Previous Calendar Month

SQL
SELECT SUM(Amount) AS TotalRevenue
FROM Orders
WHERE OrderDate >= DATE_FORMAT(
          CURDATE() - INTERVAL 1 MONTH, '%Y-%m-01'
      )
  AND OrderDate < DATE_FORMAT(
          CURDATE(), '%Y-%m-01'
      );
Intuition

Use the first day of the previous month as the inclusive start and the first day of the current month as the exclusive end.

This calculates revenue for the previous calendar month, not a rolling 30-day period.

For the supplied sample data, if the intended period is December 2024, the total is:

300+100+250=650300 + 100 + 250 = 650

4. Find the Top 10 Restaurants by Average Customer Rating

SQL
SELECT
    RestaurantID,
    ROUND(AVG(Rating), 2) AS AverageRating
FROM Ratings
GROUP BY RestaurantID
ORDER BY AverageRating DESC
LIMIT 10;
Intuition

Calculate the average rating per restaurant, sort from highest to lowest, and return ten rows.

Expected output from the supplied data:
RestaurantIDAverageRating
R1014.67
R1024.00
R1034.00

For a production ranking, consider requiring a minimum number of ratings so that restaurants with very few reviews are not unfairly favored.

5. Find Customers Who Ordered from More Than 3 Different Restaurants

SQL
SELECT UserID
FROM Orders
GROUP BY UserID
HAVING COUNT(DISTINCT RestaurantID) > 3;
Intuition
  • GROUP BY UserID creates one group per customer.
  • COUNT(DISTINCT RestaurantID) counts unique restaurants.
  • HAVING filters groups after aggregation.
Expected output:
UserID
U123

Use HAVING, rather than WHERE, because the condition depends on an aggregate count.

6. Find Restaurants Whose Average Order Value Exceeds the Overall Average

SQL
WITH RestaurantAverage AS (
    SELECT
        RestaurantID,
        AVG(Amount) AS AvgOrderValue
    FROM Orders
    GROUP BY RestaurantID
),
OverallAverage AS (
    SELECT AVG(Amount) AS OverallAvg
    FROM Orders
)
SELECT
    r.RestaurantID,
    ROUND(r.AvgOrderValue, 2) AS AvgOrderValue
FROM RestaurantAverage r
CROSS JOIN OverallAverage o
WHERE r.AvgOrderValue > o.OverallAvg;
Intuition

Calculate each restaurant's average in one CTE, the overall average in another, and compare them.

For the supplied data:

  • Overall average: (200+150+300+100+250)/5=200(200+150+300+100+250)/5 = 200
  • R101 average: 175175
  • R102 average: 275275
  • R103 average: 100100
Expected output:
RestaurantIDAvgOrderValue
R102275.00

This is a good example of multiple CTEs.

7. Calculate Total Revenue for Each Cuisine

SQL
SELECT
    Cuisine,
    SUM(Amount) AS TotalRevenue
FROM Orders
GROUP BY Cuisine
ORDER BY TotalRevenue DESC;
Intuition

Group all orders by cuisine and sum their amounts.

Expected output:
CuisineTotalRevenue
Chinese550
Italian350
Indian100

8. Rank Restaurants by Total Revenue Within Each City

SQL
WITH RestaurantRevenue AS (
    SELECT
        RestaurantID,
        City,
        SUM(Amount) AS TotalRevenue
    FROM Orders
    GROUP BY RestaurantID, City
)
SELECT
    RestaurantID,
    City,
    TotalRevenue,
    RANK() OVER (
        PARTITION BY City
        ORDER BY TotalRevenue DESC
    ) AS RevenueRank
FROM RestaurantRevenue
ORDER BY City, RevenueRank;
Intuition
  • Aggregate revenue per restaurant first.
  • PARTITION BY City starts a separate ranking for each city.
  • ORDER BY TotalRevenue DESC gives the highest revenue rank 1.
Expected output:
RestaurantIDCityTotalRevenueRevenueRank
R104London2501
R103London1502
R102New York3001
R101New York2002
R105New York1003

The order of cities in the output depends on the final ORDER BY; the ranking itself is calculated separately within each city.

9. Categorize Restaurants by Monthly Revenue

Using the thresholds in your question:

  • Above 300: High Revenue
  • 150 through 300: Medium Revenue
  • Below 150: Low Revenue
SQL
WITH MonthlyRevenue AS (
    SELECT
        RestaurantID,
        SUM(Amount) AS TotalMonthlyRevenue
    FROM Orders
    WHERE OrderDate >= DATE_FORMAT(
              CURDATE() - INTERVAL 1 MONTH, '%Y-%m-01'
          )
      AND OrderDate < DATE_FORMAT(
              CURDATE(), '%Y-%m-01'
          )
    GROUP BY RestaurantID
)
SELECT
    RestaurantID,
    TotalMonthlyRevenue,
    CASE
        WHEN TotalMonthlyRevenue > 300
            THEN 'High Revenue'
        WHEN TotalMonthlyRevenue >= 150
            THEN 'Medium Revenue'
        ELSE 'Low Revenue'
    END AS RevenueCategory
FROM MonthlyRevenue
ORDER BY TotalMonthlyRevenue DESC;
Intuition

Aggregate monthly revenue per restaurant, then use CASE to assign a category.

The simplified second condition >= 150 works because values above 300 have already been caught by the first condition.

Date warning

The example data is from January 2025, but this query uses the previous calendar month relative to today. To reproduce the supplied output, replace the date filter with the intended reporting month.

10. Find the Top 3 Dishes for Each Restaurant in New York

SQL
WITH DishSales AS (
    SELECT
        RestaurantID,
        City,
        DishName,
        SUM(Quantity) AS TotalQuantity
    FROM Orders
    WHERE City = 'New York'
    GROUP BY RestaurantID, City, DishName
),
RankedDishes AS (
    SELECT
        RestaurantID,
        City,
        DishName,
        TotalQuantity,
        ROW_NUMBER() OVER (
            PARTITION BY RestaurantID, City
            ORDER BY TotalQuantity DESC, DishName
        ) AS rn
    FROM DishSales
)
SELECT
    RestaurantID,
    City,
    DishName,
    TotalQuantity
FROM RankedDishes
WHERE rn <= 3
ORDER BY RestaurantID, rn;
Intuition
  1. DishSales calculates total quantity per dish and restaurant.
  2. ROW_NUMBER() ranks dishes separately for each restaurant.
  3. rn <= 3 keeps the top three dishes per restaurant.

I used ROW_NUMBER() to return at most three dishes per restaurant. The secondary ordering by DishName makes ties deterministic.

If the requirement is to include every dish tied within the top three ranks, use RANK() or DENSE_RANK() instead.