InterviewHack.ai
Start free
Blog/SQL Interview Questions and How to Answer Them (45+ Questions)

SQL Interview Questions and How to Answer Them (45+ Questions)

September 16, 2026

sqldatabase

Complete SQL interview questions article

SQL Interview Questions and How to Answer Them (45+ Questions)

SQL remains one of the most-tested skills in technical interviews — whether you are applying for a data analyst, backend engineer, data engineer, or business intelligence role. Interviewers use SQL questions to evaluate not just syntax knowledge but logical thinking, query optimization instinct, and your ability to model real-world problems as relational data.

This guide covers 45+ SQL interview questions organized by difficulty and topic, with detailed answers, real working code, and the reasoning interviewers want to hear. Bookmark it, work through it section by section, and run every query yourself before your interview.


How to Use This Guide

  • Junior/entry-level roles: Focus on questions 1–20 (fundamentals + joins).
  • Mid-level roles: Cover questions 1–35 (add window functions, subqueries, and indexes).
  • Senior / data engineering roles: Work through all 45+ questions, including performance tuning and schema design.
  • Run the code: Every snippet uses standard ANSI SQL unless labeled otherwise. Test in PostgreSQL, MySQL, or SQLite — all are free to set up locally.

Section 1: Core Fundamentals

1. What is SQL and what are its main sub-languages?

How to answer: Name the four sub-languages and give one real example of each. Interviewers want to see you understand that SQL is more than just SELECT.

SQL (Structured Query Language) is the standard language for managing and querying relational databases. It is divided into four sub-languages:

| Sub-language | Purpose | Example |

|---|---|---|

| DDL (Data Definition Language) | Define and modify schema | CREATE TABLE, ALTER TABLE, DROP TABLE |

| DML (Data Manipulation Language) | Read and modify data | SELECT, INSERT, UPDATE, DELETE |

| DCL (Data Control Language) | Manage permissions | GRANT, REVOKE |

| TCL (Transaction Control Language) | Manage transactions | COMMIT, ROLLBACK, SAVEPOINT |

What to add in your answer: Mention that in most day-to-day work you live in DML, but DDL matters a lot for migrations and schema design — showing you think beyond just writing queries.


2. What is the difference between `WHERE` and `HAVING`?

The short answer: WHERE filters rows before grouping. HAVING filters groups after aggregation.

sql
-- WHERE filters individual rows before the GROUP BY runs
SELECT department, COUNT(*) AS headcount
FROM employees
WHERE status = 'active'          -- applied to individual rows
GROUP BY department
HAVING COUNT(*) > 5;             -- applied to aggregated groups

Why this matters: A common mistake is trying to use an aggregate function inside a WHERE clause:

sql
-- This FAILS — you cannot reference an aggregate in WHERE
SELECT department, COUNT(*)
FROM employees
WHERE COUNT(*) > 5               -- syntax error
GROUP BY department;

-- This WORKS
SELECT department, COUNT(*)
FROM employees
GROUP BY department
HAVING COUNT(*) > 5;

Pro tip for the interview: Mention that WHERE executes before GROUP BY, so it reduces the number of rows that need to be aggregated — making it more performant when you can use it.


3. What is the order of execution of a SQL `SELECT` statement?

Why interviewers ask this: It explains why you cannot use a column alias from SELECT inside a WHERE, and why HAVING can reference aggregates but WHERE cannot.

The logical order of execution:

1. FROM / JOIN        -- which tables, which rows qualify from joins
2. WHERE              -- filter individual rows
3. GROUP BY           -- aggregate rows into groups
4. HAVING             -- filter groups
5. SELECT             -- compute the output columns
6. DISTINCT           -- remove duplicates (if specified)
7. ORDER BY           -- sort the result
8. LIMIT / OFFSET     -- return a subset

Example to illustrate:

sql
SELECT department, AVG(salary) AS avg_sal   -- step 5
FROM employees                               -- step 1
WHERE hire_date > '2020-01-01'              -- step 2
GROUP BY department                          -- step 3
HAVING AVG(salary) > 60000                  -- step 4
ORDER BY avg_sal DESC                        -- step 7
LIMIT 5;                                     -- step 8

Notice ORDER BY can reference the alias avg_sal even though it is defined in SELECT — because ORDER BY runs after SELECT.


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

| Command | Removes | Can rollback? | Resets auto-increment? | Speed |

|---|---|---|---|---|

| DELETE | Specific rows (or all rows with no WHERE) | Yes (within transaction) | No | Slow on large tables |

| TRUNCATE | All rows | Depends on DB (PostgreSQL: yes; MySQL: no) | Yes | Very fast |

| DROP | Entire table including structure | No (DDL) | N/A | Instant |

sql
-- DELETE: can be targeted and rolled back
BEGIN;
DELETE FROM orders WHERE status = 'cancelled';
ROLLBACK; -- rows come back

-- TRUNCATE: removes all rows, keeps the table structure
TRUNCATE TABLE session_logs;

-- DROP: destroys the table completely
DROP TABLE temp_calculations;

What to say in the interview: "I use DELETE when I need a WHERE clause or need the safety of a transaction. I use TRUNCATE when I want to wipe a staging or log table fast. I would never use DROP on production data without a backup."


5. What are `NULL` values and how do you handle them?

NULL means the absence of a known value — it is not zero, not an empty string, not false. This creates three-valued logic (true / false / unknown) in SQL.

sql
-- NULL comparisons with = always return NULL (unknown), not true/false
SELECT * FROM employees WHERE manager_id = NULL;    -- returns 0 rows (wrong)
SELECT * FROM employees WHERE manager_id IS NULL;   -- correct

-- Use COALESCE to substitute a default value
SELECT name, COALESCE(phone, 'not provided') AS phone
FROM customers;

-- Use NULLIF to turn a specific value into NULL
-- Prevents division-by-zero errors
SELECT total_revenue / NULLIF(total_orders, 0) AS avg_order_value
FROM monthly_summary;

Key behavior to mention: Aggregate functions like COUNT, SUM, AVG ignore NULLs automatically — except COUNT(*) which counts all rows including those with NULLs.

sql
-- Difference between COUNT(*) and COUNT(column)
SELECT
  COUNT(*)          AS total_rows,       -- counts all rows
  COUNT(salary)     AS rows_with_salary  -- skips NULLs
FROM employees;

6. What are constraints in SQL? Name the most common ones.

