Top 65 SQL Interview Questions and Answers (2026)

The 65 SQL questions interviewers actually ask, with direct answers, runnable queries, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.

65 questions with answers

What Is SQL?

Key Takeaways

  • SQL (Structured Query Language) is the declarative language relational databases use to define, query, and change data. You The result you want, and the planner decides how to fetch it.
  • One language covers every major system: PostgreSQL, MySQL, SQL Server, Oracle, and SQLite share the same core with small dialect differences in function names and syntax.
  • Interviews test judgment, not memorized syntax: joins, NULL logic, window functions, indexes, and schema-design trade-offs reveal practical data fluency in minutes.
  • This page is a question bank. Work your tier first, read one tier up, and type every query, because a live round tests recall, not recognition.

SQL (Structured Query Language) is the declarative language relational databases use for defining, querying, and changing data. You state what result you want, and the database's query planner decides how to fetch it, which is a different mental model from procedural languages where you spell out each step. Every major system, PostgreSQL, MySQL, SQL Server, Oracle, and SQLite, implements the same core with its own dialect, so the concepts transfer even when function names change. Interviewers reach for SQL because nearly every engineering, data, and analytics role touches a relational database, and the skill is hard to fake in person: ten minutes of joins and aggregation reveals more about practical data fluency than most resume lines. This page collects the 65 questions that come up most, from SELECT fundamentals to index internals and schema-design judgment, each with a direct answer you can give and a query you can run. Answers use standard SQL, with PostgreSQL syntax where dialects differ. If you're still building fundamentals, the official tutorial from the PostgreSQL Global Development Group is the canonical path; increasingly the first SQL round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.

65Questions with answers on this page
3Difficulty tiers: freshers, intermediate, experienced
48Runnable SQL snippets you can practice from
45-60 minTypical length of a SQL technical round

Watch: SQL Course for Beginners [Full Course]

Video: SQL Course for Beginners [Full Course] (Programming with Mosh, YouTube)

Test yourself and earn a certificate

10 quick questions. Score 70%+ to download your SQL certificate.

Jump to quiz

All Questions on This Page

65 questions
SQL Interview Questions for Freshers
  1. 1. What is SQL and why do interviewers test it?
  2. 2. What does a full SELECT statement look like, and what does each clause do?
  3. 3. Why do experienced developers avoid SELECT * in production code?
  4. 4. How does the WHERE clause filter rows, and which operators does it support?
  5. 5. What does DISTINCT do, and when is it a warning sign?
  6. 6. How do ORDER BY and LIMIT work together?
  7. 7. In what order does a database logically process the clauses of a query?
  8. 8. What do the aggregate functions do, and what is the difference between COUNT(*) and COUNT(column)?
  9. 9. How does GROUP BY work?
  10. 10. What is the difference between WHERE and HAVING?
  11. 11. What is an INNER JOIN?
  12. 12. What is the difference between LEFT JOIN and RIGHT JOIN?
  13. 13. What does a FULL OUTER JOIN do, and when would you use one?
  14. 14. What is a CROSS JOIN and is it ever deliberate?
  15. 15. What is a self join, and what is the classic example?
  16. 16. Why does WHERE column = NULL never match anything?
  17. 17. What do COALESCE and NULLIF do?
  18. 18. What is the difference between a primary key and a unique constraint?
  19. 19. What is a foreign key and what do the ON DELETE options mean?
  20. 20. Which constraints does SQL provide, and why put rules in the database at all?
  21. 21. What is the difference between UNION and UNION ALL?
  22. 22. What is the difference between DELETE, TRUNCATE, and DROP?
  23. 23. What are DDL, DML, DCL, and TCL?
  24. 24. What is a subquery and where can one appear?
  25. 25. How does the CASE expression work?
SQL Intermediate Interview Questions
  1. 26. What is a CTE, and when do you use one instead of a subquery?
  2. 27. What is a correlated subquery and what does it cost?
  3. 28. When do you use EXISTS instead of IN?
  4. 29. What is a window function and how does it differ from GROUP BY?
  5. 30. What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
  6. 31. What do LAG and LEAD do?
  7. 32. How do you compute a running total in SQL?
  8. 33. Why can't you use a window function in WHERE, and what do you do instead?
  9. 34. How do you find the second-highest salary?
  10. 35. How do you get the top N rows per group?
  11. 36. How do you find duplicate rows in a table?
  12. 37. How do you delete duplicates while keeping one row from each set?
  13. 38. How do you find gaps in a sequence, like missing invoice numbers?
  14. 39. How do you pivot rows into columns?
  15. 40. What is a view, and how is a materialized view different?
  16. 41. What is an index and how does it speed up queries?
  17. 42. What is normalization, and what do 1NF, 2NF, and 3NF mean?
  18. 43. When would you deliberately denormalize?
  19. 44. What is a transaction and how do COMMIT and ROLLBACK work?
  20. 45. What does ACID stand for?
  21. 46. How do you work with dates and times in SQL?
  22. 47. How do you concatenate values from multiple rows into one string?
SQL Interview Questions for Experienced Developers
  1. 48. How does a B-tree index actually work?
  2. 49. How do composite indexes work, and why does column order matter?
  3. 50. What is a covering index and an index-only scan?
  4. 51. When do indexes hurt performance?
  5. 52. What makes a query sargable, and what breaks index use?
  6. 53. How do you read an EXPLAIN plan?
  7. 54. A query that was fast is now slow in production. Walk through your approach.
  8. 55. What is the N+1 query problem and how do you fix it?
  9. 56. What are the transaction isolation levels and which anomalies does each allow?
  10. 57. What is MVCC and why does it mean readers don't block writers?
  11. 58. What is a deadlock and how do you prevent it?
  12. 59. What is the difference between pessimistic and optimistic locking?
  13. 60. Why is OFFSET pagination slow, and what is keyset pagination?
  14. 61. How do you write an upsert, and why not SELECT-then-INSERT?
  15. 62. Surrogate keys or natural keys: how do you choose?
  16. 63. Design question: how would you model orders for an e-commerce system?
  17. 64. Soft delete or hard delete: how do you decide, and what does soft delete cost?
  18. 65. What data type do you use for money, and why do types matter so much?

SQL Interview Questions for Freshers

Freshers25 questions

The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.

Q1. What is SQL and why do interviewers test it?

SQL (Structured Query Language) is the declarative language relational databases use for defining, querying, and changing data. You state what result you want, and the database's query planner decides how to fetch it. Every major system, PostgreSQL, MySQL, SQL Server, Oracle, SQLite, implements the same core with its own dialect.

Interviewers test it because nearly every engineering, data, and analytics role touches a relational database, and SQL skill is hard to fake in person. Ten minutes of joins and aggregation reveals more about practical data fluency than most resume lines.

Key point: Lead with 'declarative': saying the planner chooses the execution strategy signals you know there's an engine underneath, which is where senior follow-ups go.

Watch a deeper explanation

Video: SQL Tutorial - Full Database Course for Beginners (freeCodeCamp.org, YouTube)

Q2. What does a full SELECT statement look like, and what does each clause do?

A SELECT statement reads data: FROM names the tables, WHERE filters rows, GROUP BY collapses rows into groups, HAVING filters those groups, SELECT picks and computes the output columns, ORDER BY sorts the result, and LIMIT caps how many rows come back. Only SELECT and FROM are required.

Being able to recite what each clause does, in order, is the foundation for half the questions in a SQL round, including the classic ones about WHERE versus HAVING and why aliases fail in some clauses.

sql
SELECT department, COUNT(*) AS headcount
FROM employees
WHERE hire_date >= '2024-01-01'
GROUP BY department
HAVING COUNT(*) > 5
ORDER BY headcount DESC
LIMIT 10;

