This page requires JavaScript to display.
S
Unpacking...
← Interview Prep
Interview Prep

SQL Interview Questions

97 tricky SQL questions with answers: joins, NULLs, aggregation, window functions, subqueries, types and dates, ordering, DML, performance, and the classic puzzles asked in live rounds.

Contents
1. Joins 2. Self-Joins & Hierarchies 3. NULL Traps 4. Aggregation & GROUP BY 5. Window Functions 6. Subqueries & CTEs 7. Views & Materialized Views 8. Dates, Types & Strings 9. Ordering, Pagination & Set Operations 10. UPDATE, DELETE & MERGE 11. Performance & Query Plans 12. Classic Puzzles

1. Joins

Most join bugs are about row counts: rows multiplying through duplicate keys, or rows disappearing through NULLs and filters.

Q1.Explain INNER, LEFT, FULL OUTER, CROSS and SELF joins. What happens with NULL join keys?
INNER keeps matches only; LEFT keeps every left row (NULLs where no match); FULL OUTER keeps unmatched rows from both sides; CROSS is the Cartesian product; a SELF join joins a table to itself (e.g. employee → manager). NULL never equals NULL, so rows with NULL keys never match in an equi-join. Also watch for duplicate keys on the right side, which silently multiply rows.
Q2.Table A has 3 rows with key 1 and table B has 2 rows with key 1. How many rows does A INNER JOIN B return?
6. Every matching pair is returned, so duplicate keys multiply (3 × 2). This "fan-out" silently inflates any SUM computed after the join, e.g. summing order totals after joining to order lines. Aggregate each side to the join grain first, then join.
Q3.Can a LEFT JOIN return more rows than the left table? Fewer?
More, yes: if a left row matches several right rows, it appears once per match. Fewer, no: every left row appears at least once, unless a WHERE clause on the right table filters rows out afterwards (see the NULL traps section).
Q4.Find employees who earn more than their manager.
SELECT e.first_name, e.salary, m.first_name AS manager, m.salary AS manager_salary
FROM employees e
JOIN employees m ON m.employee_id = e.manager_id
WHERE e.salary > m.salary;
Use a LEFT JOIN instead when you need to keep the CEO, who has no manager.
Q5.What is a non-equi join? Give an example.
A join on a condition other than equality, such as a range:
SELECT e.*
FROM employees e
JOIN jobs j ON j.job_id = e.job_id
WHERE e.salary NOT BETWEEN j.min_salary AND j.max_salary;
Q6.How do you list every pair of employees in the same department without duplicates or self-pairs?
Self join with an inequality on the ID, so each pair appears once (A–B but not B–A or A–A):
SELECT a.name AS emp1, b.name AS emp2, a.dept_id
FROM employees a
JOIN employees b ON a.dept_id = b.dept_id AND a.emp_id < b.emp_id;
Q7.What are the ways to write an anti join and a semi join? Why prefer EXISTS over JOIN for a semi join?
Anti join (rows with no match): NOT EXISTS, LEFT JOIN … WHERE right.key IS NULL, or EXCEPT on keys. Avoid NOT IN when the subquery can contain NULLs. Semi join (rows with at least one match): EXISTS or IN. A plain JOIN returns the left row once per match, so customers with three orders appear three times; EXISTS returns each customer once without needing DISTINCT.
Q8.MySQL has no FULL OUTER JOIN. How do you emulate it?
Combine a LEFT JOIN with the unmatched rows of a RIGHT JOIN:
SELECT a.id, a.val, b.val FROM a LEFT JOIN b ON a.id = b.id
UNION ALL
SELECT b.id, NULL, b.val FROM a RIGHT JOIN b ON a.id = b.id
WHERE a.id IS NULL;
Using UNION ALL with the anti-join filter keeps legitimate duplicate rows; plain UNION would also work but removes duplicates.
Q9.When is a CROSS JOIN actually useful?
To build a complete grid before joining facts, so missing combinations show up as zero instead of disappearing, e.g. every store × every day:
SELECT s.store_id, d.day, COALESCE(SUM(f.amount), 0) AS sales
FROM stores s
CROSS JOIN calendar d
LEFT JOIN fact_sales f ON f.store_id = s.store_id AND f.sale_date = d.day
GROUP BY s.store_id, d.day;

2. Self-Joins & Hierarchies

Five join patterns cover almost every employee/manager and hierarchy question. Every example below runs against this sample employees table. Note the planted edge cases: Asha is the CEO (no manager), Chen was hired before his manager, and Gus reports to a manager ID that does not exist.