Constraints enforce data integrity at the database level — they are the last line of defense against bad data.

sql
CREATE TABLE employees (
  id          SERIAL PRIMARY KEY,            -- PRIMARY KEY: unique + not null
  email       VARCHAR(255) UNIQUE NOT NULL,  -- UNIQUE, NOT NULL
  department_id INT REFERENCES departments(id) ON DELETE SET NULL, -- FOREIGN KEY
  salary      NUMERIC CHECK (salary >= 0),   -- CHECK constraint
  status      VARCHAR(20) DEFAULT 'active'   -- DEFAULT value
);

| Constraint | What it enforces |

|---|---|

| PRIMARY KEY | Uniquely identifies each row; combines UNIQUE + NOT NULL |

| FOREIGN KEY | Referential integrity between tables |

| UNIQUE | No duplicate values in a column (NULLs allowed in most DBs) |

| NOT NULL | Column cannot be empty |

| CHECK | Custom boolean condition must be true |

| DEFAULT | Value used when none is provided |


Section 2: Joins

7. Explain all types of SQL JOINs with examples.

This is one of the most common SQL interview topics. Know all five and be ready to draw a Venn diagram if asked.

Setup for examples:

sql
-- employees: id, name, department_id
-- departments: id, name

-- Sample data
employees: (1,'Ana',1), (2,'Bob',2), (3,'Carlos',NULL)
departments: (1,'Engineering'), (2,'Marketing'), (3,'HR')

INNER JOIN — only rows with matching values in both tables:

sql
SELECT e.name, d.name AS department
FROM employees e
INNER JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing
-- Carlos (no dept) and HR (no employees) are excluded

LEFT JOIN (LEFT OUTER JOIN) — all rows from the left table, matching rows from the right (NULLs where no match):

sql
SELECT e.name, d.name AS department
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing, Carlos/NULL

RIGHT JOIN (RIGHT OUTER JOIN) — all rows from the right table:

sql
SELECT e.name, d.name AS department
FROM employees e
RIGHT JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing, NULL/HR

FULL OUTER JOIN — all rows from both tables:

sql
SELECT e.name, d.name AS department
FROM employees e
FULL OUTER JOIN departments d ON e.department_id = d.id;
-- Returns: Ana/Engineering, Bob/Marketing, Carlos/NULL, NULL/HR

CROSS JOIN — every combination (Cartesian product):

sql
SELECT e.name, d.name
FROM employees e
CROSS JOIN departments d;
-- Returns 3 * 3 = 9 rows (use carefully — can explode on large tables)

SELF JOIN — joining a table to itself:

sql
-- Find employees and their managers (both in the same table)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

8. What is the difference between `UNION` and `UNION ALL`?

Both combine result sets from two SELECT queries. The difference is deduplication.

sql
-- UNION removes duplicate rows (slower — requires sorting/hashing)
SELECT city FROM customers
UNION
SELECT city FROM suppliers;

-- UNION ALL keeps all rows including duplicates (faster)
SELECT city FROM customers
UNION ALL
SELECT city FROM suppliers;

Rules: Both queries must have the same number of columns and compatible data types. Column names come from the first query.

When to use which: Use UNION ALL by default (faster). Use UNION only when you explicitly need deduplication.


9. How do you find rows in Table A that have no match in Table B?

This comes up constantly — finding orphaned records, unmatched orders, customers who never purchased, etc.

Method 1: LEFT JOIN + IS NULL (most readable)

sql
SELECT e.id, e.name
FROM employees e
LEFT JOIN departments d ON e.department_id = d.id
WHERE d.id IS NULL;
-- Returns employees with no matching department

Method 2: NOT EXISTS (often fastest with proper indexes)

sql
SELECT e.id, e.name
FROM employees e
WHERE NOT EXISTS (
  SELECT 1
  FROM departments d
  WHERE d.id = e.department_id
);

Method 3: NOT IN (use carefully — fails silently with NULLs)

sql
-- DANGEROUS if department_id contains any NULLs — returns 0 rows
SELECT id, name
FROM employees
WHERE department_id NOT IN (SELECT id FROM departments);

-- Safe version (explicitly exclude NULLs)
SELECT id, name
FROM employees
WHERE department_id NOT IN (
  SELECT id FROM departments WHERE id IS NOT NULL
);

What to say in the interview: "I prefer NOT EXISTS or LEFT JOIN / IS NULL. I avoid NOT IN when the subquery might return NULLs because of the three-valued logic trap."


10. Write a query to find employees who earn more than their manager.

Classic interview question — tests self-join understanding.

sql
SELECT
  e.name         AS employee,
  e.salary       AS employee_salary,
  m.name         AS manager,
  m.salary       AS manager_salary
FROM employees e
INNER JOIN employees m ON e.manager_id = m.id
WHERE e.salary > m.salary;

Variation: Find the count per department:

sql
SELECT
  e.department_id,
  COUNT(*) AS count_earning_more_than_manager
FROM employees e
INNER JOIN employees m ON e.manager_id = m.id
WHERE e.salary > m.salary
GROUP BY e.department_id;

Section 3: Aggregation and Grouping

11. Write a query to find the second-highest salary.

Multiple approaches — know at least two.

Method 1: Subquery with MAX

sql
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

Method 2: OFFSET (simple, readable)

sql
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;

Method 3: Using a CTE with DENSE_RANK (most robust — handles ties)

sql
WITH ranked AS (
  SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
  FROM employees
)
SELECT salary AS second_highest
FROM ranked
WHERE rnk = 2
LIMIT 1;

What to say: "I prefer the window function approach because it handles ties correctly and is easy to generalize to the Nth highest salary by changing the rnk = 2 condition."


12. Write a query to find duplicate records in a table.

sql
-- Find email addresses that appear more than once
SELECT email, COUNT(*) AS occurrences
FROM customers
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY occurrences DESC;

-- Get all columns for duplicate rows
SELECT *
FROM customers
WHERE email IN (
  SELECT email
  FROM customers
  GROUP BY email
  HAVING COUNT(*) > 1
)
ORDER BY email;

Deleting duplicates while keeping one copy (common follow-up):

sql
-- Keep the row with the lowest id, delete the rest
DELETE FROM customers
WHERE id NOT IN (
  SELECT MIN(id)
  FROM customers
  GROUP BY email
);

