SQL Cheat Sheet

20 queries every interview expects⏱ 11 min readUpdated

The 20 SQL patterns that keep showing up in interviews, from WHERE vs HAVING to window functions, each with a query you can run.

0 of 20 done

Almost every backend, data, and full-stack loop includes a SQL round, and the questions repeat: filter and group, join without losing rows, rank within groups, find duplicates or the Nth highest value. Get these 20 fluent and most SQL rounds become routine.

Every example uses one tiny schema: employees(id, name, dept_id, manager_id, salary, hired_on), departments(id, name), and orders(id, customer, amount, order_date). All queries run as-is in SQLite; dialect differences for MySQL/Postgres are called out. Type each one yourself, predict the output, then tick it off.

Filter & sort

Pick the rows you want, in the order you want.

  1. 1SELECT ... WHERE

    The bread and butter: choose columns, then keep only rows matching a condition. Combine conditions with AND/OR and use parentheses to make precedence obvious.

    • = NULL is never true; use IS NULL / IS NOT NULL.
    • BETWEEN a AND b is inclusive on both ends.
    • LIKE 'A%' matches a prefix; a leading % usually can't use an index.
    sql
    SELECT name, salary
    FROM employees
    WHERE dept_id = 1
      AND (salary >= 120000 OR hired_on < '2019-01-01');
    
  2. 2ORDER BY ... LIMIT

    Sort the result, then keep the first N rows. Add OFFSET to page through results; always sort when paging, or the order is not guaranteed.

    • Without ORDER BY, row order is undefined.
    • Add a tiebreaker column so pages are stable.
    • SQL Server uses TOP n / OFFSET ... FETCH; Oracle uses FETCH FIRST n ROWS ONLY.
    • Big OFFSETs are slow; keyset paging (WHERE id > last_id) scales better.
    sql
    SELECT name, salary
    FROM employees
    ORDER BY salary DESC, name ASC
    LIMIT 3 OFFSET 0;
    

Aggregate

Collapse many rows into summary numbers.

  1. 3COUNT / SUM / AVG (aggregates)

    Aggregate functions squash a set of rows into one value. Without GROUP BY they summarise the whole table.

    • COUNT(*) counts rows; COUNT(col) skips NULLs.
    • SUM/AVG/MIN/MAX ignore NULLs; AVG of integers may truncate in some engines.
    • SUM over zero rows returns NULL, not 0; wrap with COALESCE(..., 0).
    sql
    SELECT COUNT(*)          AS headcount,
           COUNT(dept_id)    AS with_dept,
           SUM(salary)       AS payroll,
           AVG(salary)       AS avg_salary,
           MIN(hired_on)     AS first_hire,
           COUNT(DISTINCT dept_id) AS depts_used
    FROM employees;
    
  2. 4GROUP BY ... HAVING

    GROUP BY makes one output row per group; HAVING filters those groups after aggregation. WHERE runs first and filters raw rows.

    • WHERE vs HAVING: WHERE can't use aggregates; HAVING can.
    • Every selected non-aggregated column must be in GROUP BY (MySQL is lax, others error).
    • NULL keys form their own single group.
    • Execution order: FROM > WHERE > GROUP BY > HAVING > SELECT > ORDER BY > LIMIT.
    sql
    SELECT dept_id, COUNT(*) AS n, AVG(salary) AS avg_sal
    FROM employees
    WHERE hired_on >= '2019-01-01'   -- filters rows
    GROUP BY dept_id
    HAVING COUNT(*) >= 2             -- filters groups
    ORDER BY avg_sal DESC;
    

Join