emp_idnamemanager_idsalaryhire_datedept
1AshaNULL3002015-01-10Exec
2Ben12002016-03-01Data
3Chen12102014-06-15Data
4Dia21502017-02-20Data
5Eli22202018-07-01Data
6Fay41202019-09-09Data
7Gus991002020-01-05Ops
PatternTypical interview requirementQuestion below
Self-joinEmployee vs. manager, peers, salary comparisonsQ1
Left anti-joinFind records without a matchQ2
Self-join with <Generate unique pairs without mirrored duplicatesQ3
Multiple self-joinsTraverse a fixed-depth hierarchyQ4
Recursive CTETraverse an arbitrary-depth hierarchyQ5
Interview tip: whenever a question mentions manager, hierarchy, ancestor, descendant, reporting chain or levels, first ask whether the depth is fixed or arbitrary. Fixed depth usually means repeated self-joins; arbitrary depth usually means a recursive CTE or an iterative traversal.
Q1.Find employees who were hired before their manager. Why is the CEO not in the result, and when does that matter?
SELECT e.name, e.hire_date, m.name AS manager, m.hire_date AS manager_hired
FROM employees e
JOIN employees m ON m.emp_id = e.manager_id     -- alias the same table twice
WHERE e.hire_date < m.hire_date;
-- Chen | 2014-06-15 | Asha | 2015-01-10
The INNER self-join drops anyone without a matching manager row: the CEO (NULL manager_id) and Gus (manager 99 does not exist). That is fine here, but when the question says "list every employee with their manager", use a LEFT JOIN and COALESCE(m.name, '(no manager)'), or you silently lose the top of the org chart. A related variant: peers are employees with the same manager, ON a.manager_id = b.manager_id AND a.emp_id <> b.emp_id.
Q2.Find employees whose manager does not exist in the table (orphaned records). What is wrong with the obvious query?
The obvious left anti-join also returns the CEO:
SELECT e.name
FROM employees e
LEFT JOIN employees m ON m.emp_id = e.manager_id
WHERE m.emp_id IS NULL;                 -- Asha, Gus  (wrong: Asha has no manager at all)
A missing match has two causes, no manager (NULL) and a broken reference, so exclude the legitimate case explicitly:
SELECT e.name, e.manager_id
FROM employees e
LEFT JOIN employees m ON m.emp_id = e.manager_id
WHERE m.emp_id IS NULL
  AND e.manager_id IS NOT NULL;         -- Gus | 99
This is a standard referential-integrity data-quality check. NOT EXISTS gives the same result and is equally safe; NOT IN (SELECT emp_id …) is fine here only because emp_id is never NULL.
Q3.List each pair of colleagues in the same department whose salaries are within 20 of each other, without showing A–B and B–A.
SELECT a.name, b.name AS colleague, ABS(a.salary - b.salary) AS diff
FROM employees a
JOIN employees b
  ON  a.dept = b.dept
  AND a.emp_id < b.emp_id               -- each unordered pair once, never a row with itself
  AND ABS(a.salary - b.salary) <= 20;