How a query is logically processed

1FROM / JOIN
assemble the source rows from tables
2WHERE
filter individual rows (no aggregates yet)
3GROUP BY
collapse rows into groups
4HAVING
filter groups using aggregates
5SELECT
compute output columns and window functions
6ORDER BY / LIMIT
sort the result, then cap the rows

You write SELECT first, but the database evaluates it near the end. That gap explains WHERE vs HAVING and why a SELECT alias fails in WHERE.

Q3. Why do experienced developers avoid SELECT * in production code?

SELECT * fetches every column whether the application needs it or not, which wastes I/O and network transfer, blocks index-only scans that could have answered the query from an index alone, and silently changes the result shape whenever someone adds a column to the table.

It's fine for interactive exploration. In application code, listing columns explicitly documents intent, keeps results stable under schema changes, and lets the database do less work. If a table later gains a wide JSON or text column, every SELECT * on it silently starts dragging that payload across the network.

Key point: The index-only scan point is what separates this answer from a memorized style rule. It connects a habit to a measurable performance mechanism.

Q4. How does the WHERE clause filter rows, and which operators does it support?

WHERE evaluates a condition for every candidate row and keeps only the rows where it's true. It supports comparisons (=, <>, <, >=), boolean combinators (AND, OR, NOT), range checks with BETWEEN, list membership with IN, pattern matching with LIKE, and NULL tests with IS NULL.

Conditions can combine freely with parentheses. The one operator that never works the way beginners expect is = NULL, which is its own classic question. For LIKE, % matches any run of characters and _ matches exactly one; ILIKE is PostgreSQL's case-insensitive variant.

sql
SELECT name, salary
FROM employees
WHERE department = 'Engineering'
  AND salary BETWEEN 60000 AND 90000
  AND name LIKE 'A%'
  AND manager_id IS NOT NULL;

Q5. What does DISTINCT do, and when is it a warning sign?

DISTINCT removes duplicate rows from the result set, comparing the full set of selected columns. COUNT(DISTINCT column) counts unique values of one column. Deduplication isn't free: the database has to sort or hash the whole result to find the duplicates.

It becomes a warning sign when it's papering over a join that multiplied rows. If you find yourself adding DISTINCT to fix an inflated result, the real bug is usually in the join condition. Related and worth naming: DISTINCT ON (column) is a PostgreSQL extension that keeps one full row per value.

sql
SELECT DISTINCT department
FROM employees;

SELECT COUNT(DISTINCT department) AS dept_count
FROM employees;

Key point: Volunteering the 'DISTINCT hiding a bad join' insight turns a definition question into a debugging story, which is exactly what the technical value is.

Q6. How do ORDER BY and LIMIT work together?

ORDER BY sorts the result by one or more columns, each ascending or descending; LIMIT then caps how many rows are returned, and OFFSET skips rows before counting. Without ORDER BY, row order is undefined, so LIMIT without a sort returns an arbitrary slice.

Two details worth adding: sort columns can be expressions or aliases, and NULL ordering is controllable in PostgreSQL with NULLS FIRST or NULLS LAST. OFFSET also gets expensive at depth, since skipped rows are still generated; the keyset alternative appears in the experienced tier.

sql
SELECT name, salary
FROM employees
ORDER BY salary DESC, name ASC
LIMIT 5 OFFSET 10;   -- rows 11 through 15 of the sorted result

Q7. In what order does a database logically process the clauses of a query?

Logically: FROM and JOINs first, then WHERE, then GROUP BY, then HAVING, then SELECT (including window functions), then DISTINCT, then ORDER BY, then LIMIT. That's different from the written order, where SELECT comes first, and the gap explains several classic errors.

It's why a column alias defined in SELECT can't be used in WHERE (WHERE runs earlier), why HAVING can see aggregates but WHERE can't, and why ORDER BY is the one clause that can use aliases everywhere. Databases are free to physically execute in any order that preserves these semantics; the logical order is the contract, not the plan.

Key point: This is the highest-value fact in the freshers tier: it answers WHERE vs HAVING, alias errors, and window function placement all at once.

Q8. What do the aggregate functions do, and what is the difference between COUNT(*) and COUNT(column)?

Aggregates collapse many rows into one value: COUNT counts rows, SUM adds, AVG averages, MIN and MAX find extremes. COUNT(*) counts all rows in the group; COUNT(column) counts only rows where that column is not NULL; COUNT(DISTINCT column) counts unique non-NULL values.

Every aggregate except COUNT(*) ignores NULLs, which quietly changes results: AVG(commission) averages only the rows that have a commission, not all rows. One myth worth preempting: COUNT(1) is not faster than COUNT(*); they compile to the same operation in every major database.

sql
SELECT COUNT(*)                 AS all_rows,
       COUNT(commission)        AS rows_with_commission,
       COUNT(DISTINCT department) AS departments,
       AVG(salary)              AS avg_salary
FROM employees;

Key point: The NULL behavior of aggregates is the follow-up. 'AVG ignores NULLs, so decide whether missing means zero or means absent' is the sentence to have ready.

Q9. How does GROUP BY work?

GROUP BY partitions rows into groups that share the same values in the listed columns, then aggregates run once per group instead of once over the whole table. Every column in SELECT must either appear in GROUP BY or sit inside an aggregate function.

That rule exists because a group of many rows has no single value for an ungrouped column. PostgreSQL raises an error; MySQL's legacy modes used to pick an arbitrary value, which is worse. PostgreSQL also accepts the select-list alias or a column position in GROUP BY, though explicit names read better in reviews.

sql
SELECT department,
       COUNT(*)    AS headcount,
       AVG(salary) AS avg_salary
FROM employees
GROUP BY department;

Q10. What is the difference between WHERE and HAVING?

WHERE filters individual rows before grouping happens, so it can't reference aggregates. HAVING filters groups after aggregation, so it exists precisely to express conditions like 'departments with more than five people'. Using both in one query is normal and often optimal.

Filtering early is cheaper: push every condition that doesn't need an aggregate into WHERE, so fewer rows reach the grouping step at all. HAVING without GROUP BY is legal too: it treats the entire result as one group, a corner that occasionally appears as a trick question.

sql
SELECT department, AVG(salary) AS avg_salary
FROM employees
WHERE status = 'active'          -- filters rows before grouping
GROUP BY department
HAVING AVG(salary) > 70000;      -- filters groups after aggregation
WHEREHAVING
FiltersIndividual rowsGroups
RunsBefore GROUP BYAfter GROUP BY
Can use aggregatesNoYes
Performance roleCuts rows early, cheapRuns after the expensive grouping

Key point: The closing step is the performance sentence: 'anything that can go in WHERE should'. It shows you think about cost, not just syntax.

Q11. What is an INNER JOIN?

An INNER JOIN combines rows from two tables where the join condition matches, and drops rows from either side that have no partner. It's the default JOIN: writing JOIN alone means INNER JOIN. The result contains only customer-order pairs that actually exist, in this example.

Rows multiply when matches aren't one-to-one: a customer with three orders appears three times. That multiplication is the root cause behind many double-counted aggregates. The expected cardinality out loud (one customer to many orders) before writing the join; it's how you catch fan-out before it corrupts a SUM.

sql
SELECT o.id, o.total, c.name
FROM orders o
INNER JOIN customers c ON c.id = o.customer_id;

How an INNER JOIN matches rows

1Take each left row
one row from orders
2Find matching right rows
customers where c.id = o.customer_id
3Emit one row per match
a left row with three matches produces three output rows
4Drop the unmatched
left or right rows with no partner disappear entirely

The 'one row per match' step is why joins multiply rows and can double-count a SUM. The expected cardinality before you write the join.

