SQL interview cheat sheet
Execution order, joins, window functions, NULL, indexes and the classic interview queries, each with the rows SQLite returned.
Updated 10 interview queries55 results, each run on SQLitePrints cleanly — use your browser's print
Every result on this page is what SQLite returned when the query above it was run on the small sample tables below. The syntax is standard SQL unless a row says otherwise; where PostgreSQL or MySQL behave differently, the row names them.
Sample tables
A small company. The joins use their own two tables, shown there.
| id | name | dept | salary | manager_id |
|---|---|---|---|---|
| 1 | Ana | eng | 120 | NULL |
| 2 | Ben | eng | 95 | 1 |
| 3 | Cleo | eng | 130 | 1 |
| 4 | Dev | ops | 95 | 1 |
| 5 | Eve | ops | 80 | 4 |
| 6 | Finn | ops | 100 | 4 |
| 7 | Gus | eng | 95 | 3 |
| id | name |
|---|---|
| 1 | Ivy |
| 2 | Jon |
| 3 | Kai |
| id | customer_id | amount |
|---|---|---|
| 1 | 1 | 30 |
| 2 | 1 | 40 |
| 3 | 3 | 25 |
| 4 | NULL | 10 |
| id | |
|---|---|
| 1 | a@x.io |
| 2 | b@x.io |
| 3 | a@x.io |
| 4 | c@x.io |
| 5 | b@x.io |
| 6 | a@x.io |
| day | amount |
|---|---|
| 2026-09-01 | 40 |
| 2026-09-02 | 55 |
| 2026-09-03 | 35 |
| 2026-09-04 | 55 |
| person | day |
|---|---|
| kim | 2026-09-01 |
| kim | 2026-09-02 |
| kim | 2026-09-03 |
| kim | 2026-09-05 |
| lou | 2026-09-01 |
| lou | 2026-09-03 |
| lou | 2026-09-04 |
| n |
|---|
| 1 |
| 2 |
| 3 |
| 5 |
| 6 |
| 9 |
Execution order
Written SELECT first, evaluated fifth. The logical order decides what each clause can see; the planner may run it differently as long as the result is the same.
| Clause | What it does | Can see |
|---|---|---|
1FROM / JOIN | Builds the rows: every table, every join. | Table columns only |
2WHERE | Keeps the rows whose test is TRUE. | Columns — no aggregates, no SELECT aliases |
3GROUP BY | Folds the rows into one row per group. | Columns |
4HAVING | Keeps the groups whose test is TRUE. | Aggregates (SUM, COUNT…) |
5SELECT | Computes the output columns and names the aliases; window functions run here. | Everything above |
6DISTINCT | Drops duplicate output rows. | The output rows |
7ORDER BY | Sorts the finished rows. | Aliases and window results |
8LIMIT / OFFSET | Cuts the sorted rows to a page. | The sorted rows |
Why WHERE cannot see an alias
WHERE runs at step 2 and the alias is named at step 5, so it does not exist yet. Repeat the expression, or compute it in a subquery or CTE and filter outside. ORDER BY runs after SELECT, which is why it can use the alias.
SQLite is lenient and resolves it anyway — this runs there, and PostgreSQL rejects it:
SELECT name, salary * 2 AS doubled
FROM emp
WHERE doubled > 250| name | doubled |
|---|---|
| Cleo | 260 |
LessonsQuery Execution OrderSELECT BasicsORDER BY and LIMITArticleSQL Order of Execution: Why WHERE Can't See Your AliasArticleSQL SELECT Basics: Columns, Aliases, DISTINCT and SELECT *ArticleSQL ORDER BY and LIMIT: Top-N, OFFSET and Keyset Pagination
Joins
A join pairs every left row with every right row that meets the ON condition. The join type only decides what happens to a row with no partner. Row counts below are for these two tables:
| id | name |
|---|---|
| 1 | Pepper |
| 2 | Rusty |
| 3 | Nell |
| dog_id | adopter |
|---|---|
| 1 | Yusuf |
| 1 | Omar |
| 3 | Marta |
| 4 | Ines |
INNER JOIN
3 rows
Only the pairs that match. Pepper matches twice, so he appears twice; Rusty and Ines vanish.
SELECT d.name, a.adopter FROM dog d JOIN adoption a ON a.dog_id = d.id3 rowsResult name adopter Pepper Omar Pepper Yusuf Nell Marta LEFT JOIN
4 rows
Every dog. A dog with no match comes back once, with NULL on the right.
SELECT d.name, a.adopter FROM dog d LEFT JOIN adoption a ON a.dog_id = d.id4 rowsResult name adopter Pepper Omar Pepper Yusuf Rusty NULL Nell Marta RIGHT JOIN
4 rows
Every adoption — a LEFT JOIN read from the other side. Ines's dog 4 is not in the dog table.
SELECT d.name, a.adopter FROM dog d RIGHT JOIN adoption a ON a.dog_id = d.id4 rowsResult name adopter Pepper Yusuf Pepper Omar Nell Marta NULL Ines SQLite has the keyword from 3.39; these rows came from the equivalent LEFT JOIN form on 3.37, and match the keyword on newer versions.
FULL OUTER JOIN
5 rows
Every row from both sides, matched where possible. MySQL has no FULL JOIN: write LEFT JOIN UNION ALL the unmatched right rows.
SELECT d.name, a.adopter FROM dog d FULL JOIN adoption a ON a.dog_id = d.id5 rowsResult name adopter Pepper Omar Pepper Yusuf Rusty NULL Nell Marta NULL Ines SQLite has the keyword from 3.39; these rows came from the equivalent LEFT JOIN form on 3.37, and match the keyword on newer versions.
CROSS JOIN
12 rows
Every dog paired with every adoption, no condition: 3 × 4 rows.
SELECT COUNT(*) AS n FROM dog CROSS JOIN adoptionSemi-join (EXISTS)
2 rows
Dogs that have at least one match — each once, however many matches.
SELECT d.name FROM dog d WHERE EXISTS ( SELECT 1 FROM adoption a WHERE a.dog_id = d.id)2 rowsResult name Pepper Nell Anti-join
1 row
Dogs with no match: LEFT JOIN, then keep the rows whose right key came back NULL.
SELECT d.name FROM dog d LEFT JOIN adoption a ON a.dog_id = d.id WHERE a.dog_id IS NULL1 rowResult name Rusty LEFT JOIN + WHERE on the right table
2 rows
Rusty's NULL adopter fails the test, so the LEFT JOIN quietly became an INNER one. Move the condition into ON (AND a.adopter <> 'Omar') and Rusty is back: 3 rows.
SELECT d.name, a.adopter FROM dog d LEFT JOIN adoption a ON a.dog_id = d.id WHERE a.adopter <> 'Omar'2 rowsResult name adopter Pepper Yusuf Nell Marta
LessonINNER and OUTER JOINsArticleSQL Joins Explained Visually: INNER, LEFT and FULL OUTER
Aggregation & HAVING
GROUP BY makes one row per distinct value; aggregates are computed per group. WHERE filters rows before grouping, HAVING filters groups after it.
- Row conditions in WHERE, group conditions in
HAVING. A row condition inHAVINGworks but groups rows it could have dropped. - COUNT(*) counts rows;
COUNT(col)skips NULLs;COUNT(DISTINCT col)counts values. - SUM of no rows is NULL, not 0 —
COALESCE(SUM(x), 0)when a report needs a number. - Every selected column is grouped or aggregated. SQLite accepts a bare column and picks a row; PostgreSQL rejects the query.
SELECT dept, COUNT(*) AS n,
AVG(salary) AS avg_salary
FROM emp
WHERE salary >= 90
GROUP BY dept
HAVING COUNT(*) >= 3| dept | n | avg_salary |
|---|---|---|
| eng | 4 | 110 |
SELECT COUNT(*) AS all_rows,
COUNT(manager_id) AS has_manager,
COUNT(DISTINCT salary) AS salaries
FROM emp| all_rows | has_manager | salaries |
|---|---|---|
| 7 | 6 | 5 |
SELECT SUM(amount) AS total,
COALESCE(SUM(amount), 0) AS safe
FROM orders
WHERE customer_id = 2| total | safe |
|---|---|
| NULL | 0 |
LessonAggregations & GROUP BYArticleSQL GROUP BY and HAVING Explained: WHERE vs HAVING
Window functions
f() OVER (PARTITION BY … ORDER BY …) computes across related rows and keeps every row. PARTITION BY restarts it per group; ORDER BY defines “so far”. Windows run with SELECT, after WHERE — to filter on one, wrap the query.
| Function | Returns | On the examples below |
|---|---|---|
ROW_NUMBER() | 1, 2, 3… — never repeats. Ties are ordered arbitrarily unless the ORDER BY breaks them. | Ben, Dev, Gus (all 95) → 4, 5, 6 |
RANK() | Ties share a number, then it skips. | 95, 95, 95 → 4, 4, 4; Eve next → 7 |
DENSE_RANK() | Ties share a number, no gaps — the one for "N-th highest value". | 95, 95, 95 → 4, 4, 4; Eve next → 5 |
LAG(x [, k]) | x from k rows back (default 1); NULL before the first row. | prev on 09-02 → 40; on 09-01 → NULL |
LEAD(x [, k]) | x from k rows ahead; NULL past the last row. | next on 09-01 → 55; on 09-04 → NULL |
SUM(x) OVER (…) | With ORDER BY: a running total. With only PARTITION BY: the group's total on every row. | so_far → 40, 95, 130, 185; eng dept_total → 440 on each eng row |
A worked ranking: three 95s
ROW_NUMBER breaks the tie by name, RANK skips to 7 after it, DENSE_RANK does not skip.
SELECT name, salary,
ROW_NUMBER() OVER (
ORDER BY salary DESC, name) AS rn,
RANK() OVER w AS rnk,
DENSE_RANK() OVER w AS drnk
FROM emp
WINDOW w AS (ORDER BY salary DESC)
ORDER BY rn| name | salary | rn | rnk | drnk |
|---|---|---|---|---|
| Cleo | 130 | 1 | 1 | 1 |
| Ana | 120 | 2 | 2 | 2 |
| Finn | 100 | 3 | 3 | 3 |
| Ben | 95 | 4 | 4 | 4 |
| Dev | 95 | 5 | 4 | 4 |
| Gus | 95 | 6 | 4 | 4 |
| Eve | 80 | 7 | 7 | 5 |
Neighbours and a running total
WINDOW w AS (…) names a window several functions share.
SELECT day, amount,
LAG(amount) OVER w AS prev,
LEAD(amount) OVER w AS next,
SUM(amount) OVER w AS so_far
FROM sale
WINDOW w AS (ORDER BY day)
ORDER BY day| day | amount | prev | next | so_far |
|---|---|---|---|---|
| 2026-09-01 | 40 | NULL | 55 | 40 |
| 2026-09-02 | 55 | 40 | 35 | 95 |
| 2026-09-03 | 35 | 55 | 55 | 130 |
| 2026-09-04 | 55 | 35 | NULL | 185 |
The group total on every row
PARTITION BY without ORDER BY: GROUP BY's answer, rows kept.
SELECT name, dept, salary,
SUM(salary) OVER (
PARTITION BY dept) AS dept_total
FROM emp
ORDER BY dept, name| name | dept | salary | dept_total |
|---|---|---|---|
| Ana | eng | 120 | 440 |
| Ben | eng | 95 | 440 |
| Cleo | eng | 130 | 440 |
| Gus | eng | 95 | 440 |
| Dev | ops | 95 | 275 |
| Eve | ops | 80 | 275 |
| Finn | ops | 100 | 275 |
The default frame and ties
With ORDER BY and no frame, the frame is RANGE … CURRENT ROW: rows tied on the key are summed together, so both 55s show 185. Write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and they read 130, then 185.
SELECT amount,
SUM(amount) OVER (
ORDER BY amount) AS t
FROM sale
ORDER BY amount| amount | t |
|---|---|
| 35 | 35 |
| 40 | 75 |
| 55 | 185 |
| 55 | 185 |
LessonWindow FunctionsArticleSQL Window Functions Explained: OVER, PARTITION BY, RANK
Subqueries vs CTEs
A CTE is a subquery with a name: the same power, read top to bottom, usable twice in one statement. Only a recursive CTE can do something a subquery cannot. Whether a CTE is computed once or folded into the query is the engine's choice (PostgreSQL 12+ inlines a CTE used once unless you write MATERIALIZED).
| Form | Looks like | Runs | Reach for it when |
|---|---|---|---|
| Scalar subquery | WHERE salary > (SELECT AVG(salary) FROM emp) | Once. Must return one row, one column. | Comparing against one computed value. |
| IN / NOT IN | WHERE id IN (SELECT customer_id FROM orders) | Once, as a list. | Membership. Never NOT IN over a column that can hold NULL. |
| EXISTS / NOT EXISTS | WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) | Per outer row, stops at the first match. | Semi- and anti-joins; NULL-safe. |
| Correlated subquery | WHERE salary > (SELECT AVG(salary) FROM emp WHERE dept = e.dept) | Per outer row (unless the planner rewrites it). | Short to write; watch the cost on big tables. |
| Derived table | FROM (SELECT dept, AVG(salary) … GROUP BY dept) AS d | Once, as a table in FROM. | Filtering on an aggregate or a window result. |
| CTE (WITH) | WITH dept_avg AS (SELECT …) SELECT … FROM dept_avg | Like a derived table with a name. | Several steps, read top to bottom; one result used twice. |
| Recursive CTE | WITH RECURSIVE chain AS (anchor UNION ALL step) | Anchor once, then the step until it returns nothing. | Trees and graphs: org charts, bill of materials, paths. |
Same question, three shapes: who earns more than their department's average?
Correlated subquery
SELECT e.name
FROM emp e
WHERE e.salary > (
SELECT AVG(salary) FROM emp
WHERE dept = e.dept)
ORDER BY e.name| name |
|---|
| Ana |
| Cleo |
| Dev |
| Finn |
Derived table
SELECT e.name
FROM emp e
JOIN (SELECT dept, AVG(salary) AS avg_s
FROM emp GROUP BY dept) AS d
ON d.dept = e.dept
WHERE e.salary > d.avg_s
ORDER BY e.name| name |
|---|
| Ana |
| Cleo |
| Dev |
| Finn |
CTE
WITH dept_avg AS (
SELECT dept, AVG(salary) AS avg_s
FROM emp GROUP BY dept
)
SELECT e.name
FROM emp e
JOIN dept_avg d ON d.dept = e.dept
WHERE e.salary > d.avg_s
ORDER BY e.name| name |
|---|
| Ana |
| Cleo |
| Dev |
| Finn |
LessonsSubqueriesCTEs and RecursionArticleSQL Subqueries Explained: Scalar, IN, EXISTS and CorrelatedArticleSQL Recursive CTE Explained: Anchor, Recursive Step and Cycles
NULL
NULL means unknown, so SQL has three truth values: TRUE, FALSE and UNKNOWN. WHERE, ON and HAVING keep a row only when the test is TRUE — UNKNOWN is dropped like FALSE.
| Expression | Result | Why |
|---|---|---|
NULL = NULL | NULL | Two unknowns are not known to be equal. |
NULL <> 1 | NULL | Any comparison with NULL is unknown. |
NULL AND FALSE | FALSE | FALSE whatever the unknown is. |
NULL OR TRUE | TRUE | TRUE whatever the unknown is. |
NOT NULL | NULL | Not-unknown is still unknown. |
NULL IS NULL | TRUE | IS is the test that can see NULL. |
1 IN (1, NULL) | TRUE | A match was found. |
2 IN (1, NULL) | NULL | 2 = NULL is unknown, so no clean FALSE. |
2 NOT IN (1, NULL) | NULL | …and so NOT IN is never TRUE: the trap below. |
The NOT IN trap
Order 4 has a NULL customer_id. Every NOT IN test then meets a NULL and comes out UNKNOWN, so Jon, who never ordered, is not returned. NOT EXISTS is NULL-safe.
SELECT name FROM customer
WHERE id NOT IN (
SELECT customer_id FROM orders)| name |
|---|
| (no rows) |
SELECT c.name FROM customer c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id)| name |
|---|
| Jon |
COALESCE
COALESCE(a, b, …) returns the first argument that is not NULL. Jon has no orders, so his LEFT JOIN row sums to NULL — and reads 0.
SELECT c.name,
COALESCE(SUM(o.amount), 0) AS spent
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id| name | spent |
|---|---|
| Ivy | 70 |
| Jon | 0 |
| Kai | 25 |
- NULL keys never join — not even to another NULL.
- GROUP BY puts all NULLs in one group, and
DISTINCTkeeps one of them. - Sorting: SQLite and MySQL put NULL first in ascending order (Ana, with no manager, sorts first here); PostgreSQL puts it last. Say
NULLS FIRSTorNULLS LASTwhen it matters.
LessonWHERE and FilteringArticleSQL NULL in WHERE: Why = NULL Matches Nothing
Indexes
An index is a second, sorted copy of some columns — usually a B-tree — with a pointer back to each row. The plans below are SQLite's EXPLAIN QUERY PLAN on a photo table with an index on (shot_by, year): SCAN reads every row, SEARCH jumps in.
| Case | Query | Plan (SQLite) | Why |
|---|---|---|---|
| No index: a full scan | SELECT box FROM photo
WHERE shot_by = 'Weiss' | SCAN photo | Every row is read and tested: O(n) whatever the answer size. |
| What an index saves | SELECT box FROM photo
WHERE shot_by = 'Weiss' | SEARCH photo USING INDEX photo_by_year (shot_by=?) | A B-tree search to the first match, then the matches: O(log n + k). |
| Composite: leftmost prefix | SELECT box FROM photo
WHERE shot_by = 'Weiss' AND year = 1998 | SEARCH photo USING INDEX photo_by_year (shot_by=? AND year=?) | Sorted by shot_by, then year within it: equality on the first column, then the second. |
| Ignored: second column alone | SELECT box FROM photo
WHERE year = 1998 | SCAN photo | year is only sorted inside each shot_by, so there is no place to jump to. |
| Covering index | SELECT year FROM photo
WHERE shot_by = 'Weiss' | SEARCH photo USING COVERING INDEX photo_by_year (shot_by=?) | Every column the query needs is in the index, so the table is never read. |
| Ignored: a function on the column | SELECT box FROM photo
WHERE lower(shot_by) = 'weiss' | SCAN photo | The index is sorted by shot_by, not lower(shot_by). Rewrite, or index the expression. |
| Ignored: leading wildcard | SELECT box FROM photo
WHERE shot_by LIKE '%eiss' | SCAN photo | A pattern that can start with anything has no sorted starting point. |
| ORDER BY served by the index | SELECT shot_by, year FROM photo
ORDER BY shot_by, year | SCAN photo USING COVERING INDEX photo_by_year | Rows come out already sorted. ORDER BY box instead adds USE TEMP B-TREE FOR ORDER BY. |
- Cost: a scan is O(n); an index search is O(log n) plus the k matches, plus one row lookup each unless the index covers the query.
- Every write pays: each insert, delete and update of an indexed column also updates every index that holds it — O(log n) more per index, and more storage.
- The planner may ignore a good index when the query matches a large share of the table: one sequential scan can beat many row lookups.
- Composite order matters: put the column you always filter by with
=first; the index serves any leftmost prefix of its columns.
LessonsIndexesComposite IndexesReading a Query PlanArticleSQL Indexes Explained: Full Scan vs Index Search, With Query PlansArticleComposite and Covering Indexes: Why Column Order MattersArticleHow to Read a Query Plan: EXPLAIN in SQL, Line by Line
The N+1 problem
One query for a list, then one more per row. Each is fast; the round trips are the cost, and their count grows with the data — so it passes on ten rows and crawls on five hundred. ORMs do it by default when a relation loads lazily.
| Approach | Statements | Counted for 3 jobs |
|---|---|---|
| One query for the list, one per row | 1 + N | 4 |
| One JOIN | 1 | 1 |
| The list, then one WHERE job_id IN (…) | 2 | 2 |
Counted with SQLite's trace callback, which records every statement it runs. Bind the ids of the IN (…) list as parameters, never paste them in; index the foreign key, or each of the N queries is a scan as well as a trip.
LessonThe N+1 Query ProblemArticleThe N+1 Query Problem: How to Spot It and Fix It
Transactions & isolation
A transaction is one unit of work. The isolation level is how much of other, unfinished transactions it may see — each rung up the ladder rules out one more anomaly and costs some concurrency.
- AAtomic
- All the statements land, or none do; a failure rolls back the earlier ones too.
- CConsistent
- Constraints (CHECK, foreign keys, UNIQUE) hold before and after.
- IIsolated
- Others do not see your half-finished work — how strictly is the isolation level.
- DDurable
- Once committed, it survives a crash.
| Level | Dirty read | Non-repeatable read | Phantom | In one line |
|---|---|---|---|---|
| Read uncommitted | Possible | Possible | Possible | You may read another transaction's uncommitted writes. |
| Read committed | Prevented | Possible | Possible | Each statement sees what was committed before it began. |
| Repeatable read | Prevented | Prevented | Possible | A row you read reads the same until you finish. |
| Serializable | Prevented | Prevented | Prevented | The result equals some one-at-a-time order. Retry on serialization failures. |
Dirty: you read uncommitted data. Non-repeatable: a row you read twice changed. Phantom: the set of rows matching a condition changed. Engines may give more than the minimum (PostgreSQL's repeatable read also stops phantoms), and defaults differ: read committed in PostgreSQL, repeatable read in MySQL's InnoDB, and SQLite runs every transaction serializable.
LessonsTransactions & ACIDIsolation LevelsArticleACID Transactions Explained: Commit, Rollback and ConstraintsArticleSQL Isolation Levels Explained: Dirty, Non-Repeatable, Phantom
Interview queries
The questions that come up again and again, each on the sample tables with the rows it returned.
Second-highest salary
The second-highest distinct salary, or NULL if there is none.
SELECT MAX(salary) AS second FROM emp WHERE salary < (SELECT MAX(salary) FROM emp)1 rowResult second 120 An aggregate over zero rows is one NULL row, so a one-salary table answers NULL, not nothing.
- Lesson
- Subqueries · SQL
- Practice
N-th highest salary
The third-highest distinct salary.
SELECT DISTINCT salary FROM ( SELECT salary, DENSE_RANK() OVER ( ORDER BY salary DESC) AS r FROM emp) AS ranked WHERE r = 31 rowResult salary 100 DENSE_RANK, not RANK: the three 95s would push RANK past 5 and leave gaps.
- Lesson
- Window Functions · SQL
- Practice
Find duplicates
Emails that signed up more than once, and how often.
SELECT email, COUNT(*) AS n FROM signup GROUP BY email HAVING COUNT(*) > 1 ORDER BY email2 rowsResult email n a@x.io 3 b@x.io 2 To keep the first of each: DELETE FROM signup WHERE id NOT IN (SELECT MIN(id) FROM signup GROUP BY email) leaves ids 1, 2, 4.
- Lesson
- Aggregations & GROUP BY · SQL
- Practice
Rows with no match
Customers who never ordered.
SELECT c.name FROM customer c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id)1 rowResult name Jon NOT IN (SELECT customer_id FROM orders) returns no rows here: order 4 has a NULL customer_id.
- Lesson
- INNER and OUTER JOINs · SQL
- Practice
Earns more than their manager
Employees paid more than the person they report to.
SELECT e.name, e.salary, m.salary AS manager_salary FROM emp e JOIN emp m ON m.id = e.manager_id WHERE e.salary > m.salary ORDER BY e.name2 rowsResult name salary manager_salary Cleo 130 120 Finn 100 95 A self-join: the same table twice under two aliases. The inner join drops Ana, who has no manager.
- Lesson
- INNER and OUTER JOINs · SQL
- Practice
Top N per group
The two best-paid people in each department.
SELECT dept, name, salary FROM ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY dept ORDER BY salary DESC) AS rn FROM emp) AS ranked WHERE rn <= 2 ORDER BY dept, rn4 rowsResult dept name salary eng Cleo 130 eng Ana 120 ops Finn 100 ops Dev 95 A window cannot sit in WHERE, so rank in a subquery and filter outside. ROW_NUMBER gives exactly N; DENSE_RANK keeps ties (top 3 in eng is then 4 people).
- Lesson
- Window Functions · SQL
- Practice
Running total
Sales so far, day by day.
SELECT day, amount, SUM(amount) OVER ( ORDER BY day) AS total FROM sale ORDER BY day4 rowsResult day amount total 2026-09-01 40 40 2026-09-02 55 95 2026-09-03 35 130 2026-09-04 55 185 The default frame is RANGE: rows tied on the ORDER BY key share one total. Write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW for a strict row-by-row sum.
- Lesson
- Window Functions · SQL
- Practice
Three days in a row
People who logged in on at least three consecutive days.
SELECT DISTINCT person FROM ( SELECT person, day, LAG(day, 2) OVER ( PARTITION BY person ORDER BY day) AS two_back FROM login) AS t WHERE julianday(day) - julianday(two_back) = 21 rowResult person kim Two rows back is exactly two days back only if every row is a different day: deduplicate the logins first.
Dialectjulianday() is SQLite. On PostgreSQL DATE columns, day - two_back = 2; on MySQL, DATEDIFF(day, two_back) = 2.
- Lesson
- Window Functions · SQL
- Practice
Gaps and islands
Collapse the taken seat numbers into runs.
SELECT MIN(n) AS run_start, MAX(n) AS run_end FROM ( SELECT n, n - ROW_NUMBER() OVER ( ORDER BY n) AS grp FROM seat) AS t GROUP BY grp ORDER BY run_start3 rowsResult run_start run_end 1 3 5 6 9 9 Inside a run, value and row number climb together, so their difference is constant: that difference is the group. The gaps are between one run_end and the next run_start.
- Lesson
- Window Functions · SQL
- Practice
Everyone under a manager
The whole org chart from the top, with each person's depth.
WITH RECURSIVE chain AS ( SELECT id, name, 0 AS depth FROM emp WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, c.depth + 1 FROM emp e JOIN chain c ON e.manager_id = c.id ) SELECT name, depth FROM chain ORDER BY depth, name7 rowsResult name depth Ana 0 Ben 1 Cleo 1 Dev 1 Eve 2 Finn 2 Gus 2 Anchor, then a step that joins back onto the CTE until it finds nothing. Data with a cycle never runs out: cap the depth or track visited ids.
- Lesson
- CTEs and Recursion · SQL
- Practice
Common mistakes
- A SELECT alias or an aggregate in WHEREHAVING for aggregates; repeat the expression or filter in an outer query for aliases
- col = NULLcol IS NULL — a comparison with NULL is never TRUE
- NOT IN over a column that can hold NULLNOT EXISTS, or filter the NULLs out of the subquery
- A WHERE on the right table after a LEFT JOINPut the condition in ON, or the unmatched rows disappear
- SUM or COUNT over a join that multiplies rowsAggregate the many-side first, then join
- COUNT(col) when you meant rowsCOUNT(*) counts rows; COUNT(col) skips NULLs
- LIMIT without ORDER BYNo ORDER BY, no order: the rows kept are whichever came first
- RANK for “exactly N per group”ROW_NUMBER for exactly N; RANK and DENSE_RANK keep ties
- A function on an indexed columnCompare the bare column, or index the expression
- A query inside a loopOne JOIN, or one WHERE id IN (…) for the whole list
- Read, compute in the app, write backDo the arithmetic in one UPDATE, or two clerks lose an update
bytepatterns.com/cheatsheets/sql — every row links to an animated lesson there.