-- Ben–Chen 10 · Ben–Eli 20 · Chen–Eli 10
With <> instead of < the same query returns 6 rows: every pair twice, mirrored. With no inequality at all, each employee also pairs with themselves. For n matching rows, < gives n(n−1)/2 pairs. The same pattern finds possible duplicate customers (same email or phone, different IDs).
Q4.Show every employee with their manager and their skip-level manager (manager's manager).
A fixed depth means one self-join per level, and every join must be a LEFT JOIN:
SELECT e.name,
       m.name  AS manager,
       gm.name AS skip_level
FROM employees e
LEFT JOIN employees m  ON m.emp_id  = e.manager_id
LEFT JOIN employees gm ON gm.emp_id = m.manager_id;
-- Asha  | NULL | NULL
-- Ben   | Asha | NULL
-- Dia   | Ben  | Asha
-- Fay   | Dia  | Ben  …
The trap: with two INNER joins, only Dia, Eli and Fay come back, because everyone in the top two levels lacks a grandparent row. If the second join is INNER after a LEFT, it also cancels the first LEFT join for those rows. This approach works only when the depth is known; for "all levels" use a recursive CTE.
Q5.Find Fay's full reporting chain up to the CEO. Then list everyone under Ben at any depth, safely, even if bad data contains a reporting cycle.
Walking up: the anchor is the employee, and each step joins to the current row's manager:
WITH RECURSIVE chain AS (
  SELECT emp_id, name, manager_id, 0 AS lvl
  FROM employees WHERE name = 'Fay'
  UNION ALL
  SELECT m.emp_id, m.name, m.manager_id, c.lvl + 1
  FROM employees m
  JOIN chain c ON m.emp_id = c.manager_id
)
SELECT lvl, name FROM chain ORDER BY lvl;
-- 0 Fay · 1 Dia · 2 Ben · 3 Asha
Walking down (descendants): the anchor is the manager, and each step finds direct reports. Carry the visited path so a cycle cannot recurse forever:
WITH RECURSIVE sub AS (
  SELECT emp_id, name, 0 AS depth, ',' || emp_id || ',' AS path
  FROM employees WHERE emp_id = 2                         -- Ben
  UNION ALL
  SELECT e.emp_id, e.name, s.depth + 1, s.path || e.emp_id || ','
  FROM employees e
  JOIN sub s ON e.manager_id = s.emp_id
  WHERE s.path NOT LIKE '%,' || e.emp_id || ',%'          -- skip anyone already on this path
    AND s.depth < 20                                      -- hard safety limit
)
SELECT depth, name FROM sub ORDER BY depth, name;
Direction is the classic mistake: up joins m.emp_id = c.manager_id, down joins e.manager_id = s.emp_id. Postgres 14+ also has a built-in CYCLE emp_id SET is_cycle USING path clause, and SQL Server caps recursion at 100 levels by default (OPTION (MAXRECURSION n)). In Spark, which lacks recursive CTEs in older versions, iterate with a loop of joins until no new rows appear.

3. NULL Traps

NULL means "unknown", and SQL uses three-valued logic: a comparison with NULL is UNKNOWN, and WHERE keeps only TRUE rows. Almost every NULL trick question comes back to that rule.

Q1.What does SELECT NULL = NULL return? Why does WHERE col = NULL return no rows?
It returns NULL (UNKNOWN), not TRUE, because two unknown values cannot be known to be equal. WHERE drops UNKNOWN rows, so WHERE col = NULL never matches. Use IS NULL / IS NOT NULL, or the null-safe a IS NOT DISTINCT FROM b (Postgres, Spark SQL, Snowflake; a <=> b in MySQL and Spark).
Q2.Evaluate: NULL AND FALSE, NULL AND TRUE, NULL OR TRUE, NULL OR FALSE, NOT NULL.
FALSE, NULL, TRUE, NULL, NULL. If the known operand decides the result on its own (FALSE for AND, TRUE for OR), the answer is known; otherwise it stays UNKNOWN. That is why rewriting a filter with NOT does not bring NULL rows back.
Q3.A table has status values 'A', 'B' and NULL. How many rows does WHERE status <> 'A' return?
Only the 'B' rows. NULL <> 'A' is UNKNOWN, so NULL rows are silently excluded, a very common reporting bug. Use WHERE status IS DISTINCT FROM 'A' or WHERE status <> 'A' OR status IS NULL. The same applies to NOT (amount > 100): it does not return NULL amounts.
Q4.Table t(x) holds 1, 2 and NULL. What do COUNT(*), COUNT(x), SUM(x), AVG(x) and MIN(x) return?
3, 2, 3, 1.5 and 1. Aggregate functions skip NULLs; only COUNT(*) counts rows. So AVG(x) is 3/2 = 1.5, not 3/3 = 1. If NULL should mean zero, write AVG(COALESCE(x, 0)), which returns 1.
Q5.What does SUM return when every value is NULL, or the table is empty?
NULL, not 0 (COUNT returns 0). This breaks totals and downstream arithmetic, e.g. revenue - refunds becomes NULL when there were no refunds. Wrap it: COALESCE(SUM(amount), 0).
Q6.A row has col NULL. Does WHERE col NOT IN (1, 2) return it? Does WHERE col IN (1, NULL) match when col = 3?
No to both. NULL NOT IN (1, 2) is UNKNOWN, so the row is excluded. 3 IN (1, NULL) expands to 3 = 1 OR 3 = NULL = FALSE OR UNKNOWN = UNKNOWN, so no match (though 1 IN (1, NULL) is TRUE). Compare the classic trap in the SQL section, where a NULL inside a NOT IN subquery returns zero rows.
Q7.Why does this LEFT JOIN behave like an INNER JOIN?
SELECT c.customer_id, o.order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'SHIPPED';
Customers without orders get NULL in every o column, and NULL = 'SHIPPED' is UNKNOWN, so the WHERE clause removes exactly the rows the LEFT JOIN was meant to keep. Move the condition into the join: LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'SHIPPED'. The exception is WHERE o.order_id IS NULL, which is the deliberate anti-join pattern.
Q8.Two tables both have NULL in the join key. Do those rows join?
No. NULL = NULL is UNKNOWN, so NULL keys never match in an equi-join (and are dropped by INNER joins). If NULLs should match, use a null-safe join: ON a.k IS NOT DISTINCT FROM b.k or a.k <=> b.k. Avoid COALESCE(k, -1) sentinels unless -1 can never be a real value; they also block index use.
Q9.How are NULLs treated by GROUP BY, DISTINCT, UNION, INTERSECT and EXCEPT?
As equal to each other, unlike in comparisons and joins. All NULLs form one group, DISTINCT keeps a single NULL, and set operators treat NULL rows as duplicates (UNION removes them, INTERSECT matches them). So SELECT COUNT(DISTINCT x) ignores NULLs, but SELECT DISTINCT x returns one NULL row.
Q10.Does a UNIQUE constraint allow multiple NULLs? Does CHECK (amount > 0) reject a NULL amount?
UNIQUE: in most databases (Postgres, Oracle, MySQL, SQLite) multiple NULLs are allowed because NULLs are not equal to each other. SQL Server allows only one (use a filtered unique index), and Postgres 15+ offers UNIQUE NULLS NOT DISTINCT. CHECK: no, it accepts NULL, because a CHECK fails only when the result is FALSE, and UNKNOWN passes. Add NOT NULL if the value is required.
Q11.Where do NULLs appear in ORDER BY?
It depends on the database. With ascending order, Postgres and Oracle put NULLs last (treated as larger than any value), while MySQL, SQL Server, SQLite and Spark put them first. Be explicit with ORDER BY amount DESC NULLS LAST; in MySQL or SQL Server emulate it with ORDER BY amount IS NULL, amount or a CASE expression. This matters for "top N" and ROW_NUMBER queries.
Q12.Why does this CASE never return 'missing'?
SELECT CASE region WHEN NULL THEN 'missing' ELSE region END FROM sales;
A simple CASE compares with =, so region = NULL is never TRUE. Use a searched CASE: CASE WHEN region IS NULL THEN 'missing' ELSE region END, or just COALESCE(region, 'missing').
Q13.What do 5 + NULL and 'abc' || NULL return? Does CONCAT behave the same?
Arithmetic with NULL gives NULL, and so does standard || concatenation. CONCAT varies: SQL Server and Postgres CONCAT treat NULL as an empty string, but MySQL and Spark CONCAT return NULL if any argument is NULL. CONCAT_WS skips NULLs in MySQL, Postgres and Spark. Oracle is the odd one: it treats an empty string as NULL, and its || ignores NULLs.
Q14.COALESCE vs ISNULL / NVL / IFNULL? How do you avoid divide-by-zero with NULLIF?
COALESCE(a, b, c) is standard, takes any number of arguments and returns the first non-NULL. ISNULL (SQL Server), NVL (Oracle) and IFNULL (MySQL, SQLite) take two. Gotcha: SQL Server's ISNULL returns the first argument's type, so a longer replacement string can be truncated. NULLIF(a, b) returns NULL when a = b, so revenue / NULLIF(orders, 0) gives NULL instead of an error when orders is 0.
Q15.How do you forward-fill NULLs with the last known value?
Use LAST_VALUE … IGNORE NULLS over a running window (Oracle, Snowflake, BigQuery, Spark SQL):
SELECT ts, device_id,
       LAST_VALUE(reading IGNORE NULLS) OVER (
         PARTITION BY device_id ORDER BY ts
         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS reading_filled
FROM sensor_readings;
Postgres lacks IGNORE NULLS: build a group number with COUNT(reading) OVER (PARTITION BY device_id ORDER BY ts) (it only increments on non-NULL rows), then take MAX(reading) over each group. Also note LAG/LEAD return NULL at the edges unless you pass a default: LAG(amount, 1, 0).
Q16.How do you count NULLs in each column of a table?
SELECT COUNT(*)                                  AS total_rows,
       COUNT(*) - COUNT(email)                   AS null_emails,
       SUM(CASE WHEN phone IS NULL THEN 1 ELSE 0 END) AS null_phones
FROM customers;
A standard data-profiling query, and a building block for data-quality completeness checks.
Q17.Why does NOT IN return no rows when the subquery contains a NULL? What should you use instead?
x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL; the last comparison is UNKNOWN, so no row passes. Use NOT EXISTS or a LEFT JOIN … IS NULL anti join:
-- employees who manage nobody (manager_id contains NULL for the CEO)
SELECT * FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.employee_id);

4. Aggregation & GROUP BY

Know the logical order of execution: FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT. It explains most of these.

Q1.WHERE vs HAVING?
WHERE filters rows before grouping; HAVING filters groups after aggregation, so it can reference aggregates like COUNT(*) > 5. Push filters into WHERE whenever possible so less data reaches the aggregation.
Q2.COUNT(*) vs COUNT(column) after a LEFT JOIN?
COUNT(*) counts rows, so an empty department still shows 1 (the row of NULLs). COUNT(e.employee_id) skips NULLs and correctly shows 0.
Q3.Can you use a SELECT alias in WHERE? In GROUP BY or ORDER BY?
Not in WHERE: WHERE is evaluated before SELECT, so the alias does not exist yet (repeat the expression or use a subquery/CTE). ORDER BY runs after SELECT, so aliases work there everywhere. GROUP BY and HAVING aliases are allowed by some databases (MySQL, Postgres for GROUP BY, SQLite, Snowflake, Spark) but not SQL Server or Oracle, so avoid relying on it.
Q4.Is SELECT dept_id, name, MAX(salary) FROM employees GROUP BY dept_id valid? What does it return?
In standard SQL it is an error: name is neither grouped nor aggregated. MySQL with ONLY_FULL_GROUP_BY disabled returns an arbitrary name from each group, not necessarily the top earner. SQLite happens to return the row holding the MAX, but that is a quirk. To get the top earner per department use a window function (RANK() … = 1) or join back to the per-department MAX.
Q5.What do SELECT COUNT(*), MAX(x) FROM empty_table and the same query with GROUP BY return?
Without GROUP BY: exactly one row, (0, NULL), because an aggregate over no rows still produces one result. With GROUP BY: zero rows, because there are no groups. This difference matters when an application expects a row back.
Q6.What does HAVING do without a GROUP BY?
The whole table is treated as a single group. SELECT COUNT(*) FROM orders HAVING COUNT(*) > 1000 returns one row if there are more than 1,000 orders and no rows otherwise. Handy as a data-quality assertion.
Q7.How do you pivot rows into columns?
Conditional aggregation works in every database:
SELECT region,
       SUM(CASE WHEN quarter = 'Q1' THEN revenue ELSE 0 END) AS q1,
       SUM(CASE WHEN quarter = 'Q2' THEN revenue ELSE 0 END) AS q2,
       COUNT(*) FILTER (WHERE revenue > 1000)              AS big_deals  -- Postgres / SQLite
FROM sales
GROUP BY region;
Many engines also have a PIVOT clause (SQL Server, Oracle, Snowflake, Spark), but it still needs the column values listed explicitly.
Q8.How do you count distinct combinations of two columns?
COUNT(DISTINCT a, b) works only in MySQL (and Spark). Portable: SELECT COUNT(*) FROM (SELECT DISTINCT a, b FROM t) x. Do not concatenate the columns: a || b makes ('ab','c') equal to ('a','bc'), and a NULL in either column turns the whole value NULL.

5. Window Functions

Window functions compute across related rows without collapsing them. The tricky part is almost always the frame.

Q1.What is the difference between ROW_NUMBER, RANK and DENSE_RANK?
All three number rows within a window. ROW_NUMBER is always unique (1,2,3,4). RANK gives ties the same number and leaves gaps (1,2,2,4). DENSE_RANK gives ties the same number with no gaps (1,2,2,3). Use DENSE_RANK for "Nth highest salary" questions.
Q2.How would you compute a running total and a 7-day moving average?
SELECT order_date, revenue,
       SUM(revenue) OVER (ORDER BY order_date) AS running_total,
       AVG(revenue) OVER (ORDER BY order_date
                          ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7d
FROM daily_revenue;
Q3.Why does LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) return each row's own salary?
With ORDER BY, the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so the "last" row of the frame is the current row (or its last peer). Specify the full frame:
LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary
                         ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
FIRST_VALUE is unaffected because the frame always starts at the first row. MAX() OVER (PARTITION BY dept) is often simpler.
Q4.A running total ordered by order_date gives two rows on the same date the same total. Why?
The default RANGE frame treats rows with equal ORDER BY values as peers and includes all of them, so both rows show the total through the end of that date. Use ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and add a unique tiebreaker (ORDER BY order_date, order_id) for a row-by-row running total.
Q5.Can you filter on a window function in WHERE?
No. Window functions are evaluated after WHERE, GROUP BY and HAVING. Compute it in a subquery or CTE and filter outside, or use QUALIFY where supported (Snowflake, BigQuery, Databricks/Spark, DuckDB, Teradata):
SELECT * FROM employees
QUALIFY ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) = 1;
Q6.How do you show each row's share of the total and of its group?
Use window aggregates, which keep every row (GROUP BY would collapse them):
SELECT dept_id, name, salary,
       salary * 100.0 / SUM(salary) OVER ()                     AS pct_of_company,
       salary * 100.0 / SUM(salary) OVER (PARTITION BY dept_id) AS pct_of_dept