-- PostgreSQL-style using ctid (row identifier)
DELETE FROM customers a
USING customers b
WHERE a.email = b.email
  AND a.id > b.id;

13. Write a query to get the total sales per product category and the percentage each category represents.

Tests your ability to combine aggregation with a self-referencing calculation.

sql
SELECT
  category,
  SUM(sale_amount)                                              AS category_total,
  ROUND(
    SUM(sale_amount) * 100.0 / SUM(SUM(sale_amount)) OVER (), 2
  )                                                             AS pct_of_total
FROM sales
GROUP BY category
ORDER BY category_total DESC;

The key trick: SUM(SUM(sale_amount)) OVER () is a window function applied on top of the grouped aggregate — it gives the grand total without a second subquery.


14. How does `GROUP BY` handle NULLs?

All NULL values are grouped together into a single group — as if NULL equals NULL for grouping purposes (even though NULL = NULL is false in a WHERE clause).

sql
SELECT department_id, COUNT(*) AS headcount
FROM employees
GROUP BY department_id;
-- Employees with NULL department_id appear as one group with department_id = NULL

If you want to include a label for the NULL group:

sql
SELECT COALESCE(department_id::text, 'No Department') AS dept, COUNT(*)
FROM employees
GROUP BY department_id;

15. Write a query that calculates a running total.

sql
SELECT
  order_date,
  sale_amount,
  SUM(sale_amount) OVER (ORDER BY order_date) AS running_total
FROM orders
ORDER BY order_date;

Running total per customer (partitioned):