Q12. What is the difference between LEFT JOIN and RIGHT JOIN?

A LEFT JOIN keeps every row from the left table and fills the right side with NULLs where no match exists. A RIGHT JOIN is the mirror image: it keeps every row from the right table. They're interchangeable by swapping table order, so most teams standardize on LEFT JOIN.

The unmatched-rows-become-NULL behavior enables a classic pattern: LEFT JOIN then filter WHERE right_table.id IS NULL to find rows with no partner, like customers who never ordered. One trap: filtering the right table in WHERE quietly turns a LEFT JOIN back into an INNER one; those conditions belong in the ON clause.

sql
-- every customer, with orders where they exist
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;

-- customers with no orders at all
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

Key point: The anti-join pattern (LEFT JOIN plus IS NULL) is the expected follow-up. The query pattern is usually enough to settle the JOIN topic.

Watch a deeper explanation

Video: Intermediate SQL Tutorial | Inner/Outer Joins | Use Cases (Alex The Analyst, YouTube)

Q13. What does a FULL OUTER JOIN do, and when would you use one?

A FULL OUTER JOIN keeps every row from both tables: matched pairs join normally, and unmatched rows from either side appear once with NULLs filling the missing half. It's the union of a LEFT and a RIGHT JOIN over the same condition.

The everyday use is reconciliation: comparing two systems that should agree, like billing accounts against CRM accounts, where rows missing on either side are exactly the discrepancies you're hunting. MySQL doesn't implement FULL OUTER JOIN at all; the workaround there is a LEFT JOIN unioned with the unmatched half of a RIGHT JOIN.

sql
SELECT a.id AS billing_id, b.id AS crm_id
FROM billing_accounts a
FULL OUTER JOIN crm_accounts b ON a.email = b.email
WHERE a.id IS NULL OR b.id IS NULL;   -- rows present on only one side

Q14. What is a CROSS JOIN and is it ever deliberate?

A CROSS JOIN pairs every row of one table with every row of another: the Cartesian product, with no join condition at all. A 1,000-row table crossed with a 10,000-row table produces ten million rows, which is why it's usually an accident (a missing ON clause).

Deliberate uses exist: generating a date-times-product grid so reports show zero-sales days, or building test combinations. If you write one on purpose, comment it. The same product also appears by accident when someone lists two tables in FROM with a comma and forgets the WHERE condition.

sql
-- one row per calendar day per product, so reports can show zeros
SELECT d.day, p.id AS product_id
FROM calendar_days d
CROSS JOIN products p;

Q15. What is a self join, and what is the classic example?

A self join joins a table to itself, using two aliases so the database treats one physical table as two logical ones. It answers questions about relationships between rows of the same table, and the canonical example is an employees table where manager_id points at another employee's id.

The LEFT variant matters here: an INNER self join would silently drop the CEO, whose manager_id is NULL.

sql
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;

Key point: why LEFT (the CEO has no manager) matters.

Q16. Why does WHERE column = NULL never match anything?

NULL means unknown, and SQL uses three-valued logic: comparisons involving NULL evaluate to UNKNOWN rather than TRUE or FALSE, and WHERE only keeps rows where the condition is TRUE. So column = NULL, and even NULL = NULL, match nothing. The correct tests are IS NULL and IS NOT NULL.

The same logic hides a nastier trap: NOT IN against a subquery that returns any NULL matches zero rows, because every comparison collapses to UNKNOWN. NOT EXISTS is the safe form.

sql
SELECT * FROM employees WHERE commission = NULL;    -- always zero rows
SELECT * FROM employees WHERE commission IS NULL;   -- correct

-- the NOT IN trap: one NULL in the list matches nothing
SELECT name FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);   -- risky if customer_id is nullable

Key point: three-valued logic and the NOT IN trap matter.

Q17. What do COALESCE and NULLIF do?

COALESCE returns the first non-NULL argument in its list, which makes it the standard way to substitute defaults for missing data: COALESCE(commission, 0). NULLIF returns NULL when its two arguments are equal, and its main job is turning a zero into a NULL to dodge division-by-zero errors.

Both are standard SQL and work across every major database, which makes them safer interview answers than dialect-specific functions like ISNULL or IFNULL.

sql
SELECT name,
       COALESCE(commission, 0)              AS commission,
       total_pay / NULLIF(hours_worked, 0)  AS hourly_rate   -- NULL, not an error
FROM employees;

Q18. What is the difference between a primary key and a unique constraint?

Both enforce uniqueness and both get an index automatically. A table has exactly one primary key, its columns can never be NULL, and it's the default target for foreign keys. Unique constraints can appear many times per table and allow NULLs, which the standard treats as distinct from each other.

The mental model: the primary key is the row's identity; unique constraints guard alternate keys like email or national ID that must not repeat but aren't the identity.

Primary keyUnique constraint
Per tableExactly oneAny number
NULLs allowedNeverYes (treated as distinct)
Foreign key targetDefault choiceAllowed, less common
PurposeRow identityAlternate keys (email, SKU)

Key point: The NULL difference is the discriminator the key signal is. Bonus depth: PostgreSQL 15 added NULLS NOT DISTINCT for unique constraints.

Q19. What is a foreign key and what do the ON DELETE options mean?

A foreign key declares that values in one table must exist in another, and the database enforces it: you can't insert an order for a customer who doesn't exist, or delete a customer who still has orders. This is referential integrity, enforced at the data layer where no application bug can bypass it.

ON DELETE decides what happens to child rows when the parent goes: RESTRICT blocks the delete, CASCADE deletes the children too, SET NULL orphans them explicitly. CASCADE is convenient and dangerous; most production schemas default to RESTRICT.

sql
CREATE TABLE orders (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id BIGINT NOT NULL REFERENCES customers (id) ON DELETE RESTRICT,
  total       NUMERIC(10, 2) NOT NULL
);

Q20. Which constraints does SQL provide, and why put rules in the database at all?

Six do most of the work: NOT NULL requires a value, DEFAULT supplies one, UNIQUE forbids repeats, CHECK enforces an arbitrary condition like salary > 0, PRIMARY KEY identifies rows, and FOREIGN KEY ties tables together. All of them reject bad writes at the moment they happen.

The 'why' matters: applications get rewritten, scripts bypass them, and bugs ship. Constraints are the one validation layer every write path shares.

sql
CREATE TABLE employees (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email      TEXT NOT NULL UNIQUE,
  salary     NUMERIC(10, 2) NOT NULL CHECK (salary > 0),
  status     TEXT NOT NULL DEFAULT 'active',
  manager_id BIGINT REFERENCES employees (id)
);

Q21. What is the difference between UNION and UNION ALL?

Both stack the results of two queries with compatible column lists. UNION removes duplicate rows, which forces a sort or hash over the combined result; UNION ALL just concatenates, keeping duplicates and skipping that work entirely. When duplicates are impossible or acceptable, UNION ALL is strictly faster.

The same family includes INTERSECT (rows in both results) and EXCEPT (rows in the first but not the second), which round out set operations.

sql
SELECT email FROM customers
UNION            -- deduplicates, pays for a sort or hash
SELECT email FROM newsletter_subscribers;

SELECT email FROM customers
UNION ALL        -- keeps duplicates, just concatenates
SELECT email FROM newsletter_subscribers;
UNIONUNION ALL
DuplicatesRemovedKept
CostSort or hash over the resultSimple concatenation
Default choice whenDuplicates would be wrongDuplicates impossible or fine

Key point: the question needs the cost reasoning, not just the definition. 'I default to UNION ALL unless I need deduplication' is the senior phrasing.

Q22. What is the difference between DELETE, TRUNCATE, and DROP?