FROM employees;
The 100.0 avoids integer division.
Q7.Top 3 products by revenue per category: ROW_NUMBER or DENSE_RANK?
It depends on ties. ROW_NUMBER() <= 3 returns exactly 3 rows per category and breaks ties arbitrarily (add a tiebreaker for determinism). RANK() <= 3 includes everyone tied at the cut-off, so it may return more than 3. DENSE_RANK() <= 3 returns the top 3 distinct revenue values, possibly many rows. Say which you chose and why.
Q8.Compute month-over-month revenue growth.
SELECT month, revenue,
       revenue - LAG(revenue) OVER (ORDER BY month)                      AS change,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
             / NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1)         AS pct_change
FROM monthly_revenue;
The first month has no previous row, so LAG returns NULL; NULLIF guards against division by zero. If months can be missing, join to a calendar first, or LAG compares non-adjacent months.
Q9.How do you count cumulative distinct users over time? COUNT(DISTINCT) OVER doesn't work.
Most engines do not allow DISTINCT in window aggregates. Find each user's first day, then run a cumulative count of first appearances:
WITH first_seen AS (
  SELECT user_id, MIN(activity_date) AS first_day
  FROM activity GROUP BY user_id
)
SELECT first_day,
       SUM(COUNT(*)) OVER (ORDER BY first_day) AS cumulative_users