Stitch tables together on matching keys.

  1. 5INNER JOIN

    Returns only row pairs where the join condition matches in both tables. Rows with no partner (like an employee with NULL dept_id) disappear.

    • JOIN alone means INNER JOIN.
    • NULL never equals NULL, so NULL keys never match.
    • A one-to-many join multiplies rows; watch out before SUM-ing.
    sql
    SELECT e.name, d.name AS dept
    FROM employees e
    INNER JOIN departments d ON d.id = e.dept_id
    ORDER BY dept, e.name;
    
  2. 6LEFT JOIN

    Keeps every row from the left table and fills the right side with NULLs when there is no match. Classic use: find rows with no partner.

    • Count e.id, not *, or empty departments show 1.
    • A WHERE filter on the right table (WHERE e.salary > 0) silently turns it into an INNER JOIN; put it in ON instead.
    • Anti-join pattern: LEFT JOIN ... WHERE right.id IS NULL.
    sql
    -- departments with no employees
    SELECT d.name, COUNT(e.id) AS headcount
    FROM departments d
    LEFT JOIN employees e ON e.dept_id = d.id
    GROUP BY d.id, d.name
    HAVING COUNT(e.id) = 0;
    
  3. 7SELF JOIN

    Join a table to itself with two aliases. Typical for hierarchies stored in one table, such as employee to manager.

    • Aliases are mandatory so you can tell the two copies apart.
    • Use LEFT JOIN to keep the top boss (manager_id IS NULL).
    • Deep hierarchies need a recursive CTE (WITH RECURSIVE).
    sql
    -- employees who earn more than their manager
    SELECT e.name, e.salary, m.name AS manager, m.salary AS mgr_salary
    FROM employees e
    JOIN employees m ON m.id = e.manager_id
    WHERE e.salary > m.salary;
    

Combine & nest

Stack result sets and put queries inside queries.

  1. 8UNION vs UNION ALL

    Both stack the rows of two queries with the same column count and compatible types. UNION removes duplicates; UNION ALL keeps everything.

    • UNION pays for a sort/hash to dedupe; prefer UNION ALL when duplicates are impossible or wanted.
    • Column names come from the first query.
    • ORDER BY applies to the whole combined result and goes at the very end.
    sql
    SELECT customer FROM orders WHERE amount >= 250
    UNION            -- deduplicated: 3 rows
    SELECT customer FROM orders WHERE order_date >= '2024-02-01';
    
    SELECT customer FROM orders WHERE amount >= 250
    UNION ALL        -- keeps duplicates: 5 rows
    SELECT customer FROM orders WHERE order_date >= '2024-02-01';
    
  2. 9Subqueries

    A query inside another query: in WHERE (a value or list), in FROM (a derived table), or in SELECT (a scalar). Runs once when it doesn't depend on the outer row.

    • A scalar subquery must return at most one row, or it errors.
    • NOT IN (subquery) returns nothing if the subquery yields any NULL; use NOT EXISTS.
    • Derived tables in FROM need an alias in most engines.
    sql
    -- earn above the company average
    SELECT name, salary
    FROM employees
    WHERE salary > (SELECT AVG(salary) FROM employees);
    
    -- in Sales or HR
    SELECT name FROM employees
    WHERE dept_id IN (SELECT id FROM departments WHERE name IN ('Sales','HR'));
    
  3. 10Correlated subqueries

    A subquery that references the outer row, so it is logically re-evaluated per row. Great for 'compare to my group' questions and for EXISTS checks.

    • Can be O(n*m); optimizers often rewrite them, but a JOIN or window function may be faster.
    • EXISTS stops at the first match and is NULL-safe.
    • Interviewers like asking you to rewrite one as a JOIN + GROUP BY.
    sql
    -- earn above their own department's average
    SELECT e.name, e.dept_id, e.salary
    FROM employees e
    WHERE e.salary > (SELECT AVG(x.salary)
                      FROM employees x
                      WHERE x.dept_id = e.dept_id);
    
    -- departments that have at least one employee
    SELECT d.name FROM departments d
    WHERE EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.id);
    
  4. 11CTEs (WITH)

    A Common Table Expression names a subquery up front so the main query reads top-down. You can chain several, and WITH RECURSIVE walks trees and graphs.

    • Improves readability; usually no faster than a subquery.
    • A CTE is visible only to the single statement that follows it.
    • Recursive form: anchor query UNION ALL recursive step referencing the CTE.
    sql
    WITH dept_pay AS (
      SELECT dept_id, SUM(salary) AS payroll
      FROM employees
      GROUP BY dept_id
    )
    SELECT d.name, p.payroll
    FROM dept_pay p
    JOIN departments d ON d.id = p.dept_id
    ORDER BY p.payroll DESC;
    
  5. 12CASE WHEN

    SQL's if/else expression. Use it to bucket values, build custom sort orders, or do conditional aggregation (pivoting) inside SUM/COUNT.

    • First matching WHEN wins; order the branches carefully.
    • No ELSE means NULL for unmatched rows.
    • COUNT(CASE WHEN cond THEN 1 END) also works because COUNT skips NULLs.
    sql
    SELECT name,
           CASE WHEN salary >= 120000 THEN 'senior'
                WHEN salary >= 80000  THEN 'mid'
                ELSE 'junior' END AS band
    FROM employees;
    
    -- pivot: headcount per band in one row
    SELECT SUM(CASE WHEN salary >= 100000 THEN 1 ELSE 0 END) AS high,
           SUM(CASE WHEN salary <  100000 THEN 1 ELSE 0 END) AS low
    FROM employees;
    