DELETE removes rows one at a time, supports a WHERE clause, fires row-level triggers, and is fully transactional. TRUNCATE empties the entire table by deallocating its storage, which is far faster but all-or-nothing. DROP removes the table itself: data, structure, indexes, and constraints, gone.

A PostgreSQL nuance worth knowing: TRUNCATE there is transactional and can be rolled back inside a transaction, which isn't true in several other databases.

DELETETRUNCATEDROP
RemovesSelected rowsAll rowsThe table itself
WHERE clauseYesNoNo
Speed on big tablesSlow (row by row)Fast (deallocates pages)Fast
Row triggers fireYesNoNo
Identity counterKeeps countingUsually resetsGone with the table

Key point: The rollback nuance is the differentiator: 'TRUNCATE is rollback-safe in PostgreSQL but not everywhere' shows real cross-database experience.

Q23. What are DDL, DML, DCL, and TCL?

They're the four command families. DDL (data definition) shapes structure: CREATE, ALTER, DROP, TRUNCATE. DML (data manipulation) changes rows: INSERT, UPDATE, DELETE, with SELECT often filed here or under its own DQL label. DCL (data control) manages permissions: GRANT and REVOKE. TCL (transaction control) manages units of work: BEGIN, COMMIT, ROLLBACK, SAVEPOINT.

The categories matter practically because they behave differently: DDL is often auto-committing in databases like MySQL and Oracle, which surprises people mid-transaction.

Q24. What is a subquery and where can one appear?

A subquery is a query nested inside another. A scalar subquery returns one value and can sit anywhere an expression can, like a SELECT column or a comparison. Table subqueries return row sets and feed IN, EXISTS, or the FROM clause, where the result behaves like a temporary table.

The variant that changes performance behavior is the correlated subquery, which references the outer row; that gets its own question in the intermediate tier.

sql
-- scalar subquery as an expression
SELECT name, salary,
       salary - (SELECT AVG(salary) FROM employees) AS diff_from_avg
FROM employees;

-- table subquery feeding IN
SELECT name
FROM employees
WHERE department_id IN (SELECT id FROM departments WHERE region = 'EMEA');

Key point: Classify by what the subquery returns (one value, a column of values, a table). That taxonomy keeps you organized when the follow-ups start.

Q25. How does the CASE expression work?

CASE is SQL's inline conditional: it walks WHEN conditions top to bottom, returns the THEN value of the first match, and falls back to ELSE (or NULL without one). It's an expression, so it works anywhere a value does: SELECT lists, ORDER BY, GROUP BY, and inside aggregates.

That last placement, CASE inside an aggregate, is the building block for pivoting rows into columns, which is a classic exercise later in this bank.

sql
SELECT name,
       CASE
         WHEN salary >= 100000 THEN 'senior band'
         WHEN salary >= 60000  THEN 'mid band'
         ELSE 'entry band'
       END AS band
FROM employees;

Key point: Mention that conditions are checked in order, so overlapping ranges resolve to the first match. That detail is what the trick variants of this question test.

Back to question list

SQL Intermediate Interview Questions

Intermediate22 questions

For candidates with working experience: window functions, the classic exercises, and the questions that separate query writers from query thinkers.

Q26. What is a CTE, and when do you use one instead of a subquery?

A CTE (common table expression) is a named result set declared with WITH at the top of a query. It does what a derived-table subquery does, but reads top to bottom instead of inside out, can be referenced multiple times by name, and is the only form that supports recursion for hierarchies.

One depth point: before PostgreSQL 12, CTEs were always materialized, acting as an optimization fence; modern versions inline them unless you write MATERIALIZED. Knowing that history explains a lot of older advice online.

sql
WITH regional_sales AS (
  SELECT region, SUM(amount) AS total
  FROM orders
  GROUP BY region
),
top_regions AS (
  SELECT region
  FROM regional_sales
  WHERE total > (SELECT AVG(total) FROM regional_sales)
)
SELECT region, total
FROM regional_sales
WHERE region IN (SELECT region FROM top_regions);

Key point: The honest answer is 'readability first, same plan either way in modern engines'. Claiming CTEs are inherently faster or slower is the trap.

Watch a deeper explanation

Video: Advanced SQL Tutorial | CTE (Common Table Expression) (Alex The Analyst, YouTube)

Q27. What is a correlated subquery and what does it cost?

A correlated subquery references columns from the outer query, so conceptually it re-executes once per outer row instead of once total. The classic example: employees earning above their own department's average, where the inner AVG depends on the current outer row's department.

Cost-wise, naive execution is O(rows times subquery), though planners often rewrite correlated forms into joins. When they can't, rewriting by hand into a join against a grouped subquery or a window function is the standard fix.

sql
SELECT e.name, e.salary
FROM employees e
WHERE e.salary > (
  SELECT AVG(salary)
  FROM employees
  WHERE department_id = e.department_id   -- references the outer row
);

Watch a deeper explanation

Video: Advanced SQL Tutorial | Subqueries (Alex The Analyst, YouTube)

Q28. When do you use EXISTS instead of IN?

EXISTS asks 'does at least one matching row exist' and can stop at the first hit; IN builds the subquery's value list and tests membership. Modern planners usually execute both the same way, so the real difference is semantics: NOT IN silently matches nothing if the subquery yields a single NULL, while NOT EXISTS is immune.

That makes the practical rule simple: EXISTS and NOT EXISTS for subqueries against nullable columns, IN for short literal lists.

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

-- NULL-safe 'has no orders': prefer NOT EXISTS over NOT IN
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

Key point: This question is usually a stealth NULL question. Getting to the NOT IN trap quickly is what scores.

Q29. What is a window function and how does it differ from GROUP BY?

A window function computes a value across a set of related rows (the window) while keeping every input row in the output. GROUP BY collapses each group to one row; a window function annotates rows with group-level context instead, like showing each employee next to their department's average.

The OVER clause defines the window: PARTITION BY splits rows into groups, ORDER BY sequences them for running and ranking calculations, and an optional frame clause narrows which neighbors count.

sql
SELECT name, department, salary,
       AVG(salary) OVER (PARTITION BY department)          AS dept_avg,
       salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg
FROM employees;

Key point: 'Aggregates collapse rows, window functions keep them' is the one-sentence answer interviewers hope to hear before any syntax.

Watch a deeper explanation

Video: SQL Window Functions Basics (Visually Explained) (Data with Baraa, YouTube)

Q30. What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?

All three number rows within a window ordered by some key; they differ only on ties. ROW_NUMBER always gives unique sequential numbers, breaking ties arbitrarily. RANK gives tied rows the same rank and then skips (1, 1, 3). DENSE_RANK gives tied rows the same rank without gaps (1, 1, 2).

The choice is driven by the problem: deduplication and top-N-per-group want ROW_NUMBER; Nth-highest-value problems want DENSE_RANK so ties count as one value.