FROM first_seen
GROUP BY first_day;

6. Subqueries & CTEs

Q1.What happens if a scalar subquery returns more than one row?
A runtime error ("subquery returned more than 1 row" or similar), often only when data changes in production. If it returns zero rows the result is NULL. Guarantee one row with an aggregate (MAX), a unique key, or LIMIT 1 with an ORDER BY that makes the choice deterministic.
Q2.Find employees who earn more than their department's average, in two ways.
Correlated subquery (conceptually re-run per row):
SELECT e.* FROM employees e
WHERE e.salary > (SELECT AVG(salary) FROM employees x WHERE x.dept_id = e.dept_id);
Window function (one pass):
SELECT * FROM (
  SELECT e.*, AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg FROM employees e
) t
WHERE salary > dept_avg;
Modern optimizers often decorrelate the first form, but the window version is usually clearer and scans the table once.
Q3.IN vs EXISTS: is there a difference?
For a non-NULL subquery, IN and EXISTS return the same rows and most optimizers turn both into a semi join. The difference is negation: NOT IN returns nothing if the subquery yields a NULL, while NOT EXISTS behaves as expected. EXISTS also handles multi-column matches naturally.
Q4.CTE vs subquery vs temp table?
A CTE is a named subquery that improves readability and enables recursion. It is not necessarily computed once: Postgres 12+ inlines CTEs unless you write MATERIALIZED, and a CTE referenced twice may be evaluated twice. Use a temp table when an expensive intermediate result is reused several times or needs its own index or statistics.
Q5.Print an org chart with each employee's level and reporting path.
Use a recursive CTE: an anchor row (the root) unioned with a step that joins back to the CTE:
WITH RECURSIVE org(employee_id, lvl, path) AS (
  SELECT employee_id, 1, first_name FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.employee_id, o.lvl + 1, o.path || ' > ' || e.first_name
  FROM employees e JOIN org o ON e.manager_id = o.employee_id
)
SELECT * FROM org ORDER BY path;
Q6.How do you find dates with no orders?
Generate the full date range, then anti join. With a recursive CTE (Postgres has generate_series instead):
WITH RECURSIVE days(d) AS (
  SELECT DATE '2026-01-01'
  UNION ALL
  SELECT d + INTERVAL '1 day' FROM days WHERE d < DATE '2026-01-31'
)
SELECT d FROM days
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.order_date = d);
The same pattern finds missing IDs in a sequence. In SQLite use date(d, '+1 day').

7. Views & Materialized Views

A view is a saved query, not saved data. Most view questions test whether you know when the query actually runs, what a view can and cannot do, and how materialized views trade freshness for speed.

Q1.What is a view? Does it store data?
A view is a named, stored query. It holds no rows of its own: every time you select from it, the database runs the underlying query (usually merged into your query by the optimizer), so it always shows current data.
CREATE VIEW active_customers AS
SELECT customer_id, name, city
FROM customers
WHERE status = 'ACTIVE';

SELECT * FROM active_customers WHERE city = 'Pune';   -- runs the view's query with your filter added
Typical uses: hide complexity (joins, business rules) behind a simple name, give a stable interface while underlying tables change, and limit which columns or rows users can see.
Q2.View vs table vs materialized view vs CTE vs temp table?
Stores data?LifetimeFreshnessUse for
TableYesPermanentWhat you loadSource of truth
ViewNo (stored query)Permanent definitionAlways currentReusable logic, security, stable interfaces
Materialized viewYes (precomputed result)PermanentAs of the last refreshExpensive aggregates read often
CTE (WITH)NoOne statementCurrentReadability inside a single query
Temp tableYesSession or transactionAs of creationReusing an intermediate result many times
Q3.Can you INSERT or UPDATE through a view? What does WITH CHECK OPTION do?
Many databases allow DML through a simple view: one base table, no aggregates, GROUP BY, DISTINCT, UNION, window functions or LIMIT, and every NOT NULL column without a default exposed (for inserts). Complex views are read-only unless you add INSTEAD OF triggers. SQLite views are always read-only without triggers.
CREATE VIEW pune_customers AS
SELECT customer_id, name, city FROM customers WHERE city = 'Pune'
WITH CHECK OPTION;