sql
SELECT
  customer_id,
  order_date,
  sale_amount,
  SUM(sale_amount) OVER (
    PARTITION BY customer_id
    ORDER BY order_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS customer_running_total
FROM orders;

Section 4: Window Functions

16. What are window functions and how are they different from aggregate functions?

The key difference: Aggregate functions collapse multiple rows into one. Window functions compute a value for each row based on a "window" of related rows — the original rows are preserved.

sql
-- Aggregate: collapses rows
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
-- Returns one row per department

-- Window function: keeps all rows
SELECT name, department, salary,
  AVG(salary) OVER (PARTITION BY department) AS dept_avg
FROM employees;
-- Returns all employees PLUS the department average on each row

The OVER() clause is what makes a function a window function. An empty OVER() means the window is the entire result set.


17. Explain `ROW_NUMBER`, `RANK`, and `DENSE_RANK`. When would you use each?

All three assign numbers to rows, but they handle ties differently:

sql
SELECT
  name,
  salary,
  ROW_NUMBER()  OVER (ORDER BY salary DESC) AS row_num,    -- always unique: 1,2,3,4
  RANK()        OVER (ORDER BY salary DESC) AS rnk,        -- gaps after ties: 1,2,2,4
  DENSE_RANK()  OVER (ORDER BY salary DESC) AS dense_rnk   -- no gaps: 1,2,2,3
FROM employees;

Example output (two employees both earning $80k):

| name | salary | row_num | rnk | dense_rnk |

|---|---|---|---|---|

| Alice | 90000 | 1 | 1 | 1 |

| Bob | 80000 | 2 | 2 | 2 |

| Carol | 80000 | 3 | 2 | 2 |

| Dave | 70000 | 4 | 4 | 3 |

When to use which:

  • ROW_NUMBER: When you need exactly one row per rank (e.g., deduplication, pagination).
  • RANK: When you want the "sports ranking" feel (ties skip numbers, like Olympic medals).
  • DENSE_RANK: When you want the Nth distinct value (e.g., "find the 3rd highest salary").

18. Write a query to get the top 3 earners per department.

Classic interview question that combines DENSE_RANK with a CTE.

sql
WITH ranked_employees AS (
  SELECT
    name,
    department,
    salary,
    DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
  FROM employees
)
SELECT name, department, salary, dept_rank
FROM ranked_employees
WHERE dept_rank <= 3
ORDER BY department, dept_rank;

Why not just LIMIT 3 per department? Because LIMIT is not partition-aware — you need the window function to rank within each department separately.


19. What is `LAG` and `LEAD`? Write an example.

LAG accesses a previous row's value; LEAD accesses a future row's value — all within the same result set without a self-join.

sql
-- Month-over-month revenue change
SELECT
  month,
  revenue,
  LAG(revenue, 1) OVER (ORDER BY month)          AS prev_month_revenue,
  revenue - LAG(revenue, 1) OVER (ORDER BY month) AS revenue_change,
  ROUND(
    (revenue - LAG(revenue, 1) OVER (ORDER BY month)) * 100.0
    / NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
    2
  ) AS pct_change
FROM monthly_revenue
ORDER BY month;

Arguments: LAG(column, offset, default) — offset defaults to 1, default value is returned when there is no prior row (instead of NULL).


20. What is the difference between `ROWS` and `RANGE` in a window frame?

When you write OVER (ORDER BY date ROWS BETWEEN ...), you are defining which rows make up the window for each calculation.

sql
-- ROWS: physical rows relative to current row
SELECT
  date,
  sales,
  SUM(sales) OVER (
    ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW  -- last 7 rows (by position)
  ) AS rolling_7_day_sum
FROM daily_sales;

-- RANGE: logical range based on the ORDER BY value
-- RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
-- includes all rows within 6 days of the current date value,
-- regardless of how many physical rows that is
SELECT
  date,
  sales,
  SUM(sales) OVER (
    ORDER BY date
    RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
  ) AS rolling_7_day_sum
FROM daily_sales;

Practical difference: ROWS is positional (fast, deterministic). RANGE is value-based (handles gaps in dates correctly). For most rolling calculations, ROWS is what you want and is more performant.


Section 5: Subqueries and CTEs

21. What is a CTE and when would you use it instead of a subquery?

A CTE (Common Table Expression) is a named temporary result set defined with WITH. It runs once, is readable, and can be referenced multiple times.

sql
-- Subquery version (nested, harder to read)
SELECT name, salary
FROM employees
WHERE department_id IN (
  SELECT id FROM departments WHERE budget > 1000000
);

-- CTE version (readable, named)
WITH well_funded_depts AS (
  SELECT id FROM departments WHERE budget > 1000000
)
SELECT name, salary
FROM employees
WHERE department_id IN (SELECT id FROM well_funded_depts);

When to use CTEs:

  • The same subquery is needed more than once.
  • The logic has multiple steps that are easier to read top-to-bottom.
  • You are writing a recursive query (you must use a CTE for recursion).
  • Debugging — you can comment out outer parts and inspect intermediate results.

Important note: In most databases, a CTE is not materialized (it is just a syntax transformation). If you need the result to be computed once and reused, use a temporary table or check your database's MATERIALIZED option (PostgreSQL 12+).


22. Write a recursive CTE to traverse a hierarchy (employee → manager chain).

sql
-- employees table: id, name, manager_id (NULL for the CEO)

WITH RECURSIVE org_chart AS (
  -- Base case: start with the top-level employee (no manager)
  SELECT id, name, manager_id, 0 AS level, name::text AS path
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Recursive case: join to the CTE itself
  SELECT e.id, e.name, e.manager_id, oc.level + 1,
         oc.path || ' > ' || e.name
  FROM employees e
  INNER JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT id, name, level, path
FROM org_chart
ORDER BY path;

Output example:

CEO (level 0, path: CEO)
  CTO (level 1, path: CEO > CTO)
    Dev Lead (level 2, path: CEO > CTO > Dev Lead)
      Ana (level 3, path: CEO > CTO > Dev Lead > Ana)

Safeguard against infinite loops: Add WHERE level < 10 or use CYCLE detection (PostgreSQL 14+).


23. What is the difference between a correlated subquery and a non-correlated subquery?

Non-correlated: Runs once, independently of the outer query.

sql
-- Inner query runs once, produces a list of dept IDs
SELECT name FROM employees
WHERE department_id IN (
  SELECT id FROM departments WHERE location = 'NYC'
);

Correlated: References columns from the outer query, so it runs once per row of the outer query.

sql
-- For each employee, the inner query runs with THAT employee's department_id
SELECT e.name, e.salary
FROM employees e
WHERE e.salary > (
  SELECT AVG(salary)
  FROM employees
  WHERE department_id = e.department_id  -- correlates to outer query
);

Performance implication: Correlated subqueries can be slow on large tables because they execute N times (once per outer row). Often replaceable with a window function or a join:

sql
-- Same result, better performance
SELECT e.name, e.salary
FROM employees e
JOIN (
  SELECT department_id, AVG(salary) AS avg_sal
  FROM employees
  GROUP BY department_id
) dept_avg ON e.department_id = dept_avg.department_id
WHERE e.salary > dept_avg.avg_sal;

24. Write a query to find the department with the highest average salary.

sql
-- Simple version
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
ORDER BY avg_salary DESC
LIMIT 1;

-- Robust version (handles ties and includes dept name)
WITH dept_avgs AS (
  SELECT
    d.name AS department,
    AVG(e.salary) AS avg_salary,
    RANK() OVER (ORDER BY AVG(e.salary) DESC) AS rnk
  FROM employees e
  JOIN departments d ON e.department_id = d.id
  GROUP BY d.name
)
SELECT department, ROUND(avg_salary, 2) AS avg_salary
FROM dept_avgs
WHERE rnk = 1;

Section 6: Data Modification and Transactions

25. Explain ACID properties.

ACID is the foundation of database reliability. Every senior-level SQL interview expects you to know this.

| Property | Meaning | Example |

|---|---|---|

| Atomicity | A transaction succeeds completely or fails completely — no partial updates | Bank transfer: debit AND credit happen, or neither does |

| Consistency | A transaction brings the DB from one valid state to another — all constraints hold | You cannot transfer more money than you have (CHECK constraint) |

| Isolation | Concurrent transactions do not interfere with each other | Two users booking the last seat do not both succeed |

| Durability | Once committed, data survives system failures | A committed order is not lost if the server crashes |

sql
-- Example of atomicity
BEGIN;
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;  -- debit
  UPDATE accounts SET balance = balance + 500 WHERE id = 2;  -- credit
COMMIT;  -- both succeed
-- If anything fails, ROLLBACK automatically reverts both updates

26. What are transaction isolation levels?

Isolation levels are a trade-off between data consistency and performance. Know the four standard levels and the anomalies they prevent.

| Level | Dirty Read | Non-Repeatable Read | Phantom Read |

|---|---|---|---|

| READ UNCOMMITTED | Possible | Possible | Possible |

| READ COMMITTED (default in most DBs) | Prevented | Possible | Possible |

| REPEATABLE READ | Prevented | Prevented | Possible |

| SERIALIZABLE | Prevented | Prevented | Prevented |

  • Dirty Read: Reading data from an uncommitted transaction.
  • Non-Repeatable Read: Reading the same row twice in one transaction and getting different values (another transaction updated it).
  • Phantom Read: Re-running the same query in one transaction and getting different rows (another transaction inserted/deleted rows).
sql
-- Set isolation level in PostgreSQL
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
  SELECT balance FROM accounts WHERE id = 1;
  -- ... do some work ...
  SELECT balance FROM accounts WHERE id = 1; -- guaranteed same result
COMMIT;

27. Write an `UPSERT` — insert a row if it does not exist, update it if it does.

sql
-- PostgreSQL: INSERT ... ON CONFLICT
INSERT INTO user_preferences (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON CONFLICT (user_id) DO UPDATE
  SET theme    = EXCLUDED.theme,
      language = EXCLUDED.language,
      updated_at = NOW();

-- MySQL: INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO user_preferences (user_id, theme, language)
VALUES (42, 'dark', 'en')
ON DUPLICATE KEY UPDATE
  theme    = VALUES(theme),
  language = VALUES(language);

The EXCLUDED table (PostgreSQL) refers to the row that failed to insert — it lets you reference the proposed values in the DO UPDATE clause.


28. How do you update rows in one table based on values from another table?

sql
-- Standard SQL using a subquery
UPDATE employees
SET salary = salary * 1.10
WHERE department_id = (
  SELECT id FROM departments WHERE name = 'Engineering'
);

-- PostgreSQL / SQL Server: UPDATE with JOIN (more readable for multiple columns)
UPDATE employees e
SET salary = e.salary * 1.10,
    updated_at = NOW()
FROM departments d
WHERE e.department_id = d.id
  AND d.name = 'Engineering';

-- MySQL syntax (different!)
UPDATE employees e
JOIN departments d ON e.department_id = d.id
SET e.salary = e.salary * 1.10
WHERE d.name = 'Engineering';

Section 7: Indexes and Performance

29. What is a database index and how does it work?

An index is a separate data structure (typically a B-tree) that allows the database to find rows without scanning every row in a table.

sql
-- Create a basic index
CREATE INDEX idx_employees_email ON employees(email);

-- Create a composite index
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);

-- Create a partial index (only indexes rows matching a condition)
CREATE INDEX idx_active_users ON users(email) WHERE status = 'active';

-- Create a unique index (enforces uniqueness as a side effect)
CREATE UNIQUE INDEX idx_unique_email ON customers(email);

How a B-tree index works: The index is a sorted copy of the indexed column(s) with pointers back to the actual table rows (heap). A query like WHERE email = 'x@y.com' uses a binary search on the index (O(log n)) instead of a full table scan (O(n)).

When to index:

  • Columns used frequently in WHERE, JOIN ON, and ORDER BY.
  • Foreign key columns (the referencing side is often not indexed automatically).
  • Columns with high cardinality (many distinct values).

When NOT to index:

  • Columns rarely queried.
  • Very low cardinality columns (boolean flags — the DB might just scan the table anyway).
  • Tables that are written to very frequently (every insert/update must also update all indexes).

30. What is the difference between a clustered and a non-clustered index?

| Type | Data storage | Count per table | Example |

|---|---|---|---|

| Clustered | Table rows are physically stored in index order | One | Primary key in SQL Server / InnoDB (MySQL) |

| Non-clustered | Separate structure with pointers to the actual rows | Many | Any additional index you create |

In PostgreSQL: PostgreSQL does not have a traditional clustered index. All indexes are non-clustered (heap-based), but you can use CLUSTER tablename USING indexname to physically reorder the table once — though it does not stay sorted on future inserts.


31. What is an `EXPLAIN` plan and how do you read it?

EXPLAIN shows the query execution plan — how the database engine will retrieve your data.

sql
EXPLAIN ANALYZE
SELECT e.name, d.name
FROM employees e
JOIN departments d ON e.department_id = d.id
WHERE e.salary > 80000;

Key nodes to recognize:

  • Seq Scan — full table scan (red flag on large tables).
  • Index Scan — uses an index (good).
  • Index Only Scan — only reads the index, never touches the heap (best).
  • Hash Join — builds a hash table in memory from one input, probes with the other (fast for large joins).
  • Nested Loop — for each row in outer, searches inner (fast when inner is indexed and small).
  • Sort — explicit sort operation (expensive on large data without a supporting index).

Numbers to look at:

  • cost=start..total — planner's estimated cost (lower is better, relative scale).
  • actual time=start..end — real execution time in milliseconds (with ANALYZE).
  • rows — number of rows returned.
  • Buffers: shared hit/read — cache hits vs. disk reads.

32. How would you optimize a slow query?

Step-by-step answer for the interview:

  1. 1Run EXPLAIN ANALYZE to identify the bottleneck (Seq Scan? Sort? Nested Loop on a large table?).
  2. 2Check indexes — is the WHERE column indexed? Is the join column indexed on both sides?
  3. 3Check query logic — are you filtering early (WHERE before JOIN)? Are you selecting only the columns you need?
  4. 4Look at data volume — does a filter reduce data significantly before expensive operations?
  5. 5Rewrite if needed — replace correlated subqueries with joins or window functions, use CTEs to isolate expensive computations.
  6. 6Check table statistics — run ANALYZE tablename so the planner has fresh statistics.
  7. 7Consider partial indexes or composite indexes matching the exact query pattern.
sql
-- Before: slow query (correlated subquery runs once per employee row)
SELECT * FROM employees e
WHERE salary = (SELECT MAX(salary) FROM employees WHERE department_id = e.department_id);

-- After: fast equivalent with window function
SELECT * FROM (
  SELECT *, MAX(salary) OVER (PARTITION BY department_id) AS max_dept_salary
  FROM employees
) sub
WHERE salary = max_dept_salary;

Section 8: Schema Design and Normalization

33. What are the normal forms in database design?

Normalization is the process of organizing tables to reduce redundancy and improve data integrity.

1NF (First Normal Form):

  • Each cell contains a single atomic value.
  • Each column has a consistent data type.
  • No repeating groups.
sql
-- Bad (1NF violation — phone_numbers is multi-valued)
customers: id | name | phone_numbers
              1 | Ana  | "555-1234, 555-5678"

-- Good (1NF)
customer_phones: customer_id | phone_number
                 1           | 555-1234
                 1           | 555-5678

2NF: Must be in 1NF + every non-key column depends on the ENTIRE primary key (no partial dependency — only relevant for composite keys).

3NF: Must be in 2NF + no transitive dependencies (non-key columns depend only on the key, not on other non-key columns).

sql
-- Bad (3NF violation: zip_code determines city, not the employee id)
employees: id | name | zip_code | city

-- Good (3NF)
employees: id | name | zip_code
zip_codes:  zip_code | city

BCNF (Boyce-Codd Normal Form): Stricter than 3NF — every determinant must be a candidate key.

When to denormalize: For read-heavy analytical workloads (data warehouses), denormalization reduces joins and improves query speed. Normalization is for OLTP; star/snowflake schemas for OLAP.


34. Design a schema for a simple e-commerce order system.

This is a common system design variant of SQL interviews.

sql
CREATE TABLE customers (
  id         SERIAL PRIMARY KEY,
  email      VARCHAR(255) UNIQUE NOT NULL,
  name       VARCHAR(100) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE products (
  id          SERIAL PRIMARY KEY,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  price       NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
  stock_qty   INTEGER NOT NULL DEFAULT 0 CHECK (stock_qty >= 0)
);

CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(id),
  status      VARCHAR(50) NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMP DEFAULT NOW(),
  total_amount NUMERIC(12, 2) -- denormalized for fast reads
);

CREATE TABLE order_items (
  id          SERIAL PRIMARY KEY,
  order_id    INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product_id  INTEGER NOT NULL REFERENCES products(id),
  quantity    INTEGER NOT NULL CHECK (quantity > 0),
  unit_price  NUMERIC(10, 2) NOT NULL, -- snapshot price at time of order
  UNIQUE (order_id, product_id)
);

CREATE INDEX idx_orders_customer  ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);

Design decisions to explain:

  • unit_price on order_items is a price snapshot — because product prices change over time, you never want to recalculate historical order totals from the current price.
  • ON DELETE CASCADE on order_items — deleting an order removes its line items automatically.
  • Separate indexes on foreign keys because most databases do not create them automatically (PostgreSQL does not; MySQL InnoDB does).

35. What is the difference between OLTP and OLAP databases?

| | OLTP | OLAP |

|---|---|---|

| Purpose | Day-to-day operations | Analytics and reporting |

| Query type | Many small reads/writes | Few complex, large reads |

| Data model | Normalized (3NF) | Denormalized (star/snowflake) |

| Latency target | Milliseconds | Seconds to minutes |

| Example systems | PostgreSQL, MySQL | Snowflake, BigQuery, Redshift |

| Indexing strategy | Many targeted indexes | Columnar storage, partitioning |


Section 9: Advanced Queries

36. Write a query to pivot rows into columns.

Given: A scores table with student_id, subject, score. Output one row per student with columns math, science, english.

sql
-- Using conditional aggregation (works everywhere)
SELECT
  student_id,
  MAX(CASE WHEN subject = 'math'    THEN score END) AS math,
  MAX(CASE WHEN subject = 'science' THEN score END) AS science,
  MAX(CASE WHEN subject = 'english' THEN score END) AS english
FROM scores
GROUP BY student_id;

-- PostgreSQL: using crosstab (tablefunc extension)
SELECT * FROM crosstab(
  'SELECT student_id, subject, score FROM scores ORDER BY 1,2',
  'SELECT DISTINCT subject FROM scores ORDER BY 1'
) AS pivot(student_id INT, english NUMERIC, math NUMERIC, science NUMERIC);

37. What is a materialized view and when would you use one?

A view is a saved query — it runs every time you query it. A materialized view is a saved query whose results are stored physically — like a cached table.

sql
-- Create a materialized view of expensive aggregation
CREATE MATERIALIZED VIEW monthly_sales_summary AS
SELECT
  DATE_TRUNC('month', order_date) AS month,
  product_category,
  SUM(sale_amount) AS total_sales,
  COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
JOIN order_items USING (order_id)
JOIN products USING (product_id)
GROUP BY 1, 2;

-- Refresh the materialized view (runs the underlying query again)
REFRESH MATERIALIZED VIEW monthly_sales_summary;

-- Refresh without locking reads (PostgreSQL 9.4+)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales_summary;

When to use: Dashboard queries that are too slow to run on-demand, reporting aggregations, anything that takes multiple seconds and is acceptable to be slightly stale (refresh on a schedule via cron or event trigger).


38. Write a query to detect gaps in a sequence (find missing IDs or dates).

sql
-- Find missing order IDs in a sequence
WITH expected AS (
  SELECT generate_series(
    (SELECT MIN(id) FROM orders),
    (SELECT MAX(id) FROM orders)
  ) AS expected_id
)
SELECT expected_id AS missing_id
FROM expected
WHERE expected_id NOT IN (SELECT id FROM orders);

-- Find missing dates in a date series
WITH date_range AS (
  SELECT generate_series(
    '2024-01-01'::date,
    '2024-12-31'::date,
    '1 day'::interval
  )::date AS expected_date
)
SELECT expected_date AS missing_date
FROM date_range
WHERE expected_date NOT IN (SELECT DISTINCT sale_date FROM daily_sales);

39. Write a query to calculate a 7-day rolling average.

sql
SELECT
  sale_date,
  daily_revenue,
  ROUND(
    AVG(daily_revenue) OVER (
      ORDER BY sale_date
      ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ), 2
  ) AS rolling_7_day_avg
FROM daily_sales
ORDER BY sale_date;

What to mention: "I use ROWS BETWEEN 6 PRECEDING AND CURRENT ROW to include 7 days (the current day plus 6 previous days). If the data has gaps (missing dates), I would first generate a complete date series and LEFT JOIN the actual sales data to it, filling NULLs with 0."


40. What is `COALESCE` vs `NULLIF` vs `ISNULL`?

sql
-- COALESCE: returns the first non-NULL argument (standard SQL, works everywhere)
SELECT COALESCE(phone, mobile, 'no contact') FROM customers;

-- NULLIF: returns NULL if two expressions are equal, otherwise returns the first
-- Most common use: prevent division by zero
SELECT total / NULLIF(denominator, 0) AS ratio FROM metrics;

-- ISNULL: SQL Server/Sybase only — equivalent to COALESCE with 2 arguments
-- Not standard SQL; prefer COALESCE for portability
SELECT ISNULL(phone, 'no phone') FROM customers; -- SQL Server only

-- NVL: Oracle-specific equivalent of COALESCE with 2 arguments
SELECT NVL(phone, 'no phone') FROM customers; -- Oracle only

41. Write a query to transpose/unpivot columns into rows.

Given: A quarterly_sales table with columns q1, q2, q3, q4. Output one row per quarter per product.

sql
-- Using UNION ALL (universally portable)
SELECT product_id, 'Q1' AS quarter, q1 AS revenue FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q2', q2 FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q3', q3 FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q4', q4 FROM quarterly_sales
ORDER BY product_id, quarter;

-- PostgreSQL: VALUES with a CROSS JOIN LATERAL (cleaner for many columns)
SELECT qs.product_id, v.quarter, v.revenue
FROM quarterly_sales qs
CROSS JOIN LATERAL (VALUES
  ('Q1', qs.q1),
  ('Q2', qs.q2),
  ('Q3', qs.q3),
  ('Q4', qs.q4)
) v(quarter, revenue);

42. How do you handle slowly changing dimensions (SCD Type 2)?

SCD Type 2 tracks historical changes by adding new rows with validity dates rather than overwriting old values — critical knowledge for data warehouse roles.

sql
CREATE TABLE dim_customer (
  surrogate_key  SERIAL PRIMARY KEY,
  customer_id    INTEGER NOT NULL,          -- business/natural key
  name           VARCHAR(100),
  email          VARCHAR(255),
  city           VARCHAR(100),
  valid_from     DATE NOT NULL,
  valid_to       DATE,                      -- NULL means current record
  is_current     BOOLEAN DEFAULT TRUE
);

-- When a customer changes their city:
-- Step 1: Expire the old record
UPDATE dim_customer
SET valid_to   = CURRENT_DATE - 1,
    is_current = FALSE
WHERE customer_id = 42 AND is_current = TRUE;

-- Step 2: Insert the new record
INSERT INTO dim_customer (customer_id, name, email, city, valid_from, is_current)
VALUES (42, 'Ana García', 'ana@email.com', 'Buenos Aires', CURRENT_DATE, TRUE);

-- Query: what city was customer 42 in on 2024-06-15?
SELECT city
FROM dim_customer
WHERE customer_id = 42
  AND valid_from <= '2024-06-15'
  AND (valid_to >= '2024-06-15' OR valid_to IS NULL);

43. Write a query to find consecutive days a user was active (streaks).

This is a notoriously tricky SQL problem — a favorite in data engineer and analyst interviews.

sql
-- Method: date - ROW_NUMBER() trick
-- Consecutive dates produce the same "group date" when you subtract their row number

WITH user_activity AS (
  SELECT DISTINCT user_id, activity_date
  FROM events
),
ranked AS (
  SELECT
    user_id,
    activity_date,
    activity_date - CAST(ROW_NUMBER() OVER (
      PARTITION BY user_id ORDER BY activity_date
    ) AS INT) AS streak_group
  FROM user_activity
),
streaks AS (
  SELECT
    user_id,
    streak_group,
    MIN(activity_date) AS streak_start,
    MAX(activity_date) AS streak_end,
    COUNT(*) AS streak_length
  FROM ranked
  GROUP BY user_id, streak_group
)
SELECT user_id, streak_start, streak_end, streak_length
FROM streaks
ORDER BY streak_length DESC;

Why the trick works: If a user was active on Jan 1, 2, 3 (consecutive), their row numbers are 1, 2, 3. Subtracting: Jan 1 - 1 = Dec 31, Jan 2 - 2 = Dec 31, Jan 3 - 3 = Dec 31. Same date → same group. A gap in activity breaks the equality.


44. How do you write a query that returns every Nth row?

sql
-- Return every 5th row (row 5, 10, 15, ...)
SELECT *
FROM (
  SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn
  FROM employees
) numbered
WHERE rn % 5 = 0;

-- In PostgreSQL, you can also use the ctid trick for approximation
-- but ROW_NUMBER() is always correct and portable

45. What is a stored procedure vs. a function vs. a trigger?

| Object | Returns value? | Can modify data? | Called how? |

|---|---|---|---|

| Stored Procedure | Optional (via OUT params) | Yes | CALL procedure_name() |

| Function | Required | No (in pure SQL functions) / Yes (in PostgreSQL with VOLATILE) | SELECT function_name() or in expressions |

| Trigger | N/A (returns trigger) | Yes | Automatically on INSERT/UPDATE/DELETE |

sql
-- Function: calculate total order value
CREATE OR REPLACE FUNCTION get_order_total(p_order_id INT)
RETURNS NUMERIC AS $$
  SELECT SUM(quantity * unit_price)
  FROM order_items
  WHERE order_id = p_order_id;
$$ LANGUAGE SQL STABLE;

-- Usage
SELECT id, get_order_total(id) AS total FROM orders;

-- Trigger: automatically update updated_at timestamp
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_employees_updated_at
BEFORE UPDATE ON employees
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

46. Explain deadlocks and how to prevent them.

A deadlock occurs when two transactions each hold a lock that the other needs — they wait forever.

Transaction A:  locks row 1, waits for row 2
Transaction B:  locks row 2, waits for row 1
→ Neither can proceed

Prevention strategies:

  1. 1Always acquire locks in the same order across all transactions (e.g., always lock accounts by ascending ID).
  2. 2Keep transactions short — acquire locks as late as possible and release as soon as possible.
  3. 3Use SELECT ... FOR UPDATE NOWAIT to fail fast rather than wait.
  4. 4Set a lock_timeout so a transaction gives up after a reasonable wait.
sql
-- PostgreSQL: lock rows in a consistent order to prevent deadlocks
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- Process...
COMMIT;

-- Fail immediately if lock cannot be acquired (avoids long waits)
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- Give up after 5 seconds
SET lock_timeout = '5s';

47. Write a query using `FILTER` to compute conditional aggregates.

FILTER is a cleaner alternative to CASE WHEN inside aggregate functions (PostgreSQL, DuckDB).

sql
-- CASE WHEN approach (works everywhere)
SELECT
  COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed_orders,
  COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled_orders,
  SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END) AS completed_revenue