sql
SELECT name, salary,
       ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
       RANK()       OVER (ORDER BY salary DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk
FROM employees;
-- salaries 90, 90, 80 give: row_number 1,2,3   rank 1,1,3   dense_rank 1,1,2
FunctionTied rowsGaps after tiesTypical use
ROW_NUMBERNumbered arbitrarilyNever tiesDedup, top-N per group
RANKSame rankYes (1, 1, 3)Competition-style standings
DENSE_RANKSame rankNo (1, 1, 2)Nth-highest-value problems

Key point: Bring your own example with a tie (90, 90, 80) and recite all three outputs. Concrete numbers beat definitions in this answer.

Q31. What do LAG and LEAD do?

LAG reaches back to a previous row in the window's order and returns one of its values; LEAD reaches forward. They turn row-to-row comparisons, previously a painful self join, into one readable expression, with month-over-month change as the canonical example.

Both take an offset (default 1) and a default value for the edges, where LAG on the first row would otherwise return NULL.

sql
SELECT month, revenue,
       LAG(revenue)  OVER (ORDER BY month)           AS prev_month,
       revenue - LAG(revenue) OVER (ORDER BY month)  AS change,
       LEAD(revenue) OVER (ORDER BY month)           AS next_month
FROM monthly_revenue;

Q32. How do you compute a running total in SQL?

With a window aggregate: SUM(amount) OVER (ORDER BY date) accumulates from the first row to the current one. Adding PARTITION BY resets the total per group, like a running balance per account. This replaces the old correlated-subquery approach, which recomputed the sum for every row.

One subtlety: with ORDER BY, the default frame is RANGE, which includes all peer rows that tie on the sort key. Spell out ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW for strict row-by-row accumulation.

sql
SELECT order_date, amount,
       SUM(amount) OVER (
         ORDER BY order_date
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total
FROM orders;

Q33. Why can't you use a window function in WHERE, and what do you do instead?

Because of logical processing order: WHERE runs before the SELECT phase where window functions are computed, so the value you want to filter on doesn't exist yet. The database rejects it outright. The workaround is structural: compute the window function in a CTE or subquery, then filter in the outer query.

Some engines added QUALIFY as a shortcut for exactly this, but the CTE pattern is the portable answer.

sql
-- rejected: WHERE runs before window functions exist
-- SELECT name, RANK() OVER (ORDER BY salary DESC) AS rnk
-- FROM employees
-- WHERE rnk <= 3;

WITH ranked AS (
  SELECT name, RANK() OVER (ORDER BY salary DESC) AS rnk
  FROM employees
)
SELECT name, rnk
FROM ranked
WHERE rnk <= 3;

Key point: the reasoning maps back to logical processing order. It converts a syntax gotcha into proof you have a mental model of the engine.

Q34. How do you find the second-highest salary?

The classic exercise, and the key point is multiple approaches with their edge cases. DENSE_RANK is the strongest: it handles ties correctly and generalizes to any Nth value. The MAX-below-MAX subquery is the traditional answer. LIMIT with OFFSET works too but silently depends on DISTINCT and returns an empty set rather than NULL when there's no second value.

Whatever you write, say the edge cases out loud: duplicate top salaries, a table with one row, and NULL salaries.

sql
-- 1) DENSE_RANK: tie-safe, generalizes to Nth
WITH ranked AS (
  SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
  FROM employees
)
SELECT DISTINCT salary FROM ranked WHERE rnk = 2;

-- 2) MAX below MAX: returns NULL when no second value exists
SELECT MAX(salary)
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

-- 3) OFFSET: returns zero rows, not NULL, when absent
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;

Key point: The question is a proxy for tie handling. Ask 'if two people share the top salary, is the second-highest the same number or the next one down?' before answering.

Q35. How do you get the top N rows per group?

Number the rows within each group with ROW_NUMBER, partitioned by the group and ordered by the ranking key, then keep row numbers up to N in an outer query. This shape solves a whole family of interview problems: top three earners per department, latest order per customer, first event per session.

Swap ROW_NUMBER for DENSE_RANK when ties should all survive the cut; that one-word change is a favorite follow-up.

sql
WITH ranked AS (
  SELECT department, name, salary,
         ROW_NUMBER() OVER (
           PARTITION BY department
           ORDER BY salary DESC
         ) AS rn
  FROM employees
)
SELECT department, name, salary
FROM ranked
WHERE rn <= 3;

Key point: 'Latest row per entity' is this same pattern with ORDER BY created_at DESC and rn = 1. that variant is useful because shows pattern fluency.

Q36. How do you find duplicate rows in a table?

Group by the columns that define 'duplicate' and keep groups with a count above one. GROUP BY plus HAVING COUNT(*) > 1 returns each duplicated value once, with its frequency; joining that result back to the table, or using a COUNT window function, retrieves the full offending rows.

The definitional step matters more than the syntax: duplicated on email alone, or on the whole row? Interviewers deliberately leave that ambiguous to see if you ask.

sql
SELECT email, COUNT(*) AS copies
FROM users
GROUP BY email
HAVING COUNT(*) > 1;

Q37. How do you delete duplicates while keeping one row from each set?

Rank the rows within each duplicate set with ROW_NUMBER, choosing the ordering that defines which copy survives (lowest id, newest timestamp), then delete every row whose number exceeds one. The CTE-plus-DELETE form is the cleanest way to express it in PostgreSQL.

Before running it in production, mention wrapping it in a transaction, checking the count first, and adding the unique constraint afterward so the duplicates can't return.

sql
WITH ranked AS (
  SELECT id,
         ROW_NUMBER() OVER (
           PARTITION BY email
           ORDER BY id
         ) AS rn
  FROM users
)
DELETE FROM users
WHERE id IN (SELECT id FROM ranked WHERE rn > 1);

Key point: The closing move the technical value is: 'then I'd add a unique constraint so this class of bug is structurally impossible'.

Q38. How do you find gaps in a sequence, like missing invoice numbers?

Compare each row to its predecessor with LAG: wherever the difference between the current and previous value exceeds one, a gap sits between them, and simple arithmetic gives its exact bounds. The same LAG-and-compare move powers the wider gaps-and-islands family of problems, like finding streaks of consecutive activity.

An alternative when you know the full expected range is generating it (generate_series in PostgreSQL) and anti-joining actual values against it.

sql
WITH numbered AS (
  SELECT id, LAG(id) OVER (ORDER BY id) AS prev_id
  FROM invoices
)
SELECT prev_id + 1 AS gap_start,
       id - 1      AS gap_end
FROM numbered
WHERE id - prev_id > 1;

Q39. How do you pivot rows into columns?

With conditional aggregation: one aggregate per output column, each counting or summing only the rows that match that column's condition. PostgreSQL's FILTER clause states it directly; SUM or COUNT over a CASE expression is the fully portable spelling. Some databases also ship a dedicated PIVOT keyword.

The limitation to name: the output columns are fixed at write time. Dynamic pivoting, where categories become columns at runtime, needs generated SQL or is better left to the reporting layer.

sql
SELECT department,
       COUNT(*) FILTER (WHERE status = 'active')   AS active,
       COUNT(*) FILTER (WHERE status = 'on_leave') AS on_leave,
       -- portable spelling of the same idea:
       SUM(CASE WHEN status = 'resigned' THEN 1 ELSE 0 END) AS resigned
FROM employees
GROUP BY department;

Key point: Say 'conditional aggregation' by name and note the static-columns limitation. Both signal you've pivoted real data, not just read about it.

Q40. What is a view, and how is a materialized view different?

A view is a saved query that runs fresh every time you select from it: always current, no storage, and a clean way to hide complexity or restrict which columns a user sees. A materialized view stores its result physically, so reads are fast and cheap but the data goes stale until you refresh it.

The trade is recomputation versus staleness. Dashboards and heavy aggregations suit materialized views with scheduled refreshes; anything needing live data stays a plain view.

sql
CREATE VIEW active_employees AS
SELECT id, name, department
FROM employees
WHERE status = 'active';

CREATE MATERIALIZED VIEW dept_headcount AS
SELECT department, COUNT(*) AS headcount
FROM employees
GROUP BY department;

REFRESH MATERIALIZED VIEW dept_headcount;

Q41. What is an index and how does it speed up queries?

An index is a separate, ordered structure, a B-tree by default, that maps column values to row locations. Instead of scanning every row, the database descends the tree in a handful of page reads, turning O(n) lookups into O(log n). Primary keys and unique constraints get indexes automatically; everything else is your call.

Indexes serve equality lookups, range scans, and can satisfy ORDER BY without sorting. The cost side, every write maintaining every index, is its own question in the experienced tier.