INSERT INTO pune_customers VALUES (9, 'Ravi', 'Pune');     -- OK
INSERT INTO pune_customers VALUES (10, 'Meera', 'Delhi');  -- ERROR: violates the view's WHERE
Without WITH CHECK OPTION, the second insert succeeds and the new row immediately "disappears" from the view, a classic trick question. The option rejects inserts and updates that would produce rows the view cannot see.
Q4.A view was created with SELECT *. Then a column is added to the base table. Does the view show it?
Usually not. Postgres, SQL Server, MySQL and Oracle expand * into a fixed column list when the view is created, so new columns are ignored until you recreate the view (SQL Server: sp_refreshview). Dropping or renaming a column the view uses either fails (Postgres refuses unless you use CASCADE) or leaves the view broken (SQL Server without SCHEMABINDING). SQLite and Spark store the view text and re-expand it at query time, so behaviour differs. List columns explicitly in views to avoid surprises.
Q5.Why can't you just change a view's columns with CREATE OR REPLACE VIEW?
In Postgres, CREATE OR REPLACE VIEW must keep the existing columns with the same names, types and order; it can only add new columns at the end. Renaming or removing columns needs DROP VIEW and CREATE VIEW (and dependent views must be dropped too, or use CASCADE and recreate them). Plan view changes like schema migrations.
Q6.Do views make queries slower?
A simple view costs nothing extra: the optimizer merges its definition into your query and pushes your filters down. Problems come from layers of views on views: the merged query can join the same tables several times, compute columns nobody uses, or contain DISTINCT/aggregates that block filter pushdown. Check with EXPLAIN, flatten deep stacks, and remember that an ORDER BY inside a view is not guaranteed to be respected by the outer query.
Q7.What is a materialized view, and how is it refreshed?
It stores the query result physically, so reads are fast, but the data is only as fresh as the last refresh.
-- Postgres
CREATE MATERIALIZED VIEW daily_sales AS
SELECT order_date, region, SUM(amount) AS revenue
FROM orders GROUP BY order_date, region;

CREATE UNIQUE INDEX ON daily_sales (order_date, region);   -- required for CONCURRENTLY
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;        -- readers are not blocked
PlatformRefresh behaviour
PostgresManual full refresh; CONCURRENTLY needs a unique index and keeps the old data readable during refresh
OracleCOMPLETE or FAST (incremental, using materialized view logs); ON COMMIT or ON DEMAND
Redshift / Snowflake / BigQueryAuto or incremental refresh for supported query shapes; queries may be rewritten to use the MV automatically
DatabricksMaterialized views refreshed incrementally where possible, on a schedule or as part of a pipeline
Q8.How do views help with security? What is the "security definer" trap?
Grant users access to a view instead of the base table to expose only some columns (no salary, masked PAN) or rows (only their region). The trap: in Postgres, a view runs with its owner's privileges by default, so anyone who can query the view sees what the owner can see, bypassing their own row-level security on the base table. Postgres 15+ offers WITH (security_invoker = true) to check the caller's permissions instead. Know which model your database uses before relying on views for security.
Q9.Can you index a view or pass it parameters?
A normal view cannot be indexed (it holds no data), but SQL Server supports indexed views (created WITH SCHEMABINDING plus a unique clustered index), and elsewhere a materialized view can be indexed. Views take no parameters: filter the view in your query, or use a table-valued function (SQL Server) or a set-returning function (Postgres) when you need parameters.
Q10.What are temporary and global temporary views in Spark?
df.createOrReplaceTempView("txn")                 # visible only in this SparkSession
spark.sql("SELECT channel, COUNT(*) FROM txn GROUP BY channel")

df.createOrReplaceGlobalTempView("txn_shared")    # visible to all sessions of this application
spark.sql("SELECT * FROM global_temp.txn_shared")  # note the global_temp database

spark.sql("""CREATE OR REPLACE VIEW gold.v_daily_txn AS
             SELECT dt, SUM(amount) AS total FROM silver.transactions GROUP BY dt""")  # permanent, in the catalog
Temp views disappear when the session (or application, for global ones) ends and store no data; permanent views are saved in the metastore or Unity Catalog. Like any view, they re-run the query on every use, so cache or write a table if an expensive result is reused many times.

8. Dates, Types & Strings

Implicit conversions and boundary conditions cause wrong answers that look plausible.