FROM orders;

-- FILTER approach (cleaner, PostgreSQL/DuckDB)
SELECT
  COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders,
  COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_orders,
  SUM(total_amount) FILTER (WHERE status = 'completed') AS completed_revenue
FROM orders;

48. How would you find the median salary in SQL?

SQL does not have a native MEDIAN function in most databases (PostgreSQL has PERCENTILE_CONT).

sql
-- PostgreSQL: PERCENTILE_CONT
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary
FROM employees;

-- Standard SQL workaround using window functions
SELECT AVG(salary) AS median_salary
FROM (
  SELECT salary,
    COUNT(*) OVER ()                              AS total_count,
    ROW_NUMBER() OVER (ORDER BY salary)           AS rn_asc,
    ROW_NUMBER() OVER (ORDER BY salary DESC)      AS rn_desc
  FROM employees
) t
WHERE rn_asc IN (total_count / 2, total_count / 2 + 1, (total_count + 1) / 2)
  AND rn_desc IN (total_count / 2, total_count / 2 + 1, (total_count + 1) / 2);

Section 10: Behavioral and Conceptual Questions

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

| | Primary Key | Unique Key |

|---|---|---|

| NULLs allowed? | No | Yes (usually one NULL per column) |

| Count per table | Exactly one | Multiple allowed |