sql
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

-- this lookup now descends the B-tree instead of scanning the table
SELECT *
FROM orders
WHERE customer_id = 42;

Rows scanned to find one row: no index vs B-tree index

Finding a single row in a 100,000-row table. A sequential scan reads every row; a B-tree descends in a handful of page reads (log scale).

No index (sequential scan)
100,000 rows scanned
B-tree index
17 rows scanned
  • No index (sequential scan): Reads all 100,000 rows to find the match
  • B-tree index: About log-base-2 of 100,000 page reads to reach the row

Key point: The phone book analogy still works, but pair it with the mechanism: 'sorted structure, so the database can binary-search instead of reading everything'.

Q42. What is normalization, and what do 1NF, 2NF, and 3NF mean?

Normalization organizes tables so each fact lives in exactly one place, which prevents update, insert, and delete anomalies. 1NF requires atomic values: no comma-separated lists in a column. 2NF removes attributes that depend on only part of a composite key. 3NF removes attributes that depend on other non-key attributes.

The memorable summary: every non-key column depends on the key, the whole key, and nothing but the key. Most transactional schemas aim for 3NF and stop there.

Normal formRuleSmell it removes
1NFAtomic values, no repeating groupsCSV lists stuffed into one column
2NFNo dependency on part of a composite keyProduct name repeated on every order line
3NFNo non-key depending on another non-keyDepartment name repeated next to department id

Key point: Skip recited definitions and walk one bad table through the three fixes. The example is, not the vocabulary.

Q43. When would you deliberately denormalize?

When read performance or query simplicity is worth paying for with redundancy: duplicating a customer's name onto orders to avoid a hot join, keeping a running order_count on accounts, or maintaining wide pre-joined reporting tables. Analytics schemas like star schemas denormalize by design.

The cost is that every duplicated fact can now disagree with its source, so each one needs an owner: a trigger, a job, or application code that keeps it in sync, plus monitoring for drift. Normalize by default; denormalize with a measured reason.

Key point: Anchor it with a number when you can: 'we cut a dashboard from 4 seconds to 200 ms by pre-joining' is the shape of a convincing answer, if it's true for you.

Q44. What is a transaction and how do COMMIT and ROLLBACK work?

A transaction bundles several statements into one all-or-nothing unit. BEGIN opens it, COMMIT makes every change permanent at once, and ROLLBACK discards them all. The textbook case is a money transfer: the debit and the credit must both happen or neither, no matter what fails in between.

SAVEPOINT adds partial rollback inside a transaction. And most databases run each standalone statement as its own implicit transaction, which is why single statements never half-apply.

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;   -- or ROLLBACK; to undo both

Q45. What does ACID stand for?

Atomicity: a transaction applies fully or not at all. Consistency: each transaction moves the database from one valid state to another, with constraints holding. Isolation: concurrent transactions don't see each other's half-finished work. Durability: once committed, changes survive crashes, because they're persisted to a write-ahead log before the commit returns.

The property with configurable strength is isolation: databases offer levels that trade correctness guarantees for concurrency, which is the follow-up question waiting behind this one.

Key point: Expect 'which of these is configurable?' as the follow-up. Isolation is the answer, and it bridges straight into the isolation-levels question.

Q46. How do you work with dates and times in SQL?

Store timestamps in a timestamp type, in PostgreSQL preferably timestamptz, which normalizes to UTC. Group by periods with date_trunc('month', created_at), do arithmetic with intervals like now() - interval '30 days', and filter ranges with half-open bounds: >= the start and < the day after the end, which no rounding can break.

The classic mistake is wrapping the column in a function inside WHERE, like date(created_at) = '2026-07-01', which blocks index use; the range form is equivalent and index-friendly.

sql
SELECT date_trunc('month', created_at) AS month,
       COUNT(*) AS orders
FROM orders
GROUP BY month
ORDER BY month;

SELECT *
FROM orders
WHERE created_at >= now() - interval '30 days';

Key point: Half-open ranges (>= start, < end) are the tell of someone who has been burned by midnight and time zone edges. Use the phrase.

Q47. How do you concatenate values from multiple rows into one string?

With a string aggregation function: string_agg(name, ', ') in PostgreSQL, GROUP_CONCAT in MySQL, LISTAGG in Oracle and SQL Server 2017's STRING_AGG. It behaves like any aggregate, so it works with GROUP BY, and PostgreSQL lets you order the elements inside the call.

It comes up constantly in reporting: one row per parent with children listed inline. The caution is treating it as presentation, keeping the underlying data normalized rather than storing the concatenated string.

sql
SELECT department,
       string_agg(name, ', ' ORDER BY name) AS members
FROM employees
GROUP BY department;
Back to question list

SQL Interview Questions for Experienced Developers

Experienced18 questions

advanced rounds probe internals, query plans, concurrency, and design judgment. Expect every answer here to draw a follow-up.

Q48. How does a B-tree index actually work?

A B-tree is a balanced tree of pages: a root, a shallow layer of internal pages holding ordered key ranges, and leaf pages holding keys with pointers to rows. High fanout keeps it shallow, so millions of rows resolve in three or four page reads, and the tree rebalances itself on writes.

Two properties follow from the sorted structure: range scans are cheap, since leaves are ordered and linked, and the index can hand back rows already sorted, letting the planner skip an explicit sort for matching ORDER BY clauses.

Key point: 'Sorted, balanced, shallow because of fanout' covers the mechanism. Connecting it to range scans and free ordering is what makes the answer senior.

Q49. How do composite indexes work, and why does column order matter?

A composite index sorts by the first column, then the second within it, like a phone book sorted by last name then first. Queries can use any leftmost prefix: an index on (customer_id, created_at) serves filters on customer_id alone or both columns, but a filter on created_at alone can't seek into it.

The ordering heuristic: equality-filtered columns first, range-filtered columns after, because a range on an earlier column stops the index from narrowing on later ones.

sql
CREATE INDEX idx_orders_customer_date
ON orders (customer_id, created_at);

-- seeks efficiently: leading column pinned by equality
SELECT * FROM orders
WHERE customer_id = 42 AND created_at >= '2026-01-01';

-- cannot seek: skips the leading column
SELECT * FROM orders
WHERE created_at >= '2026-01-01';

Key point: The phone book analogy plus the equality-before-range rule answers this and its follow-ups. Most candidates know the first and miss the second.

Q50. What is a covering index and an index-only scan?

A covering index contains every column a query touches, so the database answers from the index alone without visiting the table: an index-only scan. That removes the random I/O of heap lookups, which is often the dominant cost of an index scan that returns many rows.

PostgreSQL's INCLUDE clause adds payload columns to the leaves without making them part of the key. One PostgreSQL nuance: index-only scans still consult the visibility map, so recently modified tables may fall back to heap checks until vacuum catches up.

sql
CREATE INDEX idx_orders_covering
ON orders (customer_id, created_at) INCLUDE (total);

-- answerable from the index alone: index-only scan
SELECT created_at, total
FROM orders
WHERE customer_id = 42;

Q51. When do indexes hurt performance?

Every INSERT, UPDATE, and DELETE must maintain every index on the table, so each added index taxes all writes; heavily indexed tables can spend more time updating trees than storing data. Indexes also occupy disk and cache, and unused ones pay that cost for nothing.

They also mislead: on low-selectivity columns (a status flag with three values), or when a query matches a large fraction of the table, a sequential scan is genuinely cheaper, and the planner will rightly ignore the index someone insisted on adding.

Key point: The audit habit: checking index usage stats (pg_stat_user_indexes) and dropping dead indexes. It proves you treat indexes as a budget, not a blessing.

