SQL Interview Questions and Answers
SQL questions for developer, analyst and support roles: SELECT basics, joins, GROUP BY, subqueries, CTEs, window functions, indexes and normalisation. Every query result shown was produced by running the query.
SELECT, WHERE and ORDER BY
What does a basic SELECT query do?
SELECT reads rows from a table. The basic shape is:
SELECTwhich columns you wantFROMwhich tableWHEREwhich rows (a filter)ORDER BYin what order
SELECT name, city
FROM employees
WHERE city = 'Bengaluru'
ORDER BY name;| name | city |
|---|---|
| Arjun Mehta | Bengaluru |
| Priya Sharma | Bengaluru |
| Rohan Gupta | Bengaluru |
Avoid SELECT * in application code: it fetches columns you don't need, breaks when columns are added or reordered, and prevents some index optimisations. It's fine for quick exploration.
Text values use single quotes ('Bengaluru'). Double quotes are for identifiers like column names in standard SQL and PostgreSQL.
SELECT picks the columns, FROM the table, WHERE filters the rows and ORDER BY sorts the result. In application code I list the columns I need instead of SELECT star, and I use single quotes for text values.
Likely follow-up: Why is SELECT * discouraged in production queries?
In what order does the database actually run the clauses of a query?
We write SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT, but the database logically processes them in this order:
FROM(andJOINs): get the rowsWHERE: filter rowsGROUP BY: form groupsHAVING: filter groupsSELECT: compute the output columns and aliasesDISTINCT: remove duplicatesORDER BY: sortLIMIT/OFFSET: cut the result
This explains common errors. A column alias defined in SELECT can be used in ORDER BY (which runs later), but not in WHERE in most databases (which runs earlier):
SELECT name, salary / 100000 AS lakhs
FROM employees
WHERE salary >= 1500000
ORDER BY lakhs DESC, name;| name | lakhs |
|---|---|
| Priya Sharma | 24 |
| Sneha Reddy | 18 |
| Meera Nair | 16 |
| Arjun Mehta | 15 |
Writing WHERE lakhs >= 15 fails in PostgreSQL, MySQL and SQL Server, because the alias doesn't exist yet when WHERE runs.
Logically the database runs FROM and joins first, then WHERE, GROUP BY, HAVING, then SELECT, DISTINCT, ORDER BY and finally LIMIT. That's why a SELECT alias works in ORDER BY but not in WHERE, and why aggregate conditions go in HAVING.
Likely follow-up: Why can't you use an aggregate function like COUNT in a WHERE clause?
Joins explained with diagrams
What is a JOIN, and what types are there?
A join combines rows from two tables using a related column, usually a foreign key like employees.department_id → departments.id.
- INNER JOIN: only rows that have a match in both tables
- LEFT JOIN: all rows from the left table, plus matches (NULLs where there's no match)
- RIGHT JOIN: all rows from the right table, plus matches
- FULL OUTER JOIN: all rows from both, matched where possible
- CROSS JOIN: every combination of rows
- SELF JOIN: a table joined to itself
With A = employees and B = departments, here's how many rows each join gives:
SELECT
(SELECT COUNT(*) FROM employees e INNER JOIN departments d ON d.id = e.department_id) AS inner_rows,
(SELECT COUNT(*) FROM employees e LEFT JOIN departments d ON d.id = e.department_id) AS left_rows,
(SELECT COUNT(*) FROM employees e RIGHT JOIN departments d ON d.id = e.department_id) AS right_rows,
(SELECT COUNT(*) FROM employees e FULL OUTER JOIN departments d ON d.id = e.department_id) AS full_rows;| inner_rows | left_rows | right_rows | full_rows |
|---|---|---|---|
| 9 | 10 | 10 | 11 |
Inner gives 9 (Aditya has no department). Left adds Aditya (10). Right adds Marketing, which has no employees (10). Full adds both (11).
A join combines rows from two tables on a related column. Inner keeps only matching rows, left keeps all rows from the left table with NULLs where nothing matches, right does the same for the right table, full outer keeps everything from both, cross join gives every combination, and a self join joins a table to itself.
Likely follow-up: Venn diagrams are a simplification of joins. When are they misleading?
How does an INNER JOIN work?
An INNER JOIN returns one row for every pair of rows where the ON condition is true. Rows without a match on either side are dropped.
SELECT e.name, d.name AS department
FROM employees e
INNER JOIN departments d ON d.id = e.department_id
ORDER BY d.name, e.name;| name | department |
|---|---|
| Arjun Mehta | Engineering |
| Kavya Iyer | Engineering |
| Priya Sharma | Engineering |
| Rohan Gupta | Engineering |
| Meera Nair | Finance |
| Rahul Verma | HR |
| Ananya Das | Sales |
| Sneha Reddy | Sales |
| Vikram Singh | Sales |
Aditya Joshi (no department) and Marketing (no employees) don't appear. JOIN on its own means INNER JOIN.
Table aliases (e, d) keep queries short, and prefixing columns (e.name, d.name) is required when both tables have a column with the same name; otherwise you get an "ambiguous column" error.
An inner join returns a row for each pair of rows that satisfy the ON condition, so unmatched rows on either side disappear, like the employee without a department here. I use short table aliases and prefix columns to avoid ambiguity.
Likely follow-up: What is the difference between writing the join condition in ON and listing tables with commas plus a WHERE condition?
GROUP BY and HAVING
What are aggregate functions?
Aggregate functions take many rows and return one value:
COUNT(): number of rows or non-NULL valuesSUM(): totalAVG(): averageMIN()/MAX(): smallest and largest
SELECT COUNT(*) AS employees,
COUNT(department_id) AS with_department,
SUM(salary) AS total,
MIN(salary) AS lowest,
MAX(salary) AS highest,
AVG(salary) AS average
FROM employees;| employees | with_department | total | lowest | highest | average |
|---|---|---|---|---|---|
| 10 | 9 | 13000000 | 600000 | 2400000 | 1300000 |
The company spends ₹1.3 crore a year on salaries, an average of ₹13 lakh per employee.
All aggregates except COUNT(*) ignore NULLs: COUNT(department_id) is 9 because Aditya's department is NULL. Without GROUP BY, the whole table is one group, so you get one row.
Aggregate functions like COUNT, SUM, AVG, MIN and MAX reduce many rows to one value per group, or one value for the whole table without GROUP BY. All of them except COUNT star ignore NULLs.
Likely follow-up: What does SUM return when every value is NULL, or there are no rows?
How does GROUP BY work?
GROUP BY splits rows into groups that share the same value(s), then aggregate functions run once per group.
Headcount and salary cost per department:
SELECT department_id, COUNT(*) AS headcount, SUM(salary) AS total_salary
FROM employees
GROUP BY department_id
ORDER BY department_id;| department_id | headcount | total_salary |
|---|---|---|
| NULL | 1 | 600000 |
| 1 | 4 | 6100000 |
| 2 | 3 | 3600000 |
| 3 | 1 | 1100000 |
| 4 | 1 | 1600000 |
All the NULLs form one group of their own (Aditya, without a department).
The rule: every column in SELECT must either be in GROUP BY or inside an aggregate function. SELECT department_id, name, COUNT(*) ... GROUP BY department_id is an error in PostgreSQL and SQL Server (and in MySQL with the default ONLY_FULL_GROUP_BY mode), because there are several names per department and the database can't know which one to show.
GROUP BY collects rows with the same values into groups and the aggregates are calculated once per group. Every selected column must be grouped or aggregated, and NULLs form a single group of their own.
Likely follow-up: Why does MySQL sometimes allow non-grouped columns in SELECT, and why is that dangerous?
Subqueries and CTEs
How do you use a subquery in a WHERE clause?
A subquery is a query inside another query. In WHERE, it often supplies a value to compare against.
Employees earning more than the company average:
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees)
ORDER BY salary DESC;| name | salary |
|---|---|
| Priya Sharma | 2400000 |
| Sneha Reddy | 1800000 |
| Meera Nair | 1600000 |
| Arjun Mehta | 1500000 |
| Kavya Iyer | 1350000 |
The subquery runs once and returns ₹13,00,000. A subquery that returns one value is called a scalar subquery; if it returned several rows, you'd need IN, ANY or EXISTS instead of >.
You can't write WHERE salary > AVG(salary) directly, because aggregates aren't allowed in WHERE.
A subquery in WHERE computes a value or list to compare against, like the average salary here. A scalar subquery must return a single value; for multiple rows I use IN or EXISTS. It's needed because aggregates can't appear directly in WHERE.
Likely follow-up: What happens if a scalar subquery returns more than one row?
Indexes and normalisation
What is an index, and how does it make queries faster?
An index is a separate data structure that lets the database find rows without scanning the whole table, like the index at the back of a book.
Most indexes are B-trees: balanced trees that keep values sorted. Finding one value in a million rows takes about 3 or 4 page reads instead of reading every row. Because the values are sorted, B-trees also speed up range queries (BETWEEN, >, <), ORDER BY and MIN/MAX.
CREATE INDEX idx_orders_customer ON orders (customer_id);
-- Now this doesn't need to scan every order:
SELECT * FROM orders WHERE customer_id = 3;Other index types: hash indexes (equality only), GIN in PostgreSQL (for JSON, arrays and full-text search), and full-text indexes for word search.
Check whether your index is used with EXPLAIN: a "Seq Scan" (PostgreSQL) or "type: ALL" (MySQL) on a big table means a full table scan.
An index is a separate sorted structure, usually a B-tree, that lets the database jump to matching rows in a few page reads instead of scanning the table. B-trees help equality, ranges, sorting and min/max. I verify that queries actually use the index with EXPLAIN.
Likely follow-up: Why does an index help ORDER BY?
What is the difference between a clustered and a non-clustered index?
- A clustered index determines the physical order of the rows: the table data itself is stored in the index's order. A table can have only one. In SQL Server and MySQL (InnoDB), the primary key is the clustered index by default.
- A non-clustered (secondary) index is a separate structure holding the indexed values plus a pointer to the row (in InnoDB, the primary key value). A table can have many.
-- SQL Server syntax
CREATE CLUSTERED INDEX ix_orders_date ON orders (order_date);
CREATE NONCLUSTERED INDEX ix_orders_customer ON orders (customer_id);A lookup through a non-clustered index needs an extra step to fetch the row, unless the index contains every column the query needs. That's called a covering index (SQL Server and PostgreSQL support INCLUDE (amount) for this).
PostgreSQL doesn't keep tables permanently clustered; its tables are "heaps", and every index is secondary.
A clustered index stores the table rows themselves in index order, so there's only one per table, usually the primary key in SQL Server and MySQL. Non-clustered indexes are separate structures pointing back to the rows, so there can be many, and a covering index that includes all needed columns avoids the extra lookup.
Likely follow-up: Why is an auto-incrementing integer often a good clustered key, and a random UUID a poor one?
20 practice queries with answers
List the employees hired in 2023, newest first.
SELECT name, hire_date
FROM employees
WHERE hire_date >= '2023-01-01'
AND hire_date < '2024-01-01'
ORDER BY hire_date DESC;| name | hire_date |
|---|---|
| Rohan Gupta | 2023-08-01 |
| Ananya Das | 2023-02-13 |
A half-open date range (>= the start, < the next year) works for both dates and timestamps, and can use an index on hire_date, unlike YEAR(hire_date) = 2023.
I filter with a half-open range from the first of January 2023 to before the first of January 2024, which works for timestamps too and can use an index, then sort by hire date descending.
Show customers who signed up in February 2024 with their number of orders.
SELECT c.name, c.signup_date, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.signup_date >= '2024-02-01'
AND c.signup_date < '2024-03-01'
GROUP BY c.id, c.name, c.signup_date
ORDER BY c.signup_date;| name | signup_date | orders |
|---|---|---|
| Karthik Rao | 2024-02-02 | 3 |
| Neha Agarwal | 2024-02-20 | 1 |
The LEFT JOIN and COUNT(o.id) mean a customer with no orders would still appear, with 0.
I filter customers by the signup date range, left join their orders so customers without orders are kept, group by customer and count the order ids.
All 80 questions in the ebook
SELECT, WHERE and ORDER BY
- What does a basic SELECT query do?
- In what order does the database actually run the clauses of a query?
- How do AND and OR work together in a WHERE clause?
- How do you filter a range with BETWEEN?
- How does the IN operator work?
- How do you search for patterns with LIKE?
- How do you find rows where a column is NULL?
- How does NULL behave in calculations, and what does COALESCE do?
- How do you sort by more than one column?
- How do you get the top N rows?
- What does DISTINCT do?
- How do you write conditional logic with CASE?
- How do you calculate new columns in a query?
- What are some useful string functions?
Joins explained with diagrams
- What is a JOIN, and what types are there?
- How does an INNER JOIN work?
- How does a LEFT JOIN work?
- How do you find rows that have no match in another table?
- What is the difference between RIGHT JOIN and LEFT JOIN?
- What is a FULL OUTER JOIN?
- What is a self join?
- What is the difference between putting a condition in ON and in WHERE?
- How do you join more than two tables?
- What is a CROSS JOIN, and when is it useful?
- Why does a join sometimes return more rows than expected?
- What is the difference between UNION and UNION ALL?
- What do INTERSECT and EXCEPT do?
- How does the database execute a join internally?
GROUP BY and HAVING
- What are aggregate functions?
- How does GROUP BY work?
- How do you include groups that have no rows, like a department with no employees?
- What is the difference between WHERE and HAVING?
- How do you count values that appear more than once?
- What is the difference between COUNT(*), COUNT(column) and COUNT(DISTINCT column)?
- How do you turn rows into columns (conditional aggregation)?
- How do you group by month?
- How do NULLs affect AVG and other aggregates?
- How do you find which values are repeated, like employees with the same salary?
- How do you add a grand total row to a grouped report?
- How do you calculate each group's percentage of the total?
Subqueries and CTEs
- How do you use a subquery in a WHERE clause?
- What is a correlated subquery?
- How do you find the second highest salary?
- How do you find the Nth highest salary, handling ties?
- What is the difference between IN and EXISTS?
- Why can NOT IN return no rows at all?
- What is a CTE, and why use one?
- What is a recursive CTE?
- What are window functions, and how do ROW_NUMBER, RANK and DENSE_RANK differ?
- What does PARTITION BY do in a window function?
- How do you calculate a running total?
- How do you compare a row with the previous row?
Indexes and normalisation
- What is an index, and how does it make queries faster?
- What is the difference between a clustered and a non-clustered index?
- How does a composite index work, and when won't an index be used?
- What are the costs of indexes, and which columns should you index?
- What are primary keys, foreign keys and other constraints?
- What is normalisation? Explain 1NF, 2NF and 3NF.
- What is denormalisation, and when is it used?
- What is a transaction, and what does ACID mean?
20 practice queries with answers
- List the employees hired in 2023, newest first.
- Show customers who signed up in February 2024 with their number of orders.
- Find the number of delivered orders, total revenue and average order value.
- Find the customer who has spent the most on delivered orders.
- Which department has the highest total salary cost?
- Rohan gets a raise to ₹16 lakh. Who now earns more than their manager?
- Find customers whose orders have all been delivered.
- Show each customer's first order.
- Find customers who ordered in more than one month.
- Find the second highest salary in each department.
- A bug inserted duplicate customers. Delete the duplicates, keeping the oldest record of each.
- Show the average delivered order value by customer city.
- Rank customers by total spend, excluding cancelled orders.
- Show new customer signups per month and the running total of customers.
- List pending orders placed before 1 March 2024, with the customer's name and city.
- Compare each Sales employee's salary with their department average.
- Find departments whose average salary is above the company average.
- For each of Karthik's orders, show the date of the next one.
- Find the most common order amount (the mode), including ties.
- Build a department summary: headcount and top earner, including empty departments.