| Clustered index? | Yes (in most DBs) | No |

| Purpose | Main row identifier | Enforce uniqueness on alternate columns |

sql
CREATE TABLE users (
  id    SERIAL PRIMARY KEY,      -- primary key: unique + not null + clustered
  email VARCHAR(255) UNIQUE,     -- unique key: unique but can be null (bad practice here)
  ssn   VARCHAR(11) UNIQUE       -- alternate unique identifier
);

50. How would you find all tables in a database that contain a specific column name?

Tests your knowledge of information schema — useful for exploring unfamiliar databases.

sql
-- Standard SQL (works in PostgreSQL, MySQL, SQL Server)
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ILIKE '%email%'   -- case-insensitive search
ORDER BY table_schema, table_name;

-- PostgreSQL: find all foreign keys referencing a specific table
SELECT
  conname AS constraint_name,
  conrelid::regclass AS table_name,
  a.attname AS column_name,
  confrelid::regclass AS referenced_table
FROM pg_constraint
JOIN pg_attribute a ON a.attrelid = conrelid AND a.attnum = ANY(conkey)
WHERE contype = 'f'                  -- 'f' = foreign key
  AND confrelid = 'customers'::regclass;

Bonus: 5 Common Patterns You Need to Memorize

Pattern 1: Find records updated in the last N days

