Skip to content
BytePatterns

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.

emp
idnamedeptsalarymanager_id
1Anaeng120NULL
2Beneng951
3Cleoeng1301
4Devops951
5Eveops804
6Finnops1004
7Guseng953
customer
idname
1Ivy
2Jon
3Kai
orders
idcustomer_idamount
1130
2140
3325
4NULL10
signup
idemail
1a@x.io
2b@x.io
3a@x.io
4c@x.io
5b@x.io
6a@x.io
sale
dayamount
2026-09-0140
2026-09-0255
2026-09-0335
2026-09-0455
login
personday
kim2026-09-01
kim2026-09-02
kim2026-09-03
kim2026-09-05
lou2026-09-01
lou2026-09-03
lou2026-09-04
seat
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.

The logical order a SELECT is evaluated in
ClauseWhat it doesCan see
1FROM / JOINBuilds the rows: every table, every join.Table columns only
2WHEREKeeps the rows whose test is TRUE.Columns — no aggregates, no SELECT aliases
3GROUP BYFolds the rows into one row per group.Columns
4HAVINGKeeps the groups whose test is TRUE.Aggregates (SUM, COUNT…)
5SELECTComputes the output columns and names the aliases; window functions run here.Everything above
6DISTINCTDrops duplicate output rows.The output rows
7ORDER BYSorts the finished rows.Aliases and window results
8LIMIT / OFFSETCuts 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
1 row
Result
namedoubled
Cleo260

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:

dog
idname
1Pepper
2Rusty
3Nell
adoption
dog_idadopter
1Yusuf
1Omar
3Marta
4Ines
  • 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.id
    3 rows
    Result
    nameadopter
    PepperOmar
    PepperYusuf
    NellMarta
  • 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.id
    4 rows
    Result
    nameadopter
    PepperOmar
    PepperYusuf
    RustyNULL
    NellMarta
  • 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.id
    4 rows
    Result
    nameadopter
    PepperYusuf
    PepperOmar
    NellMarta
    NULLInes

    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.id
    5 rows
    Result
    nameadopter
    PepperOmar
    PepperYusuf
    RustyNULL
    NellMarta
    NULLInes

    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 adoption
  • Semi-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 rows
    Result
    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 NULL
    1 row
    Result
    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 rows
    Result
    nameadopter
    PepperYusuf
    NellMarta

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 in HAVING works 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
1 row
Result
deptnavg_salary
eng4110
SELECT COUNT(*) AS all_rows,
  COUNT(manager_id) AS has_manager,
  COUNT(DISTINCT salary) AS salaries
FROM emp
1 row
Result
all_rowshas_managersalaries
765
SELECT SUM(amount) AS total,
  COALESCE(SUM(amount), 0) AS safe
FROM orders
WHERE customer_id = 2
1 row
Result
totalsafe
NULL0

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.

Window functions: what each returns
FunctionReturnsOn 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
7 rows
Result
namesalaryrnrnkdrnk
Cleo130111
Ana120222
Finn100333
Ben95444
Dev95544
Gus95644
Eve80775

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
4 rows
Result
dayamountprevnextso_far
2026-09-0140NULL5540
2026-09-0255403595
2026-09-03355555130
2026-09-045535NULL185

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
7 rows
Result
namedeptsalarydept_total
Anaeng120440
Beneng95440
Cleoeng130440
Guseng95440
Devops95275
Eveops80275
Finnops100275

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
4 rows
Result
amountt
3535
4075
55185
55185

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).

Subquery forms and common table expressions
FormLooks likeRunsReach for it when
Scalar subqueryWHERE salary > (SELECT AVG(salary) FROM emp)Once. Must return one row, one column.Comparing against one computed value.
IN / NOT INWHERE id IN (SELECT customer_id FROM orders)Once, as a list.Membership. Never NOT IN over a column that can hold NULL.
EXISTS / NOT EXISTSWHERE 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 subqueryWHERE 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 tableFROM (SELECT dept, AVG(salary) … GROUP BY dept) AS dOnce, as a table in FROM.Filtering on an aggregate or a window result.
CTE (WITH)WITH dept_avg AS (SELECT …) SELECT … FROM dept_avgLike a derived table with a name.Several steps, read top to bottom; one result used twice.
Recursive CTEWITH 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
4 rows
Result
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
4 rows
Result
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
4 rows
Result
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.