Q52. What makes a query sargable, and what breaks index use?

Sargable means the predicate's shape lets the planner seek into an index: comparisons against a bare column. Wrapping the column in a function or expression, date(created_at), lower(email), price * 1.1, forces evaluation per row, so the index on the raw column becomes useless for that filter. Leading-wildcard LIKE patterns and implicit type casts do the same damage.

Fixes: move the arithmetic to the constant side, rewrite date functions as ranges, or create an expression index matching the exact expression you query.

sql
-- kills the index on created_at
SELECT * FROM orders WHERE date(created_at) = '2026-07-01';

-- sargable rewrite: same rows, index-friendly
SELECT * FROM orders
WHERE created_at >= '2026-07-01'
  AND created_at <  '2026-07-02';

-- for unavoidable expressions, index the expression itself
CREATE INDEX idx_users_lower_email ON users (lower(email));

Key point: The lower(email) login lookup is the everyday example. Offering the expression-index fix, not just the diagnosis, completes the answer.

Q53. How do you read an EXPLAIN plan?

EXPLAIN shows the planner's chosen strategy with cost estimates; EXPLAIN ANALYZE executes the query and adds actual times and row counts. Read it inside out: leaf nodes are scans (sequential or index), parents are joins (nested loop, hash, merge) and aggregations, and each node reports estimated versus actual rows.

The highest-value signal is a large gap between estimated and actual rows, which means stale statistics steering the planner wrong; ANALYZE the table. After that: sequential scans on big filtered tables, and nested loops fed by huge outer inputs.

sql
EXPLAIN ANALYZE
SELECT c.name, SUM(o.total)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.created_at >= '2026-01-01'
GROUP BY c.name;

Key point: Caution flag worth stating: EXPLAIN ANALYZE really runs the statement, so wrap data-modifying queries in a transaction you roll back.

Q54. A query that was fast is now slow in production. Walk through your approach.

Establish what changed: data volume, a deploy, new query patterns, or plan flips. Capture the real query with its parameters from logs or pg_stat_statements, run EXPLAIN ANALYZE, and compare estimated to actual rows. Stale statistics, an index the planner stopped choosing, growth past a memory threshold, or parameter values with skewed distributions cover most cases.

Then fix at the right layer, ANALYZE, an index, a rewrite, and verify with the same plan-reading measurement before and after. The technical sequence matters more than the specific culprit: observe, hypothesize, verify, fix, confirm.

Key point: The methodology is; tools support the evidence.

Q55. What is the N+1 query problem and how do you fix it?

It's the pattern where code fetches N parent rows with one query, then loops and issues one more query per row for related data: 1 + N round trips. Each query is fast, so the database looks innocent while the page takes seconds. ORMs generate it silently through lazy-loaded relations.

Fixes: fetch the relation in the same query with a JOIN, batch it with one IN query for all parent ids, or turn on the ORM's eager loading. Detection is the senior half: query counts per request in APM, or spotting hundreds of near-identical statements in the log.

sql
-- the N+1 shape an ORM emits: one query, then one per row
SELECT id, name FROM authors;                -- 200 rows
SELECT * FROM books WHERE author_id = 1;     -- then 200 of these
SELECT * FROM books WHERE author_id = 2;

-- fix: a single round trip
SELECT a.name, b.title
FROM authors a
LEFT JOIN books b ON b.author_id = a.id;

Key point: Frame it as a latency problem, not a database problem: 200 fast queries lose to 1 medium query because of round-trip time. That framing is the senior tell.

Q56. What are the transaction isolation levels and which anomalies does each allow?

The standard defines four levels by which anomalies they permit. A dirty read sees uncommitted data. A non-repeatable read gets different values re-reading the same row. A phantom read finds new rows matching a repeated query. Read uncommitted allows all three, read committed blocks dirty reads, repeatable read also blocks non-repeatable reads, and serializable blocks everything.

PostgreSQL specifics worth volunteering: it never allows dirty reads at any level, its repeatable read blocks phantoms too through snapshots, and serializable uses serializable snapshot isolation, so applications must retry serialization failures.

Isolation levelDirty readNon-repeatable readPhantom read
Read uncommittedPossible (per standard)PossiblePossible
Read committedNoPossiblePossible
Repeatable readNoNoPossible (per standard)
SerializableNoNoNo

Key point: Define each anomaly with a two-transaction story before naming levels. Candidates who recite the grid without the stories get pushed until it collapses.

Q57. What is MVCC and why does it mean readers don't block writers?

Multi-version concurrency control keeps multiple versions of each row: an update writes a new version instead of overwriting, and every transaction reads the snapshot of versions that were committed when it started. Readers never take locks that writers wait on, and writers never wait for readers, so read-heavy workloads scale without lock queues.

The costs come as follow-ups: dead row versions accumulate until vacuum reclaims them, which is why long-running transactions are dangerous in PostgreSQL, they pin old versions and cause table bloat.

Q58. What is a deadlock and how do you prevent it?

A deadlock is two transactions each holding a lock the other needs: A locked row 1 and wants row 2, B locked row 2 and wants row 1. Neither can proceed, so the database's deadlock detector picks a victim, rolls it back, and returns an error the application should catch and retry.

Prevention is mostly ordering discipline: every code path acquires locks in the same order (say, ascending id), transactions stay short, and lock acquisition happens as late as possible. You can't fully eliminate them under concurrency, so retry logic is part of the answer.

Key point: 'Consistent lock ordering plus retry on the victim' is the two-part answer. Giving only prevention or only retry indicates half experience.

Q59. What is the difference between pessimistic and optimistic locking?

Pessimistic locking takes the lock up front: SELECT FOR UPDATE holds the row so no one else can change it until you commit. It's right when conflicts are common or the update must not fail. Optimistic locking holds nothing: you read a version, and your update asserts the version is unchanged; zero rows updated means someone got there first, so you retry.

Optimistic wins for web apps with rare conflicts and no desire to hold locks across user think time; pessimistic wins for short, contended, must-succeed sections like inventory decrements.

sql
-- pessimistic: hold the row until commit
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- optimistic: detect conflicts at write time
UPDATE accounts
SET balance = 400, version = version + 1
WHERE id = 1 AND version = 7;   -- 0 rows updated means retry

Q60. Why is OFFSET pagination slow, and what is keyset pagination?

OFFSET must generate and discard every skipped row, so page 1,000 scans 25,000 rows to return 25; cost grows linearly with page depth, and rows shifting underneath cause skips and repeats. Keyset pagination instead remembers the last row's sort values and asks for rows after them, which an index answers directly at any depth.

Requirements: a deterministic ORDER BY with a unique tiebreaker like id, and accepting the trade-off that you can't jump to an arbitrary page number, only forward and back.

sql
-- deep OFFSET: generates and throws away 24,975 rows
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 25 OFFSET 24975;

-- keyset: start right after the previous page's last row
SELECT * FROM orders
WHERE (created_at, id) < ('2026-06-01 08:30:00', 91842)
ORDER BY created_at DESC, id DESC
LIMIT 25;

Key point: The row-tuple comparison (created_at, id) < (...) handles ties correctly in one expression. Writing it fluently is a strong PostgreSQL signal.

Q61. How do you write an upsert, and why not SELECT-then-INSERT?

In PostgreSQL: INSERT ... ON CONFLICT (key) DO UPDATE, with EXCLUDED carrying the values the insert attempted. MySQL spells it ON DUPLICATE KEY UPDATE, and the standard's MERGE covers the general case. One statement either inserts or updates, atomically, backed by a unique constraint.

The SELECT-then-INSERT alternative has a race: two requests both see 'not there' and both insert, one dying on the constraint, or worse, duplicating without one. The upsert closes that window inside the database.