Q1.Why does WHERE order_ts BETWEEN '2026-01-01' AND '2026-01-31' miss orders?
If order_ts is a timestamp, '2026-01-31' means midnight at the start of the 31st, so almost the whole last day is excluded. Use a half-open range: order_ts >= '2026-01-01' AND order_ts < '2026-02-01'. BETWEEN is inclusive on both ends, which also double-counts boundary rows when you chain monthly ranges.
Q2.What does SELECT 1 / 2 return?
0 in Postgres, SQL Server and SQLite (integer division); 0.5 in MySQL, Oracle and Spark SQL. Make one operand non-integer: 1.0 * a / b or CAST(a AS DECIMAL(10,2)) / b. Related trap: in SQL Server AVG() of an INT column returns an INT, so AVG of 1 and 2 is 1.
Q3.Why might WHERE price = 0.3 miss a row where price was computed as 0.1 + 0.2?
FLOAT/DOUBLE are binary approximations: 0.1 + 0.2 is stored as 0.30000000000000004. Use DECIMAL/NUMERIC for money and exact values, or compare floats with a tolerance (ABS(a - b) < 1e-9).
Q4.A VARCHAR column holds numbers. What goes wrong when you sort or compare it?
Strings compare character by character, so '10' < '9' is TRUE and ORDER BY gives 1, 10, 100, 2. Comparing the column with a number triggers implicit conversion, which can fail on bad values, differs between databases, and usually prevents index use. Cast explicitly (CAST(code AS INT)) or fix the column type.
Q5.How do you match a literal underscore or percent sign with LIKE?
_ matches any single character and % any sequence, so LIKE '%_%' matches every non-empty string. Escape it: LIKE '%!_%' ESCAPE '!'. Case sensitivity also varies: Postgres LIKE is case-sensitive (use ILIKE), while MySQL and SQL Server usually follow a case-insensitive collation.
Q6.Why don't two "identical" strings join?
Hidden differences: trailing spaces (CHAR padding, or PAD SPACE collations that ignore them in = but not in LIKE or hashing engines like Spark), case, leading zeros, Unicode lookalikes or non-breaking spaces. Normalise keys before joining (TRIM, UPPER, consistent types) and check LENGTH when values look equal.
Q7.What are the timezone traps with timestamps?
Grouping CAST(event_ts AS DATE) uses the session or server timezone, so the same event can land on different days in different tools. Store timestamps in UTC (or with timezone), convert explicitly to the business timezone before taking the date, and remember daylight-saving days have 23 or 25 hours.

9. Ordering, Pagination & Set Operations

Q1.Is the order of rows guaranteed without ORDER BY? With ORDER BY inside a subquery or view?
No in both cases. Without an outer ORDER BY, the database may return rows in any order, and it can change between runs as plans, parallelism or data change. An ORDER BY inside a subquery, view or CTE is not guaranteed to survive (SQL Server rejects it without TOP). Only the outermost ORDER BY counts.
Q2.Why can LIMIT 10 return different rows each run, and why do pages repeat or skip rows?
If ORDER BY columns are not unique, rows that tie can come back in any order, so LIMIT/OFFSET may cut at different places each time. Add a unique tiebreaker: ORDER BY created_at DESC, id DESC. OFFSET pagination also shifts when rows are inserted between page requests.
Q3.Why is OFFSET pagination slow for deep pages, and what is the alternative?
OFFSET 100000 still reads and discards 100,000 rows. Keyset (seek) pagination remembers the last row seen and uses an index:
SELECT * FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
Row-value comparison works in Postgres, MySQL and SQLite; elsewhere expand it to created_at < :t OR (created_at = :t AND id < :id).
Q4.UNION vs UNION ALL? DELETE vs TRUNCATE vs DROP?
UNION deduplicates (sort/hash cost); UNION ALL just appends and is faster. DELETE removes rows (can filter, is logged, can be rolled back); TRUNCATE removes all rows quickly without row-level logging; DROP removes the table and its definition.
Q5.Two queries are combined with UNION, but one lists (first_name, last_name) and the other (last_name, first_name). What happens?
It runs without error and silently mixes the columns, because set operators match columns by position, not name. Only the count and compatible types are checked. List columns explicitly in the same order (Spark offers unionByName).
Q6.How do you sort rows in a custom order, e.g. HIGH, MEDIUM, LOW?
SELECT * FROM alerts
ORDER BY CASE risk_level WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 3 ELSE 4 END,
         created_at DESC;
Alphabetical order would give HIGH, LOW, MEDIUM. A lookup table with a sort_order column is better if the list is reused.

10. UPDATE, DELETE & MERGE

Data-changing statements have database-specific behaviour that interviewers like to probe.

Q1.How do you delete duplicate rows but keep one of each?
Rank within each duplicate group and delete everything after the first:
-- Postgres / SQL Server / Oracle style (SQL Server can DELETE from the CTE directly)
DELETE FROM customers
WHERE id IN (
  SELECT id FROM (
    SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
    FROM customers
  ) t
  WHERE rn > 1
);
MySQL refuses to delete from a table it selects from in a plain subquery (error 1093); the extra derived-table level shown above works around it. Without a unique id, use Postgres ctid or rebuild the table with SELECT DISTINCT.
Q2.UPDATE t SET a = b, b = a: are the values swapped?
In standard SQL, yes: every expression on the right uses the row's values from before the update. MySQL is the exception: it applies assignments left to right, so b receives the new a (which is the old b) and both columns end up equal to the old b. In MySQL use a temporary variable or a self join.
Q3.Swap M and F in a gender column with one statement.
UPDATE employees
SET gender = CASE gender WHEN 'M' THEN 'F' WHEN 'F' THEN 'M' ELSE gender END;
Two separate UPDATEs would turn everyone into the same value. Keep ELSE gender, or other values (including NULL) are set to NULL.
Q4.An UPDATE … FROM (or MERGE) source has two rows for the same target key. What happens?
UPDATE … FROM in Postgres and SQL Server silently uses one of the matching rows, and which one is not defined. MERGE in SQL Server, Delta Lake and Snowflake (by default) raises an error because a target row would be modified twice. Deduplicate the source to one row per key first, e.g. with ROW_NUMBER.
Q5.Why can "SELECT to check if it exists, then INSERT" create duplicates?
Two sessions can both run the check before either inserts, and both insert (a race condition). Enforce a UNIQUE constraint and use an atomic upsert: INSERT … ON CONFLICT (key) DO UPDATE (Postgres, SQLite), INSERT … ON DUPLICATE KEY UPDATE (MySQL), or MERGE.