sql
SELECT * FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days';

Pattern 2: Pagination with LIMIT/OFFSET

sql
-- Page 3 of 20 results per page (0-indexed pages)
SELECT * FROM products
ORDER BY id
LIMIT 20 OFFSET 40;  -- (page_number - 1) * page_size

Pattern 3: String aggregation (group rows into a comma-separated string)

sql
-- PostgreSQL
SELECT order_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products
FROM order_items JOIN products USING (product_id)
GROUP BY order_id;

-- MySQL
SELECT order_id, GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', ')
FROM order_items JOIN products USING (product_id)
GROUP BY order_id;

Pattern 4: Insert from a SELECT

sql
INSERT INTO archived_orders (id, customer_id, total, archived_at)
SELECT id, customer_id, total, NOW()
FROM orders
WHERE created_at < '2023-01-01' AND status = 'completed';

Pattern 5: Conditional update (update only specific columns)

sql
UPDATE employees
SET salary = CASE
  WHEN performance_rating = 'A' THEN salary * 1.15
  WHEN performance_rating = 'B' THEN salary * 1.08
  WHEN performance_rating = 'C' THEN salary * 1.03
  ELSE salary  -- no change for others
END
WHERE review_year = 2024;

How to Prepare for Your SQL Interview

Week 1: Nail fundamentals (questions 1–15). Run every query. Understand the WHY behind each answer, not just the syntax.

