SQL Cheat Sheet
The 20 SQL patterns that keep showing up in interviews, from WHERE vs HAVING to window functions, each with a query you can run.
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.
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.
= NULLis never true; useIS NULL/IS NOT NULL.BETWEEN a AND bis inclusive on both ends.LIKE 'A%'matches a prefix; a leading%usually can't use an index.
SELECT name, salary FROM employees WHERE dept_id = 1 AND (salary >= 120000 OR hired_on < '2019-01-01');
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 usesFETCH FIRST n ROWS ONLY. - Big OFFSETs are slow; keyset paging (
WHERE id > last_id) scales better.
SELECT name, salary FROM employees ORDER BY salary DESC, name ASC LIMIT 3 OFFSET 0;
Aggregate
Collapse many rows into summary numbers.
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).
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;
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.
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.
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.
JOINalone means INNER JOIN.- NULL never equals NULL, so NULL keys never match.
- A one-to-many join multiplies rows; watch out before SUM-ing.
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;
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.
-- 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;
- Count
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).
-- 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.
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.
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';
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.
-- 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'));
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.
-- 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);
11CTEs (WITH)
A Common Table Expression names a subquery up front so the main query reads top-down. You can chain several, and
WITH RECURSIVEwalks 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.
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;
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.
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.
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.
-- 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;
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.
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;
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.
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;
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.
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.
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.
-- 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;
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.
-- 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.
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 useEXPLAIN(Postgres alsoEXPLAIN ANALYZE).- Composite index follows the leftmost-prefix rule: (dept_id, salary) helps
dept_id = ?but notsalary > ?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.
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>?)
- Composite index follows the leftmost-prefix rule: (dept_id, salary) helps
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.
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.