11. Performance & Query Plans

Expect at least one "why is this slow?" question.

Q1.Why doesn't WHERE YEAR(order_date) = 2026 use the index on order_date?
Wrapping an indexed column in a function (or an implicit type conversion) makes the predicate non-sargable: the database must compute YEAR() for every row. Rewrite it as a range on the raw column: order_date >= '2026-01-01' AND order_date < '2027-01-01'. The same applies to LIKE '%abc' (a leading wildcard cannot use a B-tree index) and to col + 1 = 10.
Q2.There is an index on (country, city). Which queries can use it?
Queries filtering on country, or on country and city, can seek into it. A filter on city alone generally cannot (leftmost-prefix rule), apart from engines that support index skip scans. Put equality columns first and the range or sort column last.
Q3.How do you investigate a slow query?
Run EXPLAIN (or EXPLAIN ANALYZE, which executes the query) and look for full scans on large tables, estimated vs actual row counts that differ widely (stale statistics), nested-loop joins over large inputs, sorts or hashes spilling to disk, and filters applied late. Then fix the cause: an index, a sargable rewrite, updated statistics, earlier filtering, or pre-aggregation.
Q4.Why is SELECT * discouraged in production queries?
It reads and transfers columns you do not need (expensive in columnar warehouses, where cost scales with columns scanned), stops covering indexes from being used, and breaks downstream code or views when columns are added, removed or reordered.
Q5.A query returns duplicate rows, so someone adds DISTINCT. What is wrong with that?
DISTINCT hides the real problem, usually a join fan-out on a non-unique key, and adds an expensive sort or hash. It can also collapse rows that are genuinely different events. Find which join multiplies rows, then fix the join key or aggregate before joining.

12. Classic Puzzles

These come up again and again in live SQL rounds. Practise writing them from memory.

Q1.How do you remove duplicate rows, keeping the latest version of each key?
Number the rows per key ordered by the change timestamp and keep row 1:
WITH ranked AS (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY customer_id
                            ORDER BY updated_at DESC) AS rn
  FROM customers_raw
)
SELECT * FROM ranked WHERE rn = 1;
Q2.Find the second-highest salary in each department.
SELECT dept_id, salary
FROM (
  SELECT dept_id, salary,
         DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
  FROM employees
) t
WHERE rnk = 2;
Q3.Return the Nth highest distinct salary, or NULL if there is none.
SELECT (
  SELECT DISTINCT salary FROM employees
  ORDER BY salary DESC
  LIMIT 1 OFFSET 2          -- N - 1, here N = 3
) AS third_highest;
Wrapping the query in a scalar subquery turns "no row" into NULL. DISTINCT handles ties; for per-group versions use DENSE_RANK.
Q4.Find emails that appear more than once.
SELECT email, COUNT(*) AS occurrences
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
Normalise first (LOWER(TRIM(email))) if case or spaces should not count as differences.
Q5.What is the gaps-and-islands problem? Find users with 3+ consecutive login days.
Subtracting a row number from the date gives a constant for each consecutive streak, so you can group on it:
WITH d AS (
  SELECT DISTINCT user_id, login_date FROM logins
), g AS (
  SELECT user_id, login_date,
         login_date - ROW_NUMBER() OVER (PARTITION BY user_id
                                         ORDER BY login_date) * INTERVAL '1 day' AS grp
  FROM d
)
SELECT user_id, MIN(login_date) AS start_day, COUNT(*) AS streak
FROM g GROUP BY user_id, grp
HAVING COUNT(*) >= 3;
Q6.Compute the median salary per department without a MEDIAN function.
SELECT department_id, AVG(salary) AS median_salary
FROM (
  SELECT department_id, salary,
         ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary) AS rn,
         COUNT(*)     OVER (PARTITION BY department_id)                 AS cnt
  FROM employees
) t
WHERE rn IN ((cnt + 1) / 2, (cnt + 2) / 2)
GROUP BY department_id;
With integer division this picks the middle row for odd counts and the two middle rows for even counts.
Q7.Find the highest-paid employee(s) in each department, including ties.
SELECT dept_id, name, salary
FROM (
  SELECT e.*, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
  FROM employees e
) t
WHERE rnk = 1;
RANK (or DENSE_RANK) keeps everyone tied for first; ROW_NUMBER would keep only one.
Q8.Find customers who bought every product (relational division).
SELECT customer_id
FROM purchases
GROUP BY customer_id
HAVING COUNT(DISTINCT product_id) = (SELECT COUNT(*) FROM products);
COUNT(DISTINCT) guards against repeat purchases. If purchases can reference products not in the products table, join to products first.
Q9.For each user, find their second order.
SELECT user_id, order_id, order_ts
FROM (
  SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_ts, order_id) AS rn
  FROM orders o
) t
WHERE rn = 2;
Users with only one order do not appear; LEFT JOIN this to users if they should show up with NULLs.
Q10.Day-7 retention: what share of users who signed up on a day were active 7 days later?
SELECT u.signup_date,
       COUNT(*)                                  AS signups,
       COUNT(a.user_id)                          AS retained_d7,
       ROUND(100.0 * COUNT(a.user_id) / COUNT(*), 1) AS retention_pct
FROM users u
LEFT JOIN (SELECT DISTINCT user_id, activity_date FROM activity) a
       ON a.user_id = u.user_id
      AND a.activity_date = u.signup_date + INTERVAL '7 day'
GROUP BY u.signup_date;
The LEFT JOIN keeps non-retained users in the denominator, and DISTINCT on activity prevents several events on day 7 from double-counting.