Window functions

Compute across related rows without collapsing them.

  1. 13ROW_NUMBER()

    Gives each row a unique sequence number within its partition, following the window's ORDER BY. Ideal for 'top N per group' and deduplication.

    • You can't filter on a window function in WHERE; wrap it in a subquery/CTE.
    • Ties get arbitrary distinct numbers unless you add a tiebreaker.
    • PARTITION BY is like GROUP BY but keeps every row.
    sql
    -- highest-paid employee per department
    SELECT name, dept_id, salary FROM (
      SELECT e.*,
             ROW_NUMBER() OVER (PARTITION BY dept_id
                                ORDER BY salary DESC, id) AS rn
      FROM employees e
    ) t
    WHERE rn = 1;
    
  2. 14RANK() vs DENSE_RANK()

    Both give tied rows the same rank. RANK leaves gaps after ties (1,2,2,4); DENSE_RANK doesn't (1,2,2,3).

    • Ben and Chen tie at 120000: RANK 2,2 then 4; DENSE_RANK 2,2 then 3.
    • Use DENSE_RANK for 'Nth distinct value' questions.
    • Use RANK for competition-style standings.
    sql
    SELECT name, salary,
           ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn,
           RANK()       OVER (ORDER BY salary DESC) AS rnk,
           DENSE_RANK() OVER (ORDER BY salary DESC) AS dense
    FROM employees;
    
  3. 15LAG / LEAD

    Read a value from the previous (LAG) or next (LEAD) row in the window order. Perfect for day-over-day change, gaps between events, and streaks.

    • First row's LAG is NULL unless you pass a default (3rd argument).
    • Second argument is the offset (default 1).
    • Add PARTITION BY customer to compare within each customer.
    sql
    SELECT order_date, amount,
           LAG(amount)  OVER (ORDER BY order_date, id) AS prev_amt,
           amount - LAG(amount) OVER (ORDER BY order_date, id) AS change,
           LEAD(order_date, 1, 'none') OVER (ORDER BY order_date, id) AS next_date
    FROM orders;
    
  4. 16Running total with SUM() OVER

    Adding ORDER BY to an aggregate's window makes it cumulative. Partition it to restart the total per group.

    • Default frame with ORDER BY is RANGE, which lumps tied dates together; ROWS is row-by-row.
    • Moving average: AVG(x) OVER (ORDER BY d ROWS BETWEEN 2 PRECEDING AND CURRENT ROW).
    • Drop ORDER BY to get the group total on every row.
    sql
    SELECT customer, order_date, amount,
           SUM(amount) OVER (PARTITION BY customer
                             ORDER BY order_date, id
                             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
             AS running_total
    FROM orders
    ORDER BY customer, order_date, id;
    

Classic interview questions

Asked so often they're worth memorising.

  1. 17Nth highest salary

    Find the Nth largest distinct salary. DENSE_RANK handles ties correctly; the LIMIT/OFFSET version needs DISTINCT. Return NULL when fewer than N distinct values exist.

    • Ties: ROW_NUMBER/RANK can skip or duplicate; DENSE_RANK gives the true Nth distinct value.
    • Wrapping in an outer SELECT returns NULL instead of an empty set.
    • Classic alternative: MAX(salary) WHERE salary < (SELECT MAX(salary) ...) for N = 2.
    sql
    -- 2nd highest distinct salary (N = 2)
    SELECT (SELECT DISTINCT salary FROM employees
            ORDER BY salary DESC LIMIT 1 OFFSET 1) AS second_highest;
    
    -- same via DENSE_RANK, also works per department
    SELECT DISTINCT salary FROM (
      SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
      FROM employees) t
    WHERE dr = 2;
    
  2. 18Find duplicate rows

    Group by the columns that define 'same' and keep groups with more than one row. To delete extras, keep the smallest id per group.

    • This is a textbook WHERE vs HAVING question.
    • Alternative: ROW_NUMBER() OVER (PARTITION BY cols ORDER BY id) and delete rn > 1.
    • MySQL won't let you DELETE from a table you subquery directly; wrap it in a derived table.
    sql
    -- which (customer, amount, date) combos repeat?
    SELECT customer, amount, order_date, COUNT(*) AS copies
    FROM orders
    GROUP BY customer, amount, order_date
    HAVING COUNT(*) > 1;
    
    -- delete duplicates, keeping the lowest id
    DELETE FROM orders
    WHERE id NOT IN (SELECT MIN(id) FROM orders
                     GROUP BY customer, amount, order_date);
    

Performance & transactions

Make it fast, keep it correct.

  1. 19CREATE INDEX + EXPLAIN

    An index (usually a B-tree) lets the engine seek rows instead of scanning the whole table. EXPLAIN shows the chosen plan. SQLite uses EXPLAIN QUERY PLAN; MySQL and Postgres use EXPLAIN (Postgres also EXPLAIN ANALYZE).

    • Composite index follows the leftmost-prefix rule: (dept_id, salary) helps dept_id = ? but not salary > ? alone.
    • Functions on a column (LOWER(name) = ...) or leading % wildcards usually skip the index.
    • Indexes speed reads but slow INSERT/UPDATE/DELETE and use disk.
    • Look for 'SCAN' (full table) vs 'SEARCH'/'Index Scan' in plans.
    sql
    CREATE INDEX idx_emp_dept_salary ON employees(dept_id, salary);
    
    EXPLAIN QUERY PLAN
    SELECT name FROM employees
    WHERE dept_id = 2 AND salary > 80000;
    -- SEARCH employees USING INDEX idx_emp_dept_salary (dept_id=? AND salary>?)
    
  2. 20COMMIT / ROLLBACK (transactions)

    A transaction groups statements so they all succeed (COMMIT) or none do (ROLLBACK). That gives you the A in ACID: no half-done money transfers.

    • ACID: Atomicity, Consistency, Isolation, Durability.
    • MySQL/Postgres also accept START TRANSACTION; SAVEPOINT lets you undo part of one.
    • Isolation levels (READ COMMITTED, REPEATABLE READ, SERIALIZABLE) trade anomalies like dirty/phantom reads for speed.
    • DDL auto-commits in MySQL and Oracle; Postgres can roll back DDL.
    sql
    BEGIN;
    UPDATE employees SET salary = salary - 5000 WHERE id = 2;
    UPDATE employees SET salary = salary + 5000 WHERE id = 3;
    -- something looks wrong? undo both updates
    ROLLBACK;
    
    BEGIN;
    UPDATE employees SET salary = salary * 1.10 WHERE dept_id = 3;
    COMMIT;   -- make it permanent
    

Quick tips

  • Say the logical execution order out loud: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. It explains most 'why doesn't this work' bugs.
  • Always ask about NULLs and ties before writing the query; they are where most interview answers break.
  • Build big queries in CTE steps and check each step's output before moving on.
  • Practise on a real engine (SQLite or Postgres in Docker), not just on paper.
  • When asked to optimise, talk about indexes, reading the EXPLAIN plan, and avoiding SELECT *.

Topic list based on the free Everything You Need to Get Placed checklist by Mohammad Fraz. Explanations and examples are original.