Week 2: Practice joins and aggregations until they feel automatic. Write 10 join queries on a real dataset — download a free one from Kaggle or use PostgreSQL's built-in dvdrental sample database.

Week 3: Master window functions. They appear in nearly every mid-level and senior interview. ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM OVER, AVG OVER — know all of them.

Week 4: System design and performance. Read execution plans. Practice EXPLAIN ANALYZE on your own queries.

The day before: Review the streak problem (Q43), the top-N per group pattern (Q18), and the self-join salary comparison (Q10). These three come up constantly.

In the interview itself:

  • Think out loud. Interviewers want to see your reasoning process.
  • State assumptions before writing code ("I am assuming salary can be NULL — I will use NULLIF to handle division").
  • Mention trade-offs ("This works, but on a table with 50 million rows I would add an index on department_id and check the query plan first").
  • If you know multiple approaches, name them and say which one you prefer and why.

SQL fluency is something you build by doing, not by reading. Run these queries. Break them. Change the data. The interview is not testing whether you have memorized syntax — it is testing whether you can think through a data problem under pressure.

FAQ

How many SQL questions are typically asked in a technical interview?+

Most technical interviews include 2–5 SQL questions. Analyst roles tend to ask more (sometimes a take-home with 5–10 queries); engineering interviews often include 1–2 SQL questions alongside system design and coding challenges. This guide covers 50 questions so you are over-prepared for any scenario.

Which SQL dialect should I learn for interviews — PostgreSQL, MySQL, or SQL Server?+

PostgreSQL is the best choice for interview prep. It supports the most standard SQL features (CTEs, window functions, FILTER, LATERAL joins) and is what most data-heavy companies use today. Interviewers rarely penalize you for using PostgreSQL syntax in a MySQL shop — the logic matters more than the dialect.

Do I need to memorize syntax or just understand concepts?+

Both, but weighted toward concepts. Interviewers care more about whether you know that a correlated subquery runs once per outer row (and its performance implications) than whether you can spell DENSE_RANK from memory. That said, you should be able to write JOINs, GROUP BY, and basic window functions without looking them up.

What is the most commonly asked SQL interview question?+

The top-N per group problem (find the top 3 earners per department) and the second-highest salary question appear in a majority of SQL interviews. The self-join to compare employees to their managers is also extremely common. Master these three patterns and you will be prepared for 80% of SQL interview questions.

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

With 1–2 hours of daily practice, 3–4 weeks is enough to go from beginner to ready for most mid-level SQL interviews. The key accelerator is writing queries against real data, not just reading. Download the PostgreSQL dvdrental sample database or any CSV dataset from Kaggle and practice every concept hands-on.

Are window functions asked in junior-level SQL interviews?+

Sometimes, but they are more commonly tested at mid-level and above. For junior roles, focus on mastering JOINs, GROUP BY + HAVING, subqueries, and NULL handling first. If you can also write a basic ROW_NUMBER or SUM OVER query, you will stand out against other junior candidates.

Related articles

How to Answer Conflict-With-a-Coworker Interview Questions

Learn how to answer conflict-with-a-coworker interview questions with real examples and proven techniques. Stand out in tech and remote job interviews.

How to Answer 'Why Do You Want to Work Here' in Interviews

Discover expert strategies for answering 'why do you want to work here,' tailored for remote tech roles and dollar opportunities. Real, practical interview tips.

Frontend Developer Interview Questions and How to Answer Them (50+)

Complete SEO article covering 54 frontend developer interview questions with detailed answers, real code snippets across HTML, CSS, JavaScript, React, TypeScript, accessibility, security, build tools, and testing.

Full-Stack Developer Interview Questions: How to Answer Like a Pro (45+)

Comprehensive full-stack developer interview guide with 46 numbered questions covering JavaScript/TypeScript, React, CSS, REST APIs, databases, Node.js, system design, security, testing, DevOps, and advanced architecture topics. Each answer includes working code examples and production-level context.

Prepare for your real interview

Paste your job link: we research who's interviewing you and rehearse you live.

Start free →

Have an interview coming up? Install the live copilot →

InterviewHack.ai

Prepare for the exact interview: who's interviewing you, a tailored CV, and a real coach.

Product

JobsFree ATS checkerInterview-English checkSalary checkLATAM salary reportFree coursesBlogTailored CVSpoken practiceIt's free

Remote jobs

ReactPythonFull-StackLATAMArgentinaMexicoSee all →

Prepare

Spoken practiceFrontendBackendAI EngineerBy companySell with your CV

Company

For employersAboutContactPrivacyTerms

© 2026 InterviewHack.ai · Your CV is yours. Never used to train anything. · A product of IA-PTY