SQL Interview Questions and How to Answer Them (45+ Questions)
SQL remains one of the most-tested skills in technical interviews — whether you are applying for a data analyst, backend engineer, data engineer, or business intelligence role. Interviewers use SQL questions to evaluate not just syntax knowledge but logical thinking, query optimization instinct, and your ability to model real-world problems as relational data.
This guide covers 45+ SQL interview questions organized by difficulty and topic, with detailed answers, real working code, and the reasoning interviewers want to hear. Bookmark it, work through it section by section, and run every query yourself before your interview.
How to Use This Guide
- Junior/entry-level roles: Focus on questions 1–20 (fundamentals + joins).
- Mid-level roles: Cover questions 1–35 (add window functions, subqueries, and indexes).
- Senior / data engineering roles: Work through all 45+ questions, including performance tuning and schema design.
- Run the code: Every snippet uses standard ANSI SQL unless labeled otherwise. Test in PostgreSQL, MySQL, or SQLite — all are free to set up locally.
Section 1: Core Fundamentals
1. What is SQL and what are its main sub-languages?
How to answer: Name the four sub-languages and give one real example of each. Interviewers want to see you understand that SQL is more than just SELECT.
SQL (Structured Query Language) is the standard language for managing and querying relational databases. It is divided into four sub-languages:
| Sub-language | Purpose | Example |
|---|---|---|
| DDL (Data Definition Language) | Define and modify schema | CREATE TABLE, ALTER TABLE, DROP TABLE |
| DML (Data Manipulation Language) | Read and modify data | SELECT, INSERT, UPDATE, DELETE |
| DCL (Data Control Language) | Manage permissions | GRANT, REVOKE |
| TCL (Transaction Control Language) | Manage transactions | COMMIT, ROLLBACK, SAVEPOINT |
What to add in your answer: Mention that in most day-to-day work you live in DML, but DDL matters a lot for migrations and schema design — showing you think beyond just writing queries.
2. What is the difference between `WHERE` and `HAVING`?
The short answer: WHERE filters rows before grouping. HAVING filters groups after aggregation.
-- WHERE filters individual rows before the GROUP BY runs
SELECT department, COUNT(*) AS headcount
FROM employees
WHERE status = 'active' -- applied to individual rows
GROUP BY department
HAVING COUNT(*) > 5; -- applied to aggregated groupsWhy this matters: A common mistake is trying to use an aggregate function inside a WHERE clause:
-- This FAILS — you cannot reference an aggregate in WHERE
SELECT department, COUNT(*)
FROM employees
WHERE COUNT(*) > 5 -- syntax error
GROUP BY department;
-- This WORKS
SELECT department, COUNT(*)
FROM employees
GROUP BY department
HAVING COUNT(*) > 5;Pro tip for the interview: Mention that WHERE executes before GROUP BY, so it reduces the number of rows that need to be aggregated — making it more performant when you can use it.
3. What is the order of execution of a SQL `SELECT` statement?
Why interviewers ask this: It explains why you cannot use a column alias from SELECT inside a WHERE, and why HAVING can reference aggregates but WHERE cannot.
The logical order of execution:
1. FROM / JOIN -- which tables, which rows qualify from joins
2. WHERE -- filter individual rows
3. GROUP BY -- aggregate rows into groups
4. HAVING -- filter groups
5. SELECT -- compute the output columns
6. DISTINCT -- remove duplicates (if specified)
7. ORDER BY -- sort the result
8. LIMIT / OFFSET -- return a subsetExample to illustrate:
SELECT department, AVG(salary) AS avg_sal -- step 5
FROM employees -- step 1
WHERE hire_date > '2020-01-01' -- step 2
GROUP BY department -- step 3
HAVING AVG(salary) > 60000 -- step 4
ORDER BY avg_sal DESC -- step 7
LIMIT 5; -- step 8Notice ORDER BY can reference the alias avg_sal even though it is defined in SELECT — because ORDER BY runs after SELECT.
4. What is the difference between `DELETE`, `TRUNCATE`, and `DROP`?
| Command | Removes | Can rollback? | Resets auto-increment? | Speed |
|---|---|---|---|---|
| DELETE | Specific rows (or all rows with no WHERE) | Yes (within transaction) | No | Slow on large tables |
| TRUNCATE | All rows | Depends on DB (PostgreSQL: yes; MySQL: no) | Yes | Very fast |
| DROP | Entire table including structure | No (DDL) | N/A | Instant |
-- DELETE: can be targeted and rolled back
BEGIN;
DELETE FROM orders WHERE status = 'cancelled';
ROLLBACK; -- rows come back
-- TRUNCATE: removes all rows, keeps the table structure
TRUNCATE TABLE session_logs;
-- DROP: destroys the table completely
DROP TABLE temp_calculations;What to say in the interview: "I use DELETE when I need a WHERE clause or need the safety of a transaction. I use TRUNCATE when I want to wipe a staging or log table fast. I would never use DROP on production data without a backup."
5. What are `NULL` values and how do you handle them?
NULL means the absence of a known value — it is not zero, not an empty string, not false. This creates three-valued logic (true / false / unknown) in SQL.
-- NULL comparisons with = always return NULL (unknown), not true/false
SELECT * FROM employees WHERE manager_id = NULL; -- returns 0 rows (wrong)
SELECT * FROM employees WHERE manager_id IS NULL; -- correct
-- Use COALESCE to substitute a default value
SELECT name, COALESCE(phone, 'not provided') AS phone
FROM customers;
-- Use NULLIF to turn a specific value into NULL
-- Prevents division-by-zero errors
SELECT total_revenue / NULLIF(total_orders, 0) AS avg_order_value
FROM monthly_summary;Key behavior to mention: Aggregate functions like COUNT, SUM, AVG ignore NULLs automatically — except COUNT(*) which counts all rows including those with NULLs.
-- Difference between COUNT(*) and COUNT(column)
SELECT
COUNT(*) AS total_rows, -- counts all rows
COUNT(salary) AS rows_with_salary -- skips NULLs
FROM employees;6. What are constraints in SQL? Name the most common ones.
Constraints enforce data integrity at the database level — they are the last line of defense against bad data.
CREATE TABLE employees (
id SERIAL PRIMARY KEY, -- PRIMARY KEY: unique + not null
email VARCHAR(255) UNIQUE NOT NULL, -- UNIQUE, NOT NULL
department_id INT REFERENCES departments(id) ON DELETE SET NULL, -- FOREIGN KEY
salary NUMERIC CHECK (salary >= 0), -- CHECK constraint
status VARCHAR(20) DEFAULT 'active' -- DEFAULT value
);| Constraint | What it enforces |
|---|---|
| PRIMARY KEY | Uniquely identifies each row; combines UNIQUE + NOT NULL |
| FOREIGN KEY | Referential integrity between tables |
| UNIQUE | No duplicate values in a column (NULLs allowed in most DBs) |
| NOT NULL | Column cannot be empty |
| CHECK | Custom boolean condition must be true |
| DEFAULT | Value used when none is provided |
Section 2: Joins
7. Explain all types of SQL JOINs with examples.
This is one of the most common SQL interview topics. Know all five and be ready to draw a Venn diagram if asked.
Setup for examples:
-- employees: id, name, department_id
-- departments: id, name
-- Sample data
employees: (1,'Ana',1), (2,'Bob',2), (3,'Carlos',NULL)
departments: (1,'Engineering'), (2,'Marketing'), (3,'HR')INNER JOIN — only rows with matching values in both tables:
SELECT e.name, d.name AS department
FROM employees e
INNER JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing
-- Carlos (no dept) and HR (no employees) are excludedLEFT JOIN (LEFT OUTER JOIN) — all rows from the left table, matching rows from the right (NULLs where no match):
SELECT e.name, d.name AS department
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing, Carlos/NULLRIGHT JOIN (RIGHT OUTER JOIN) — all rows from the right table:
SELECT e.name, d.name AS department
FROM employees e
RIGHT JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing, NULL/HRFULL OUTER JOIN — all rows from both tables:
SELECT e.name, d.name AS department
FROM employees e
FULL OUTER JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing, Carlos/NULL, NULL/HRCROSS JOIN — every combination (Cartesian product):
SELECT e.name, d.name
FROM employees e
CROSS JOIN departments d;
-- Returns 3 * 3 = 9 rows (use carefully — can explode on large tables)SELF JOIN — joining a table to itself:
-- Find employees and their managers (both in the same table)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;8. What is the difference between `UNION` and `UNION ALL`?
Both combine result sets from two SELECT queries. The difference is deduplication.
-- UNION removes duplicate rows (slower — requires sorting/hashing)
SELECT city FROM customers
UNION
SELECT city FROM suppliers;
-- UNION ALL keeps all rows including duplicates (faster)
SELECT city FROM customers
UNION ALL
SELECT city FROM suppliers;Rules: Both queries must have the same number of columns and compatible data types. Column names come from the first query.
When to use which: Use UNION ALL by default (faster). Use UNION only when you explicitly need deduplication.
9. How do you find rows in Table A that have no match in Table B?
This comes up constantly — finding orphaned records, unmatched orders, customers who never purchased, etc.
Method 1: LEFT JOIN + IS NULL (most readable)
SELECT e.id, e.name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id
WHERE d.id IS NULL;
-- Returns employees with no matching departmentMethod 2: NOT EXISTS (often fastest with proper indexes)
SELECT e.id, e.name
FROM employees e
WHERE NOT EXISTS (
SELECT 1
FROM departments d
WHERE d.id = e.department_id
);Method 3: NOT IN (use carefully — fails silently with NULLs)
-- DANGEROUS if department_id contains any NULLs — returns 0 rows
SELECT id, name
FROM employees
WHERE department_id NOT IN (SELECT id FROM departments);
-- Safe version (explicitly exclude NULLs)
SELECT id, name
FROM employees
WHERE department_id NOT IN (
SELECT id FROM departments WHERE id IS NOT NULL
);What to say in the interview: "I prefer NOT EXISTS or LEFT JOIN / IS NULL. I avoid NOT IN when the subquery might return NULLs because of the three-valued logic trap."
10. Write a query to find employees who earn more than their manager.
Classic interview question — tests self-join understanding.
SELECT
e.name AS employee,
e.salary AS employee_salary,
m.name AS manager,
m.salary AS manager_salary
FROM employees e
INNER JOIN employees m ON e.manager_id = m.id
WHERE e.salary > m.salary;Variation: Find the count per department:
SELECT
e.department_id,
COUNT(*) AS count_earning_more_than_manager
FROM employees e
INNER JOIN employees m ON e.manager_id = m.id
WHERE e.salary > m.salary
GROUP BY e.department_id;Section 3: Aggregation and Grouping
11. Write a query to find the second-highest salary.
Multiple approaches — know at least two.
Method 1: Subquery with MAX
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);Method 2: OFFSET (simple, readable)
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;Method 3: Using a CTE with DENSE_RANK (most robust — handles ties)
WITH ranked AS (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
)
SELECT salary AS second_highest
FROM ranked
WHERE rnk = 2
LIMIT 1;What to say: "I prefer the window function approach because it handles ties correctly and is easy to generalize to the Nth highest salary by changing the rnk = 2 condition."
12. Write a query to find duplicate records in a table.
-- Find email addresses that appear more than once
SELECT email, COUNT(*) AS occurrences
FROM customers
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY occurrences DESC;
-- Get all columns for duplicate rows
SELECT *
FROM customers
WHERE email IN (
SELECT email
FROM customers
GROUP BY email
HAVING COUNT(*) > 1
)
ORDER BY email;Deleting duplicates while keeping one copy (common follow-up):
-- Keep the row with the lowest id, delete the rest
DELETE FROM customers
WHERE id NOT IN (
SELECT MIN(id)
FROM customers
GROUP BY email
);
-- PostgreSQL-style using ctid (row identifier)
DELETE FROM customers a
USING customers b
WHERE a.email = b.email
AND a.id > b.id;13. Write a query to get the total sales per product category and the percentage each category represents.
Tests your ability to combine aggregation with a self-referencing calculation.
SELECT
category,
SUM(sale_amount) AS category_total,
ROUND(
SUM(sale_amount) * 100.0 / SUM(SUM(sale_amount)) OVER (), 2
) AS pct_of_total
FROM sales
GROUP BY category
ORDER BY category_total DESC;The key trick: SUM(SUM(sale_amount)) OVER () is a window function applied on top of the grouped aggregate — it gives the grand total without a second subquery.
14. How does `GROUP BY` handle NULLs?
All NULL values are grouped together into a single group — as if NULL equals NULL for grouping purposes (even though NULL = NULL is false in a WHERE clause).
SELECT department_id, COUNT(*) AS headcount
FROM employees
GROUP BY department_id;
-- Employees with NULL department_id appear as one group with department_id = NULLIf you want to include a label for the NULL group:
SELECT COALESCE(department_id::text, 'No Department') AS dept, COUNT(*)
FROM employees
GROUP BY department_id;15. Write a query that calculates a running total.
SELECT
order_date,
sale_amount,
SUM(sale_amount) OVER (ORDER BY order_date) AS running_total
FROM orders
ORDER BY order_date;Running total per customer (partitioned):
SELECT
customer_id,
order_date,
sale_amount,
SUM(sale_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total
FROM orders;Section 4: Window Functions
16. What are window functions and how are they different from aggregate functions?
The key difference: Aggregate functions collapse multiple rows into one. Window functions compute a value for each row based on a "window" of related rows — the original rows are preserved.
-- Aggregate: collapses rows
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
-- Returns one row per department
-- Window function: keeps all rows
SELECT name, department, salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg
FROM employees;
-- Returns all employees PLUS the department average on each rowThe OVER() clause is what makes a function a window function. An empty OVER() means the window is the entire result set.
17. Explain `ROW_NUMBER`, `RANK`, and `DENSE_RANK`. When would you use each?
All three assign numbers to rows, but they handle ties differently:
SELECT
name,
salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num, -- always unique: 1,2,3,4
RANK() OVER (ORDER BY salary DESC) AS rnk, -- gaps after ties: 1,2,2,4
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk -- no gaps: 1,2,2,3
FROM employees;Example output (two employees both earning $80k):
| name | salary | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| Alice | 90000 | 1 | 1 | 1 |
| Bob | 80000 | 2 | 2 | 2 |
| Carol | 80000 | 3 | 2 | 2 |
| Dave | 70000 | 4 | 4 | 3 |
When to use which:
ROW_NUMBER: When you need exactly one row per rank (e.g., deduplication, pagination).RANK: When you want the "sports ranking" feel (ties skip numbers, like Olympic medals).DENSE_RANK: When you want the Nth distinct value (e.g., "find the 3rd highest salary").
18. Write a query to get the top 3 earners per department.
Classic interview question that combines DENSE_RANK with a CTE.
WITH ranked_employees AS (
SELECT
name,
department,
salary,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
FROM employees
)
SELECT name, department, salary, dept_rank
FROM ranked_employees
WHERE dept_rank <= 3
ORDER BY department, dept_rank;Why not just LIMIT 3 per department? Because LIMIT is not partition-aware — you need the window function to rank within each department separately.
19. What is `LAG` and `LEAD`? Write an example.
LAG accesses a previous row's value; LEAD accesses a future row's value — all within the same result set without a self-join.
-- Month-over-month revenue change
SELECT
month,
revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY month) AS revenue_change,
ROUND(
(revenue - LAG(revenue, 1) OVER (ORDER BY month)) * 100.0
/ NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
2
) AS pct_change
FROM monthly_revenue
ORDER BY month;Arguments: LAG(column, offset, default) — offset defaults to 1, default value is returned when there is no prior row (instead of NULL).
20. What is the difference between `ROWS` and `RANGE` in a window frame?
When you write OVER (ORDER BY date ROWS BETWEEN ...), you are defining which rows make up the window for each calculation.
-- ROWS: physical rows relative to current row
SELECT
date,
sales,
SUM(sales) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW -- last 7 rows (by position)
) AS rolling_7_day_sum
FROM daily_sales;
-- RANGE: logical range based on the ORDER BY value
-- RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
-- includes all rows within 6 days of the current date value,
-- regardless of how many physical rows that is
SELECT
date,
sales,
SUM(sales) OVER (
ORDER BY date
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
) AS rolling_7_day_sum
FROM daily_sales;Practical difference: ROWS is positional (fast, deterministic). RANGE is value-based (handles gaps in dates correctly). For most rolling calculations, ROWS is what you want and is more performant.
Section 5: Subqueries and CTEs
21. What is a CTE and when would you use it instead of a subquery?
A CTE (Common Table Expression) is a named temporary result set defined with WITH. It runs once, is readable, and can be referenced multiple times.
-- Subquery version (nested, harder to read)
SELECT name, salary
FROM employees
WHERE department_id IN (
SELECT id FROM departments WHERE budget > 1000000
);
-- CTE version (readable, named)
WITH well_funded_depts AS (
SELECT id FROM departments WHERE budget > 1000000
)
SELECT name, salary
FROM employees
WHERE department_id IN (SELECT id FROM well_funded_depts);When to use CTEs:
- The same subquery is needed more than once.
- The logic has multiple steps that are easier to read top-to-bottom.
- You are writing a recursive query (you must use a CTE for recursion).
- Debugging — you can comment out outer parts and inspect intermediate results.
Important note: In most databases, a CTE is not materialized (it is just a syntax transformation). If you need the result to be computed once and reused, use a temporary table or check your database's MATERIALIZED option (PostgreSQL 12+).
22. Write a recursive CTE to traverse a hierarchy (employee → manager chain).
-- employees table: id, name, manager_id (NULL for the CEO)
WITH RECURSIVE org_chart AS (
-- Base case: start with the top-level employee (no manager)
SELECT id, name, manager_id, 0 AS level, name::text AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive case: join to the CTE itself
SELECT e.id, e.name, e.manager_id, oc.level + 1,
oc.path || ' > ' || e.name
FROM employees e
INNER JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT id, name, level, path
FROM org_chart
ORDER BY path;Output example:
CEO (level 0, path: CEO)
CTO (level 1, path: CEO > CTO)
Dev Lead (level 2, path: CEO > CTO > Dev Lead)
Ana (level 3, path: CEO > CTO > Dev Lead > Ana)Safeguard against infinite loops: Add WHERE level < 10 or use CYCLE detection (PostgreSQL 14+).
23. What is the difference between a correlated subquery and a non-correlated subquery?
Non-correlated: Runs once, independently of the outer query.
-- Inner query runs once, produces a list of dept IDs
SELECT name FROM employees
WHERE department_id IN (
SELECT id FROM departments WHERE location = 'NYC'
);Correlated: References columns from the outer query, so it runs once per row of the outer query.
-- For each employee, the inner query runs with THAT employee's department_id
SELECT e.name, e.salary
FROM employees e
WHERE e.salary > (
SELECT AVG(salary)
FROM employees
WHERE department_id = e.department_id -- correlates to outer query
);Performance implication: Correlated subqueries can be slow on large tables because they execute N times (once per outer row). Often replaceable with a window function or a join:
-- Same result, better performance
SELECT e.name, e.salary
FROM employees e
JOIN (
SELECT department_id, AVG(salary) AS avg_sal
FROM employees
GROUP BY department_id
) dept_avg ON e.department_id = dept_avg.department_id
WHERE e.salary > dept_avg.avg_sal;24. Write a query to find the department with the highest average salary.
-- Simple version
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
ORDER BY avg_salary DESC
LIMIT 1;
-- Robust version (handles ties and includes dept name)
WITH dept_avgs AS (
SELECT
d.name AS department,
AVG(e.salary) AS avg_salary,
RANK() OVER (ORDER BY AVG(e.salary) DESC) AS rnk
FROM employees e
JOIN departments d ON e.department_id = d.id
GROUP BY d.name
)
SELECT department, ROUND(avg_salary, 2) AS avg_salary
FROM dept_avgs
WHERE rnk = 1;Section 6: Data Modification and Transactions
25. Explain ACID properties.
ACID is the foundation of database reliability. Every senior-level SQL interview expects you to know this.
| Property | Meaning | Example |
|---|---|---|
| Atomicity | A transaction succeeds completely or fails completely — no partial updates | Bank transfer: debit AND credit happen, or neither does |
| Consistency | A transaction brings the DB from one valid state to another — all constraints hold | You cannot transfer more money than you have (CHECK constraint) |
| Isolation | Concurrent transactions do not interfere with each other | Two users booking the last seat do not both succeed |
| Durability | Once committed, data survives system failures | A committed order is not lost if the server crashes |
-- Example of atomicity
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE id = 1; -- debit
UPDATE accounts SET balance = balance + 500 WHERE id = 2; -- credit
COMMIT; -- both succeed
-- If anything fails, ROLLBACK automatically reverts both updates26. What are transaction isolation levels?
Isolation levels are a trade-off between data consistency and performance. Know the four standard levels and the anomalies they prevent.
| Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED (default in most DBs) | Prevented | Possible | Possible |
| REPEATABLE READ | Prevented | Prevented | Possible |
| SERIALIZABLE | Prevented | Prevented | Prevented |
- Dirty Read: Reading data from an uncommitted transaction.
- Non-Repeatable Read: Reading the same row twice in one transaction and getting different values (another transaction updated it).
- Phantom Read: Re-running the same query in one transaction and getting different rows (another transaction inserted/deleted rows).
-- Set isolation level in PostgreSQL
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- ... do some work ...
SELECT balance FROM accounts WHERE id = 1; -- guaranteed same result
COMMIT;27. Write an `UPSERT` — insert a row if it does not exist, update it if it does.
-- PostgreSQL: INSERT ... ON CONFLICT
INSERT INTO user_preferences (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON CONFLICT (user_id) DO UPDATE
SET theme = EXCLUDED.theme,
language = EXCLUDED.language,
updated_at = NOW();
-- MySQL: INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO user_preferences (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON DUPLICATE KEY UPDATE
theme = VALUES(theme),
language = VALUES(language);The EXCLUDED table (PostgreSQL) refers to the row that failed to insert — it lets you reference the proposed values in the DO UPDATE clause.
28. How do you update rows in one table based on values from another table?
-- Standard SQL using a subquery
UPDATE employees
SET salary = salary * 1.10
WHERE department_id = (
SELECT id FROM departments WHERE name = 'Engineering'
);
-- PostgreSQL / SQL Server: UPDATE with JOIN (more readable for multiple columns)
UPDATE employees e
SET salary = e.salary * 1.10,
updated_at = NOW()
FROM departments d
WHERE e.department_id = d.id
AND d.name = 'Engineering';
-- MySQL syntax (different!)
UPDATE employees e
JOIN departments d ON e.department_id = d.id
SET e.salary = e.salary * 1.10
WHERE d.name = 'Engineering';Section 7: Indexes and Performance
29. What is a database index and how does it work?
An index is a separate data structure (typically a B-tree) that allows the database to find rows without scanning every row in a table.
-- Create a basic index
CREATE INDEX idx_employees_email ON employees(email);
-- Create a composite index
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);
-- Create a partial index (only indexes rows matching a condition)
CREATE INDEX idx_active_users ON users(email) WHERE status = 'active';
-- Create a unique index (enforces uniqueness as a side effect)
CREATE UNIQUE INDEX idx_unique_email ON customers(email);How a B-tree index works: The index is a sorted copy of the indexed column(s) with pointers back to the actual table rows (heap). A query like WHERE email = 'x@y.com' uses a binary search on the index (O(log n)) instead of a full table scan (O(n)).
When to index:
- Columns used frequently in
WHERE,JOIN ON, andORDER BY. - Foreign key columns (the referencing side is often not indexed automatically).
- Columns with high cardinality (many distinct values).
When NOT to index:
- Columns rarely queried.
- Very low cardinality columns (boolean flags — the DB might just scan the table anyway).
- Tables that are written to very frequently (every insert/update must also update all indexes).
30. What is the difference between a clustered and a non-clustered index?
| Type | Data storage | Count per table | Example |
|---|---|---|---|
| Clustered | Table rows are physically stored in index order | One | Primary key in SQL Server / InnoDB (MySQL) |
| Non-clustered | Separate structure with pointers to the actual rows | Many | Any additional index you create |
In PostgreSQL: PostgreSQL does not have a traditional clustered index. All indexes are non-clustered (heap-based), but you can use CLUSTER tablename USING indexname to physically reorder the table once — though it does not stay sorted on future inserts.
31. What is an `EXPLAIN` plan and how do you read it?
EXPLAIN shows the query execution plan — how the database engine will retrieve your data.
EXPLAIN ANALYZE
SELECT e.name, d.name
FROM employees e
JOIN departments d ON e.department_id = d.id
WHERE e.salary > 80000;Key nodes to recognize:
Seq Scan— full table scan (red flag on large tables).Index Scan— uses an index (good).Index Only Scan— only reads the index, never touches the heap (best).Hash Join— builds a hash table in memory from one input, probes with the other (fast for large joins).Nested Loop— for each row in outer, searches inner (fast when inner is indexed and small).Sort— explicit sort operation (expensive on large data without a supporting index).
Numbers to look at:
cost=start..total— planner's estimated cost (lower is better, relative scale).actual time=start..end— real execution time in milliseconds (withANALYZE).rows— number of rows returned.Buffers: shared hit/read— cache hits vs. disk reads.
32. How would you optimize a slow query?
Step-by-step answer for the interview:
- 1Run
EXPLAIN ANALYZEto identify the bottleneck (Seq Scan? Sort? Nested Loop on a large table?). - 2Check indexes — is the
WHEREcolumn indexed? Is the join column indexed on both sides? - 3Check query logic — are you filtering early (
WHEREbeforeJOIN)? Are you selecting only the columns you need? - 4Look at data volume — does a filter reduce data significantly before expensive operations?
- 5Rewrite if needed — replace correlated subqueries with joins or window functions, use CTEs to isolate expensive computations.
- 6Check table statistics — run
ANALYZE tablenameso the planner has fresh statistics. - 7Consider partial indexes or composite indexes matching the exact query pattern.
-- Before: slow query (correlated subquery runs once per employee row)
SELECT * FROM employees e
WHERE salary = (SELECT MAX(salary) FROM employees WHERE department_id = e.department_id);
-- After: fast equivalent with window function
SELECT * FROM (
SELECT *, MAX(salary) OVER (PARTITION BY department_id) AS max_dept_salary
FROM employees
) sub
WHERE salary = max_dept_salary;Section 8: Schema Design and Normalization
33. What are the normal forms in database design?
Normalization is the process of organizing tables to reduce redundancy and improve data integrity.
1NF (First Normal Form):
- Each cell contains a single atomic value.
- Each column has a consistent data type.
- No repeating groups.
-- Bad (1NF violation — phone_numbers is multi-valued)
customers: id | name | phone_numbers
1 | Ana | "555-1234, 555-5678"
-- Good (1NF)
customer_phones: customer_id | phone_number
1 | 555-1234
1 | 555-56782NF: Must be in 1NF + every non-key column depends on the ENTIRE primary key (no partial dependency — only relevant for composite keys).
3NF: Must be in 2NF + no transitive dependencies (non-key columns depend only on the key, not on other non-key columns).
-- Bad (3NF violation: zip_code determines city, not the employee id)
employees: id | name | zip_code | city
-- Good (3NF)
employees: id | name | zip_code
zip_codes: zip_code | cityBCNF (Boyce-Codd Normal Form): Stricter than 3NF — every determinant must be a candidate key.
When to denormalize: For read-heavy analytical workloads (data warehouses), denormalization reduces joins and improves query speed. Normalization is for OLTP; star/snowflake schemas for OLAP.
34. Design a schema for a simple e-commerce order system.
This is a common system design variant of SQL interviews.
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
stock_qty INTEGER NOT NULL DEFAULT 0 CHECK (stock_qty >= 0)
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW(),
total_amount NUMERIC(12, 2) -- denormalized for fast reads
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10, 2) NOT NULL, -- snapshot price at time of order
UNIQUE (order_id, product_id)
);
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);Design decisions to explain:
unit_priceonorder_itemsis a price snapshot — because product prices change over time, you never want to recalculate historical order totals from the current price.ON DELETE CASCADEonorder_items— deleting an order removes its line items automatically.- Separate indexes on foreign keys because most databases do not create them automatically (PostgreSQL does not; MySQL InnoDB does).
35. What is the difference between OLTP and OLAP databases?
| | OLTP | OLAP |
|---|---|---|
| Purpose | Day-to-day operations | Analytics and reporting |
| Query type | Many small reads/writes | Few complex, large reads |
| Data model | Normalized (3NF) | Denormalized (star/snowflake) |
| Latency target | Milliseconds | Seconds to minutes |
| Example systems | PostgreSQL, MySQL | Snowflake, BigQuery, Redshift |
| Indexing strategy | Many targeted indexes | Columnar storage, partitioning |
Section 9: Advanced Queries
36. Write a query to pivot rows into columns.
Given: A scores table with student_id, subject, score. Output one row per student with columns math, science, english.
-- Using conditional aggregation (works everywhere)
SELECT
student_id,
MAX(CASE WHEN subject = 'math' THEN score END) AS math,
MAX(CASE WHEN subject = 'science' THEN score END) AS science,
MAX(CASE WHEN subject = 'english' THEN score END) AS english
FROM scores
GROUP BY student_id;
-- PostgreSQL: using crosstab (tablefunc extension)
SELECT * FROM crosstab(
'SELECT student_id, subject, score FROM scores ORDER BY 1,2',
'SELECT DISTINCT subject FROM scores ORDER BY 1'
) AS pivot(student_id INT, english NUMERIC, math NUMERIC, science NUMERIC);37. What is a materialized view and when would you use one?
A view is a saved query — it runs every time you query it. A materialized view is a saved query whose results are stored physically — like a cached table.
-- Create a materialized view of expensive aggregation
CREATE MATERIALIZED VIEW monthly_sales_summary AS
SELECT
DATE_TRUNC('month', order_date) AS month,
product_category,
SUM(sale_amount) AS total_sales,
COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
JOIN order_items USING (order_id)
JOIN products USING (product_id)
GROUP BY 1, 2;
-- Refresh the materialized view (runs the underlying query again)
REFRESH MATERIALIZED VIEW monthly_sales_summary;
-- Refresh without locking reads (PostgreSQL 9.4+)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales_summary;When to use: Dashboard queries that are too slow to run on-demand, reporting aggregations, anything that takes multiple seconds and is acceptable to be slightly stale (refresh on a schedule via cron or event trigger).
38. Write a query to detect gaps in a sequence (find missing IDs or dates).
-- Find missing order IDs in a sequence
WITH expected AS (
SELECT generate_series(
(SELECT MIN(id) FROM orders),
(SELECT MAX(id) FROM orders)
) AS expected_id
)
SELECT expected_id AS missing_id
FROM expected
WHERE expected_id NOT IN (SELECT id FROM orders);
-- Find missing dates in a date series
WITH date_range AS (
SELECT generate_series(
'2024-01-01'::date,
'2024-12-31'::date,
'1 day'::interval
)::date AS expected_date
)
SELECT expected_date AS missing_date
FROM date_range
WHERE expected_date NOT IN (SELECT DISTINCT sale_date FROM daily_sales);39. Write a query to calculate a 7-day rolling average.
SELECT
sale_date,
daily_revenue,
ROUND(
AVG(daily_revenue) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2
) AS rolling_7_day_avg
FROM daily_sales
ORDER BY sale_date;What to mention: "I use ROWS BETWEEN 6 PRECEDING AND CURRENT ROW to include 7 days (the current day plus 6 previous days). If the data has gaps (missing dates), I would first generate a complete date series and LEFT JOIN the actual sales data to it, filling NULLs with 0."
40. What is `COALESCE` vs `NULLIF` vs `ISNULL`?
-- COALESCE: returns the first non-NULL argument (standard SQL, works everywhere)
SELECT COALESCE(phone, mobile, 'no contact') FROM customers;
-- NULLIF: returns NULL if two expressions are equal, otherwise returns the first
-- Most common use: prevent division by zero
SELECT total / NULLIF(denominator, 0) AS ratio FROM metrics;
-- ISNULL: SQL Server/Sybase only — equivalent to COALESCE with 2 arguments
-- Not standard SQL; prefer COALESCE for portability
SELECT ISNULL(phone, 'no phone') FROM customers; -- SQL Server only
-- NVL: Oracle-specific equivalent of COALESCE with 2 arguments
SELECT NVL(phone, 'no phone') FROM customers; -- Oracle only41. Write a query to transpose/unpivot columns into rows.
Given: A quarterly_sales table with columns q1, q2, q3, q4. Output one row per quarter per product.
-- Using UNION ALL (universally portable)
SELECT product_id, 'Q1' AS quarter, q1 AS revenue FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q2', q2 FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q3', q3 FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q4', q4 FROM quarterly_sales
ORDER BY product_id, quarter;
-- PostgreSQL: VALUES with a CROSS JOIN LATERAL (cleaner for many columns)
SELECT qs.product_id, v.quarter, v.revenue
FROM quarterly_sales qs
CROSS JOIN LATERAL (VALUES
('Q1', qs.q1),
('Q2', qs.q2),
('Q3', qs.q3),
('Q4', qs.q4)
) v(quarter, revenue);42. How do you handle slowly changing dimensions (SCD Type 2)?
SCD Type 2 tracks historical changes by adding new rows with validity dates rather than overwriting old values — critical knowledge for data warehouse roles.
CREATE TABLE dim_customer (
surrogate_key SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL, -- business/natural key
name VARCHAR(100),
email VARCHAR(255),
city VARCHAR(100),
valid_from DATE NOT NULL,
valid_to DATE, -- NULL means current record
is_current BOOLEAN DEFAULT TRUE
);
-- When a customer changes their city:
-- Step 1: Expire the old record
UPDATE dim_customer
SET valid_to = CURRENT_DATE - 1,
is_current = FALSE
WHERE customer_id = 42 AND is_current = TRUE;
-- Step 2: Insert the new record
INSERT INTO dim_customer (customer_id, name, email, city, valid_from, is_current)
VALUES (42, 'Ana García', 'ana@email.com', 'Buenos Aires', CURRENT_DATE, TRUE);
-- Query: what city was customer 42 in on 2024-06-15?
SELECT city
FROM dim_customer
WHERE customer_id = 42
AND valid_from <= '2024-06-15'
AND (valid_to >= '2024-06-15' OR valid_to IS NULL);43. Write a query to find consecutive days a user was active (streaks).
This is a notoriously tricky SQL problem — a favorite in data engineer and analyst interviews.
-- Method: date - ROW_NUMBER() trick
-- Consecutive dates produce the same "group date" when you subtract their row number
WITH user_activity AS (
SELECT DISTINCT user_id, activity_date
FROM events
),
ranked AS (
SELECT
user_id,
activity_date,
activity_date - CAST(ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY activity_date
) AS INT) AS streak_group
FROM user_activity
),
streaks AS (
SELECT
user_id,
streak_group,
MIN(activity_date) AS streak_start,
MAX(activity_date) AS streak_end,
COUNT(*) AS streak_length
FROM ranked
GROUP BY user_id, streak_group
)
SELECT user_id, streak_start, streak_end, streak_length
FROM streaks
ORDER BY streak_length DESC;Why the trick works: If a user was active on Jan 1, 2, 3 (consecutive), their row numbers are 1, 2, 3. Subtracting: Jan 1 - 1 = Dec 31, Jan 2 - 2 = Dec 31, Jan 3 - 3 = Dec 31. Same date → same group. A gap in activity breaks the equality.
44. How do you write a query that returns every Nth row?
-- Return every 5th row (row 5, 10, 15, ...)
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM employees
) numbered
WHERE rn % 5 = 0;
-- In PostgreSQL, you can also use the ctid trick for approximation
-- but ROW_NUMBER() is always correct and portable45. What is a stored procedure vs. a function vs. a trigger?
| Object | Returns value? | Can modify data? | Called how? |
|---|---|---|---|
| Stored Procedure | Optional (via OUT params) | Yes | CALL procedure_name() |
| Function | Required | No (in pure SQL functions) / Yes (in PostgreSQL with VOLATILE) | SELECT function_name() or in expressions |
| Trigger | N/A (returns trigger) | Yes | Automatically on INSERT/UPDATE/DELETE |
-- Function: calculate total order value
CREATE OR REPLACE FUNCTION get_order_total(p_order_id INT)
RETURNS NUMERIC AS $$
SELECT SUM(quantity * unit_price)
FROM order_items
WHERE order_id = p_order_id;
$$ LANGUAGE SQL STABLE;
-- Usage
SELECT id, get_order_total(id) AS total FROM orders;
-- Trigger: automatically update updated_at timestamp
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_employees_updated_at
BEFORE UPDATE ON employees
FOR EACH ROW EXECUTE FUNCTION set_updated_at();46. Explain deadlocks and how to prevent them.
A deadlock occurs when two transactions each hold a lock that the other needs — they wait forever.
Transaction A: locks row 1, waits for row 2
Transaction B: locks row 2, waits for row 1
→ Neither can proceedPrevention strategies:
- 1Always acquire locks in the same order across all transactions (e.g., always lock accounts by ascending ID).
- 2Keep transactions short — acquire locks as late as possible and release as soon as possible.
- 3Use
SELECT ... FOR UPDATE NOWAITto fail fast rather than wait. - 4Set a
lock_timeoutso a transaction gives up after a reasonable wait.
-- PostgreSQL: lock rows in a consistent order to prevent deadlocks
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- Process...
COMMIT;
-- Fail immediately if lock cannot be acquired (avoids long waits)
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- Give up after 5 seconds
SET lock_timeout = '5s';47. Write a query using `FILTER` to compute conditional aggregates.
FILTER is a cleaner alternative to CASE WHEN inside aggregate functions (PostgreSQL, DuckDB).
-- CASE WHEN approach (works everywhere)
SELECT
COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed_orders,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled_orders,
SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END) AS completed_revenue
FROM orders;
-- FILTER approach (cleaner, PostgreSQL/DuckDB)
SELECT
COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_orders,
SUM(total_amount) FILTER (WHERE status = 'completed') AS completed_revenue
FROM orders;48. How would you find the median salary in SQL?
SQL does not have a native MEDIAN function in most databases (PostgreSQL has PERCENTILE_CONT).
-- PostgreSQL: PERCENTILE_CONT
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary
FROM employees;
-- Standard SQL workaround using window functions
SELECT AVG(salary) AS median_salary
FROM (
SELECT salary,
COUNT(*) OVER () AS total_count,
ROW_NUMBER() OVER (ORDER BY salary) AS rn_asc,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn_desc
FROM employees
) t
WHERE rn_asc IN (total_count / 2, total_count / 2 + 1, (total_count + 1) / 2)
AND rn_desc IN (total_count / 2, total_count / 2 + 1, (total_count + 1) / 2);Section 10: Behavioral and Conceptual Questions
49. What is the difference between a primary key and a unique key?
| | Primary Key | Unique Key |
|---|---|---|
| NULLs allowed? | No | Yes (usually one NULL per column) |
| Count per table | Exactly one | Multiple allowed |
| Clustered index? | Yes (in most DBs) | No |
| Purpose | Main row identifier | Enforce uniqueness on alternate columns |
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- primary key: unique + not null + clustered
email VARCHAR(255) UNIQUE, -- unique key: unique but can be null (bad practice here)
ssn VARCHAR(11) UNIQUE -- alternate unique identifier
);50. How would you find all tables in a database that contain a specific column name?
Tests your knowledge of information schema — useful for exploring unfamiliar databases.
-- Standard SQL (works in PostgreSQL, MySQL, SQL Server)
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ILIKE '%email%' -- case-insensitive search
ORDER BY table_schema, table_name;
-- PostgreSQL: find all foreign keys referencing a specific table
SELECT
conname AS constraint_name,
conrelid::regclass AS table_name,
a.attname AS column_name,
confrelid::regclass AS referenced_table
FROM pg_constraint
JOIN pg_attribute a ON a.attrelid = conrelid AND a.attnum = ANY(conkey)
WHERE contype = 'f' -- 'f' = foreign key
AND confrelid = 'customers'::regclass;Bonus: 5 Common Patterns You Need to Memorize
Pattern 1: Find records updated in the last N days
SELECT * FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days';Pattern 2: Pagination with LIMIT/OFFSET
-- Page 3 of 20 results per page (0-indexed pages)
SELECT * FROM products
ORDER BY id
LIMIT 20 OFFSET 40; -- (page_number - 1) * page_sizePattern 3: String aggregation (group rows into a comma-separated string)
-- PostgreSQL
SELECT order_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products
FROM order_items JOIN products USING (product_id)
GROUP BY order_id;
-- MySQL
SELECT order_id, GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', ')
FROM order_items JOIN products USING (product_id)
GROUP BY order_id;Pattern 4: Insert from a SELECT
INSERT INTO archived_orders (id, customer_id, total, archived_at)
SELECT id, customer_id, total, NOW()
FROM orders
WHERE created_at < '2023-01-01' AND status = 'completed';Pattern 5: Conditional update (update only specific columns)
UPDATE employees
SET salary = CASE
WHEN performance_rating = 'A' THEN salary * 1.15
WHEN performance_rating = 'B' THEN salary * 1.08
WHEN performance_rating = 'C' THEN salary * 1.03
ELSE salary -- no change for others
END
WHERE review_year = 2024;How to Prepare for Your SQL Interview
Week 1: Nail fundamentals (questions 1–15). Run every query. Understand the WHY behind each answer, not just the syntax.
Week 2: Practice joins and aggregations until they feel automatic. Write 10 join queries on a real dataset — download a free one from Kaggle or use PostgreSQL's built-in dvdrental sample database.
Week 3: Master window functions. They appear in nearly every mid-level and senior interview. ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM OVER, AVG OVER — know all of them.
Week 4: System design and performance. Read execution plans. Practice EXPLAIN ANALYZE on your own queries.
The day before: Review the streak problem (Q43), the top-N per group pattern (Q18), and the self-join salary comparison (Q10). These three come up constantly.
In the interview itself:
- Think out loud. Interviewers want to see your reasoning process.
- State assumptions before writing code ("I am assuming
salarycan be NULL — I will useNULLIFto handle division"). - Mention trade-offs ("This works, but on a table with 50 million rows I would add an index on
department_idand check the query plan first"). - If you know multiple approaches, name them and say which one you prefer and why.
SQL fluency is something you build by doing, not by reading. Run these queries. Break them. Change the data. The interview is not testing whether you have memorized syntax — it is testing whether you can think through a data problem under pressure.