Three-valued logic (SQLite prints TRUE as 1, FALSE as 0)
ExpressionResultWhy
NULL = NULLNULLTwo unknowns are not known to be equal.
NULL <> 1NULLAny comparison with NULL is unknown.
NULL AND FALSEFALSEFALSE whatever the unknown is.
NULL OR TRUETRUETRUE whatever the unknown is.
NOT NULLNULLNot-unknown is still unknown.
NULL IS NULLTRUEIS is the test that can see NULL.
1 IN (1, NULL)TRUEA match was found.
2 IN (1, NULL)NULL2 = 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)
0 rows
Result
name
(no rows)
SELECT c.name FROM customer c
WHERE NOT EXISTS (
  SELECT 1 FROM orders o
  WHERE o.customer_id = c.id)
1 row
Result
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
3 rows
Result
namespent
Ivy70
Jon0
Kai25
  • NULL keys never join — not even to another NULL.
  • GROUP BY puts all NULLs in one group, and DISTINCT keeps 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 FIRST or NULLS LAST when 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.

What an index does and does not help
CaseQueryPlan (SQLite)Why
No index: a full scanSELECT box FROM photo WHERE shot_by = 'Weiss'SCAN photoEvery row is read and tested: O(n) whatever the answer size.
What an index savesSELECT 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 prefixSELECT box FROM photo WHERE shot_by = 'Weiss' AND year = 1998SEARCH 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 aloneSELECT box FROM photo WHERE year = 1998SCAN photoyear is only sorted inside each shot_by, so there is no place to jump to.
Covering indexSELECT 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 columnSELECT box FROM photo WHERE lower(shot_by) = 'weiss'SCAN photoThe index is sorted by shot_by, not lower(shot_by). Rewrite, or index the expression.
Ignored: leading wildcardSELECT box FROM photo WHERE shot_by LIKE '%eiss'SCAN photoA pattern that can start with anything has no sorted starting point.
ORDER BY served by the indexSELECT shot_by, year FROM photo ORDER BY shot_by, yearSCAN photo USING COVERING INDEX photo_by_yearRows 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.

Statements sent for N jobs and their parts
ApproachStatementsCounted for 3 jobs
One query for the list, one per row1 + N4
One JOIN11
The list, then one WHERE job_id IN (…)22

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.
What each level allows (the SQL standard's minimums)
LevelDirty readNon-repeatable readPhantomIn one line
Read uncommittedPossiblePossiblePossibleYou may read another transaction's uncommitted writes.
Read committedPreventedPossiblePossibleEach statement sees what was committed before it began.
Repeatable readPreventedPreventedPossibleA row you read reads the same until you finish.
SerializablePreventedPreventedPreventedThe 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 row
    Result
    second
    120

    An aggregate over zero rows is one NULL row, so a one-salary table answers NULL, not nothing.

    Lesson
    Subqueries · SQL
  • 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 = 3
    1 row
    Result
    salary
    100

    DENSE_RANK, not RANK: the three 95s would push RANK past 5 and leave gaps.

    Lesson
    Window Functions · SQL
  • 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 email
    2 rows
    Result
    emailn
    a@x.io3
    b@x.io2

    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.

  • 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 row
    Result
    name
    Jon

    NOT IN (SELECT customer_id FROM orders) returns no rows here: order 4 has a NULL customer_id.

  • 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.name
    2 rows
    Result
    namesalarymanager_salary
    Cleo130120
    Finn10095

    A self-join: the same table twice under two aliases. The inner join drops Ana, who has no manager.

  • 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, rn
    4 rows
    Result
    deptnamesalary
    engCleo130
    engAna120
    opsFinn100
    opsDev95

    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
  • Running total

    Sales so far, day by day.

    SELECT day, amount,
      SUM(amount) OVER (
        ORDER BY day) AS total
    FROM sale
    ORDER BY day
    4 rows
    Result
    dayamounttotal
    2026-09-014040
    2026-09-025595
    2026-09-0335130
    2026-09-0455185

    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
  • 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) = 2
    1 row
    Result
    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
  • 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_start
    3 rows
    Result
    run_startrun_end
    13
    56
    99

    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
  • 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, name
    7 rows
    Result
    namedepth
    Ana0
    Ben1
    Cleo1
    Dev1
    Eve2
    Finn2
    Gus2

    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

Common mistakes

bytepatterns.com/cheatsheets/sql — every row links to an animated lesson there.

New lessons land every few weeks

Leave an address and we will tell you when the next one is up. That is the only reason we will use it.

One address, stored so we can email you. Nothing else, ever.