sql
INSERT INTO user_settings (user_id, theme)
VALUES (42, 'dark')
ON CONFLICT (user_id)
DO UPDATE SET theme = EXCLUDED.theme;

Q62. Surrogate keys or natural keys: how do you choose?

A natural key is a real-world attribute (email, ISBN, national ID); a surrogate is a meaningless generated value (identity column, UUID). Surrogates win by default in practice: natural keys change (people change emails), get typed wrong, recycle, and cascade painfully through every referencing table when corrected.

The balanced answer keeps both: a surrogate primary key for joins and foreign keys, plus a unique constraint on the natural key so the business rule still holds. The UUID-versus-bigint trade too: UUIDs generate anywhere but index worse than sequential integers matters.

Key point: 'Surrogate PK plus unique constraint on the natural key' is the answer that ends the debate. Pure positions on either side draw follow-up fire.

Q63. Design question: how would you model orders for an e-commerce system?

Clarify scope first, then the core is four tables: customers, products, orders, and order_items as the join table carrying quantity and unit_price. The judgment call interviewers watch for: order_items copies the price at purchase time rather than referencing the product's current price, because catalog prices change and historical orders must not.

The same snapshot logic applies to shipping addresses. Add a constrained status column for order state, and money as NUMERIC. Close by walking through a query the design must serve, like a customer's order history with totals.

sql
CREATE TABLE order_items (
  order_id   BIGINT NOT NULL REFERENCES orders (id),
  product_id BIGINT NOT NULL REFERENCES products (id),
  quantity   INT    NOT NULL CHECK (quantity > 0),
  unit_price NUMERIC(10, 2) NOT NULL,   -- price at purchase time, not a lookup
  PRIMARY KEY (order_id, product_id)
);

Key point: The price snapshot is the evaluated moment. Candidates who join to products for price fail the question without hearing why.

Q64. Soft delete or hard delete: how do you decide, and what does soft delete cost?

Soft delete marks rows with a deleted_at timestamp instead of removing them, preserving history, enabling undo, and keeping foreign keys intact. Hard delete keeps the schema honest and is sometimes legally required, as with GDPR erasure requests.

The costs of soft delete are the senior material: every query must remember to filter deleted rows, unique constraints break (a deleted user still holds the email) until you use partial unique indexes, and tables grow without bound. Views that pre-filter, and archive tables for truly cold data, are the standard mitigations.

sql
UPDATE users SET deleted_at = now() WHERE id = 42;

-- keep uniqueness honest among live rows only
CREATE UNIQUE INDEX idx_users_email_live
ON users (email)
WHERE deleted_at IS NULL;

Key point: The partial unique index is the detail that proves production experience; the unique-email collision is where naive soft delete falls over first.

Q65. What data type do you use for money, and why do types matter so much?

NUMERIC (or DECIMAL), never FLOAT: floating point stores binary fractions, so 0.1 has no exact representation and cent errors accumulate across arithmetic. NUMERIC(12, 2) stores exact decimal values, and financial code either uses it or stores integer cents.

The wider principle: types are the cheapest correctness tool you have. timestamptz over naive timestamps kills a whole class of time zone bugs, proper date types keep comparisons honest where strings lie, and smaller types keep indexes denser. Type choices outlive the code that made them.

Key point: Say 'floats are binary, money is decimal' and give the 0.1 example. It's the fastest proof you've debugged a rounding bug at 2 a.m.

Back to question list

SQL vs NoSQL: When to Use Which

SQL wins when data is structured, relationships matter, and correctness under concurrency is non-negotiable, which describes most transactional application data. It trades some horizontal-scaling ease and schema flexibility for strong guarantees (ACID transactions, joins, constraints) and a query language that expresses complex questions directly. NoSQL families exist because some workloads value different things: massive scale, flexible documents, or millisecond key lookups over rich querying. Knowing the trade-offs out loud is itself an interview signal, because it shows you pick a data store on merits, not habit. Most real systems end up using both: a relational database for the core records and a NoSQL store for a caching, search, or high-volume-write slice.

TypeBest atTrade-off
Relational (SQL)Structured data, joins, ACID transactions, complex queriesRigid schema, harder horizontal scaling
Document (MongoDB)Flexible, nested records that evolve; fast iterationWeaker joins; consistency is per-document, not cross-document
Key-value (Redis, DynamoDB)Millisecond lookups, caching, sessions at huge scaleAlmost no query power beyond the key
Wide-column (Cassandra)Write-heavy, time-series, linear horizontal scaleQueries must be designed up front around access patterns
Graph (Neo4j)Deeply connected data: social graphs, recommendationsNiche; poor fit for tabular, aggregate-heavy work

How to Prepare for a SQL Interview

Prepare in layers, and write every query rather than only reading it. Most SQL rounds move from concept questions to live querying against a schema you've just been shown, so rehearse each stage instead of memorizing answers.

  • Master your tier's concepts until you can write the queries from memory, then read one tier up for the stretch questions interviewers use to find your ceiling.
  • Type and run every snippet against a real database; modifying working queries cements them far faster than reading does.
  • Practice narrating your query as you build it (The join cardinality, The NULL edge case), because your reasoning, not just the final result is the technical point.
  • Take the quiz on this page to find weak spots, then revisit those questions before the real round.

The typical SQL interview flow

1Recruiter or phone screen
background, a few concept checks (WHERE vs HAVING, join types)
2SQL concepts
joins, aggregation, NULL logic, window functions, indexes
3Live querying
write and explain a query against a shown schema, under observation
4Design or optimization
schema modeling, reading an EXPLAIN plan, fixing a slow query

Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.

Test Yourself: SQL Quiz

Ready to test your SQL knowledge?

10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.

10 questions Instant feedback Free certificate on 70%+

Frequently  Asked  Questions

Which SQL topics should I prioritize before a technical round?

Prioritize the areas that change real decisions: fundamentals, practical tasks, debugging, trade-offs, and evidence. The highest-value questions are tied to a project example, a validation check, or a failure mode.

Are these questions enough to pass a SQL interview?

They cover the question-answer portion well, but most SQL rounds also include live querying: writing a join or a window function against a schema you've just been shown, while explaining your thinking. Practice on a real database with a timer. Our AI coding interview prep guide covers that format, including how automated evaluation reads your process.

Which SQL dialect do these answers use?

Standard SQL, with PostgreSQL syntax wherever dialects differ (LIMIT, string_agg, FILTER, ON CONFLICT). The concepts transfer directly to MySQL, SQL Server, and Oracle; only function names and small syntax details change. In an interview, say which dialect you're answering in and the interviewer will meet you there.

How long does it take to prepare for a SQL interview?

If you query databases at work or in study, one to two weeks of an hour a day covers this bank with practice runs. Starting colder, plan three to four weeks and write queries daily; reading answers without typing them is how preparation quietly fails.

Do interviewers ask exactly these questions?

The concepts repeat far more reliably than the exact wording. Interviewers rephrase, chain follow-ups, and push toward whatever you claim confidence in. Prepare the underlying ideas, joins, NULL logic, window functions, indexes, and the phrasing takes care of itself.

Is there a way to test my SQL knowledge?

Yes. The quiz on this page scores you as you go and explains every answer, so you find your weak spots fast. Pass the threshold and you can download a shareable certificate with your name and score, free and with no sign-up. It is the quickest way to check yourself before the real round.

From the team that builds coding interviews

Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages, SQL included. These questions reflect what actually gets asked and evaluated inside the interviews we host for 5,000+ HR teams.

See how the AI Coding Interviewer works

Sources

Adithyan RKWritten by Adithyan RK
Surya N
Fact-checked by Surya N
Published on: 16 Apr 2026Last updated: 5 Jul 2026
Share: