AdvancedSQL · 25 min

Self-Join: Advanced

At the staff level, self-join is not a SQL question. It is a stand-in for graph traversal expressed in SQL: walking org charts, manufacturing bills of materials, comment thread trees, category hierarchies, transaction reversal chains. The basic shape (one table aliased twice) extends to recursive CTEs, multi-relationship graphs, and materialized closure tables. The candidate at this level is being scored on whether they pick the right tool for the depth (fixed-depth join vs unbounded recursion vs closure table), whether they handle cycles and termination correctly, and whether they reason about cost at billion-row scale. The query is small. The architecture conversation around it is the rest of the hour.
list
Pick between fixed-depth joins, recursive CTEs, and closure tables based on traversal characteristics
chart
Handle cycles, depth bounds, and termination correctness in recursive queries
branch
Reason about the cost model: per-iteration shuffle, partition skew, and the path-explosion case
code
Architect the materialization layer when the hierarchy powers a high-cadence read pattern

Hierarchical Self-Joins and Depth

Daily Life
Interviews

Recognize that questions comparing pairs within a table (manager/employee, previous/next, duplicate detection) require self-joins.

The interviewer hands you the canonical question: 'walk an org chart and return every employee under a target manager, with the depth of each.' By the third follow-up, the conversation has moved from SQL to systems: what happens when the org chart has 500,000 employees, when the chart has cycles from a temporary reorg, when the dashboard refreshes every five minutes, when the same hierarchy walk feeds three downstream reports. The SQL itself is settled in the first two minutes. The remaining time is whether you can design the system that owns the SQL.

What the staff interviewer is really asking:
  • Pick the right tool for the depth (fixed join vs recursive CTE vs closure table)
  • Handle cycles and termination correctness in production code
  • Architect the materialization layer for a high-cadence consumer
  • Reason about the workload (depth, read rate, write rate) before picking the tool

Three tools, three different cost profiles

Fixed-depth chained join: works for bounded depth (one or two levels). Each LEFT JOIN adds a level. Query stays flat; optimizer plans it as a normal multi-way join. Recursive CTE: works for unbounded depth. The engine iterates the recursive step until no new rows are added. Cost grows linearly with the number of iterations times the average fanout per iteration. Closure table: pre-computed table of (ancestor, descendant, depth) tuples. Read queries become point lookups; the maintenance cost is paid at write time. Each tool wins in a different scenario; the candidate at this level picks the right one before writing SQL.
When each tool wins
  • Fixed-depth join: depth is bounded by the question (1-3 levels). Reports column-per-level.
  • Recursive CTE: depth is unbounded; the question asks for the full subtree.
  • Closure table: many read queries over the same hierarchy; write cost is amortized.
  • Native graph DB: the workload is dominated by shortest-path or graph-pattern queries.
When each tool loses
  • Fixed-depth join: unbounded depth makes the join chain combinatorially intractable.
  • Recursive CTE: high fanout per iteration causes the working set to explode.
  • Closure table: hierarchy changes frequently; write amplification dominates cost.
  • Native graph DB: simple parent-child reads do not justify the operational complexity.

What the staff interviewer is silently scoring

Three layers. Layer one: do you reach for the recursive CTE when the depth is unbounded, and articulate why hand-chained joins do not generalize? Most candidates at this level clear layer one. Layer two: do you handle the operational concerns (cycles, depth limits, performance) without being prompted? Some clear it. Layer three: can you design the materialization architecture for a high-cadence consumer, and defend it against the alternatives? This is the layer that defines the verdict at staff. The conversation lives there.

Frame the architecture before writing SQL. 'The recursive CTE is the body of the refresh job; the dashboard queries a materialized hierarchy_closure table the job maintains. Hand-chained joins are wrong for unbounded depth, and running the recursive query on every dashboard read does not scale.' Three sentences. Each one names a different architectural choice.

Chained Joins vs Recursive CTE

Daily Life
Interviews

Write a self-join with clear aliases and a precise ON clause that avoids Cartesian explosions.

Recursive CTEs are the canonical tool for unbounded hierarchy traversal in SQL. The shape is the same across engines: an anchor query that produces the starting set, a recursive step that joins the previous iteration's result to the source table, and a UNION ALL that combines them. The engine iterates the recursive step until it returns zero new rows. Understanding the iteration mechanics is what separates a query that ships from one that infinite-loops in production.

The canonical recursive CTE

WITH RECURSIVE subtree AS(SELECT employee_id, name, manager_id, 1 AS depth, ARRAY employee_id AS path FROM employees WHERE manager_id = 42 UNION ALL SELECT e.employee_id, e.name, e.manager_id, s.depth + 1 AS depth, s.path || e.employee_id AS path FROM employees e JOIN subtree s ON e.manager_id = s.employee_id WHERE NOT e.employee_id = ANY(s.path) AND s.depth < 50)
SELECT
employee_id,
name,
depth,
path
FROM subtree
ORDER BY depth, name ;

Reading the anchor and the recursive step

The anchor produces the starting set: the direct reports of employee 42. Each row has depth=1 and a path array containing just its own employee_id. The recursive step joins the source table (employees) to the CTE itself (subtree), matching each next-level employee against the previous level's employee_id. Each iteration increments depth and appends the new employee_id to the path. The engine repeats the recursive step, accumulating rows, until the recursive step returns zero new rows (no more reports to find). The UNION ALL combines all iterations into the final result set.

Cycle detection: the bug that ships

Real production hierarchies have cycles. A temporary reorg makes employee A's manager report to employee A. A category tree gets a circular reference from a bulk import. A comment-thread graph has a thread that loops back on itself. Without cycle detection, the recursive CTE walks the cycle forever, accumulates duplicate rows on each pass, and hits the engine's recursion limit (Postgres: 65535 iterations; SQL Server: 100 by default; Snowflake: depends on the query but typically caps at 100). The fix is the visited-set: the path array tracks every node visited so far, and the recursive step's WHERE clause excludes nodes that already appear in the path.

Without cycle detection (the bug)
  • Engine walks the cycle until the recursion limit is hit
  • Query throws a recursion-depth error or silently truncates
  • Output is missing valid descendants outside the cycle
  • Reproduces only on the data that has the cycle
With the path array (the fix)
  • Recursive step skips any node already in the path
  • Cycle is broken on the second visit to any node
  • Output is complete and correct on cyclic data
  • One extra column and one extra WHERE clause

Depth bounds as a safety net

Even with cycle detection, a deep hierarchy can hit the recursion limit through legitimate depth. A category tree might be 20 levels deep; a comment thread might be 200. Adding an explicit depth bound (s.depth < 50) acts as a defense-in-depth measure. If the path-array check fails (because of a bug in the cycle detection or a data shape the author did not anticipate), the depth bound stops the runaway. Both checks together are belt-and-suspenders: the path array prevents infinite loops; the depth bound caps the total work. For production queries, write both.

The engine-specific behavior at the recursion limit varies. Postgres throws an error and aborts the query. SQL Server returns a partial result by default unless you set MAXRECURSION 0. Snowflake's default limit is configurable. State the limit you are guarding against in the interview: 'I'm setting an explicit depth bound of 50, which is below Postgres's default 65535 and SQL Server's default 100. If the consumer needs deeper traversal, we change the bound; we do not remove it.'

At Atlassian in 2022, the Jira permissions service had a hierarchy walk over project groups that ran fine for years until a global account migration introduced a circular group reference (group A's parent was set to B, B's parent to C, and C's parent to A through a corrupted import). The query was a recursive CTE without cycle detection and without an explicit depth bound; it relied on SQL Server's default MAXRECURSION of 100 to terminate. On the cyclic data the query ran for 100 iterations producing duplicate descendants on each pass, then threw a recursion-limit error. The permissions API returned 500s for any user in the affected group tree, and customer-facing dashboards went dark for 90 minutes while the on-call traced the cycle. The fix was the path-array check plus an explicit MAXRECURSION 50; the runbook line was 'every recursive CTE in this codebase ships with cycle detection and a depth bound, regardless of whether the data is expected to be acyclic.' Candidates who name the path-array technique unprompted read as someone who has been on the wrong side of a hierarchy-cycle incident.

Why ARRAY for the path

PostgreSQL and Snowflake support ARRAY natively. BigQuery uses ARRAY. SQL Server uses XML or a delimited string. The principle is the same: the path accumulates the IDs visited so far in this branch, and the WHERE clause checks membership. The data structure choice is engine-specific; the algorithm is universal. State the engine-specific syntax when the interviewer asks about portability: 'Postgres ARRAY here; SQL Server would use a STUFF/STRING_AGG approach; the cycle-detection idea is the same.'
TIP
When you write the recursive CTE, narrate the iteration model. 'The engine runs the anchor first to seed the result with the direct reports. Then it runs the recursive step against the seed to find the next level, appends those rows to the result, and uses them as the input to the next iteration. The process stops when the recursive step returns no new rows.' Three sentences. The narration is what proves you understand the engine's evaluation model, not just the syntax.

Closure Tables for Fast Ancestor Lookups

Daily Life
Interviews

Prevent duplicate pairs using a < condition (a.id < b.id) and explain why to the interviewer.

At staff level, the cost conversation is where the depth signal lives. Recursive CTEs do not parallelize across iterations; each iteration is a dependent step on the previous one. The cost per iteration is dominated by the join between the running result set and the source table. If the source table has high fanout (one parent has hundreds of children), the working set grows exponentially across iterations. The path-explosion case is the failure mode at scale.

The per-iteration cost model

Each iteration of a recursive CTE is a join: the previous iteration's result joined to the source table on the parent-child relationship. The cost is roughly (rows in previous iteration) times (average children per row). For an org chart with 10x average fanout per manager and depth 5, the working set is 1, 10, 100, 1000, 10000 rows across iterations, with each iteration's join scaling with the current working set. Total work is proportional to the sum across iterations, which for high-fanout trees can be larger than the source table itself. State this when the interviewer asks about cost: 'each iteration is a join between the running result set and the source; total work is the sum of per-iteration join costs, which for high-fanout trees can exceed the source table size.'

The path-explosion case

If the source has very high fanout (a category tree where the root has 1000 children, each of which has 1000 children), the working set explodes after two iterations. A million rows in the result. Three iterations: a billion. The query does not finish. The mitigation is to add a constraint that bounds the explosion: a WHERE clause on the recursive step that filters to relevant nodes, a depth limit that caps the iterations, or a different tool entirely (a closure table that materializes the descendant set incrementally). Naming the path-explosion case as the failure mode is the move that distinguishes the candidate who has seen this in production.
Root has 1000 childrenEach child has 1000 grandchildrenTwo iterations: 1M rowsThree iterations: 1B rowsQuery does not finish; mitigation is bounded iteration or different tool
/* Bounded recursion for a high-fanout source */
WITH RECURSIVE subtree AS (
SELECT
category_id,
parent_id,
1 AS depth
FROM categories
WHERE parent_id = 42
UNION ALL
SELECT
c.category_id,
c.parent_id,
s.depth + 1
FROM categories AS c
INNER JOIN subtree AS s
ON c.parent_id = s.category_id
WHERE s.depth < 10 /* depth bound */
AND c.is_active = TRUE /* domain filter */
)
SELECT
*
FROM subtree
Two bounds visible in this query. The depth bound caps total iterations. The is_active filter narrows the per-iteration fanout. Both together keep the working set manageable. The interviewer at this level expects you to name both kinds of bound: the structural (depth) and the domain (is_active). State them as a pair: 'I'd add a depth bound as a safety net and a domain filter to narrow the per-iteration fanout. The depth bound bounds the worst case; the domain filter reduces the average case.'

Partition skew in distributed engines

On Snowflake, BigQuery, or Spark, the recursive CTE often does not parallelize at all; the engine treats it as a sequential iteration. The cost per iteration is dominated by the join, which can shuffle the working set if the join column is not co-located. For a hierarchy walk on a billion-row source, this is the operational cost. Mitigations: cluster the source table by the parent_id (so each iteration's join is a partition-local operation), materialize the recursive walk into a closure table that is refreshed less frequently (so reads do not pay the recursive cost), or accept the cost and run the walk off the read path. The interviewer is checking whether you name the shuffle as the cost, not the recursion itself.
Naive recursive query at scale
  • Each iteration shuffles the working set if not co-located
  • High fanout causes the working set to explode
  • No parallelism across iterations on most engines
  • Cost grows multiplicatively with depth times fanout
Production recursive query
  • Source is clustered by parent_id; iterations are partition-local
  • Depth bound caps the worst case
  • Domain filter narrows per-iteration fanout
  • Walk is materialized into a closure table; reads are point lookups

The closure table alternative

When the same hierarchy is read by many queries and changes infrequently, a closure table is the right architecture. The closure table is a (ancestor, descendant, depth) tuple for every pair in the hierarchy. A query 'find all reports of employee 42' becomes a single SELECT against the closure table; no recursion needed. The maintenance cost is the trade: every insert into the source table triggers an insert into the closure table for the new node and every ancestor; every delete triggers a cascade. For a high-read, low-write workload (org chart read by 100 dashboards, updated weekly), the closure table is the right choice. For a high-write workload (a category tree updated by every product import), the recursive CTE on read is cheaper than the closure-table maintenance.

Most production hierarchies are read-heavy and updated rarely. The closure table is the default architecture in those cases. The recursive CTE remains the right answer in ad-hoc analysis or one-time exports where the maintenance overhead would not be amortized. State the trade-off when the interviewer asks about scale: 'for a read-heavy hierarchy, I would materialize a closure table and maintain it on writes. The recursive CTE then becomes the body of the closure-table refresh, not the body of the dashboard query.'

Self-Joins on Large Tables and Indexing

Daily Life
Interviews

Decide when LAG/LEAD or ROW_NUMBER replaces a self-join and when the self-join is the only clean option.

The escalation past hierarchy walking is multi-relationship traversal. Employees report to managers and are also part of project teams; the question becomes 'find every employee within two relationship hops from employee 42 in either direction.' The same recursive CTE shape generalizes, but the recursive step's join condition encodes multiple relationship types. This section is the depth signal at the staff level: can you reason about graph algebra expressed in SQL?

Multi-edge recursion

WITH RECURSIVE related AS(SELECT employee_id, 1 AS hops, ARRAY employee_id AS path FROM employees WHERE employee_id = 42 UNION ALL SELECT e.employee_id, r.hops + 1, r.path || e.employee_id FROM employees e JOIN related r ON e.manager_id = r.employee_id OR r.employee_id IN(SELECT manager_id FROM employees WHERE employee_id = e.employee_id) WHERE NOT e.employee_id = ANY(r.path) AND r.hops < 3)
SELECT DISTINCT
employee_id
FROM related
WHERE employee_id != 42 ;
The recursive step's ON clause unions multiple edge types. The result is the set of employees reachable from employee 42 within 3 hops, where 'reachable' is defined by either the reporting relationship or the project-team relationship. The same idea extends to any graph encoded across one or more tables; the recursive step's join condition defines the edge set.

Shortest path

The interviewer's escalation: 'now return the shortest path between employee 42 and employee 99.' The recursive CTE produces all paths; the shortest-path selection is an outer query that picks the minimum-hop path. Two strategies. Strategy one: compute all paths up to a depth limit, then filter to those ending at the target, then pick the minimum. Strategy two: BFS-style termination, where the recursive step's WHERE clause stops once a path reaches the target. Strategy one is simpler and easier to write in standard SQL; strategy two is more efficient for sparse graphs but requires engine-specific tricks (lateral joins, early termination hints) that are not portable. Pick strategy one for interview SQL; mention strategy two as the optimization.
WITH RECURSIVE paths AS(SELECT employee_id, manager_id, 1 AS hops, ARRAY employee_id AS path FROM employees WHERE employee_id = 42 UNION ALL SELECT e.employee_id, e.manager_id, p.hops + 1, p.path || e.employee_id FROM employees e JOIN paths p ON e.manager_id = p.employee_id WHERE NOT e.employee_id = ANY(p.path) AND p.hops < 20)
SELECT
path,
hops
FROM paths
WHERE employee_id = 99
ORDER BY hops ASC
LIMIT 1 ;
The outer query filters to paths that reach employee 99 and picks the shortest. The depth bound (hops < 20) prevents runaway. The cycle detection prevents revisiting nodes. The path array carries the trail. State the BFS observation when the interviewer asks about efficiency: 'this computes all paths up to depth 20, which is wasteful if the target is reachable in 3 hops. For sparse graphs with a clear target, I'd add an early-termination condition (WHERE NOT EXISTS a shorter path to the target), or move to a dedicated graph engine.'

When SQL is the wrong tool

Recursive CTEs in SQL are correct but slow on graph-heavy workloads. PageRank, community detection, betweenness centrality: these are not SQL problems. A native graph database (Neo4j, JanusGraph) or a graph layer over a columnar engine (Spark GraphX, Neptune) is the right tool. The interviewer at this level expects you to name the boundary: 'recursive CTEs handle bounded-depth traversal and small subtrees correctly; they do not scale to global graph algorithms. For PageRank or shortest-path on a billion-node graph, I would move the workload off SQL.' Articulating this boundary is what proves you have shipped both kinds of workload.
  • bounded subtree walks, single-source reachability, low-fanout hierarchies. Standard tool for org charts and category trees.
  • global algorithms (PageRank, centrality), dense graph shortest-path, repeated queries against many sources.
  • billion-edge graphs with batch processing; integrates with existing data lake.
SQL recursive CTE wins
  • Bounded-depth traversal (subtree walks, comment threads)
  • Single-source reachability from one starting node
  • Hierarchies with low fanout and shallow depth (org charts)
  • Workloads where one query runs against one starting node
Graph engine wins
  • Global algorithms (PageRank, community detection, centrality)
  • Shortest-path queries across a dense graph
  • Repeated queries against many starting nodes
  • Graphs with billions of edges and complex pattern matching

The interviewer at staff level often probes this boundary. The expected answer is not 'always use SQL' or 'always use a graph DB.' It is the specific reasoning about workload characteristics: how many starting nodes, what depth, how many queries per refresh, how often the graph changes. Each of those affects the tool choice. State the reasoning in the interview; the tool falls out of it.

Picking the Shape Before You Write SQL

Daily Life
Interviews

Handle recursive manager chains, multi-level comparisons, and know when to pivot to a recursive CTE.

The closing escalation is the architecture conversation. The interviewer wants to know how the hierarchy walk lives in production: what layer it runs in, how it is refreshed, how it survives partial updates and retroactive changes. The candidate at this level is being scored on whether they frame the query as a refresh job, not as a user-facing surface, and whether they design the materialization with the operational concerns surfaced explicitly.

The closure table as the dashboard surface

For a high-cadence consumer (an org-chart dashboard refreshing every minute, a permission-check API running 10000 QPS), the recursive CTE is too expensive to run per request. The right architecture is a materialized closure table: (ancestor_id, descendant_id, depth) tuples for every pair in the hierarchy. The dashboard or API queries the closure table for a single ancestor, gets the full descendant set in milliseconds, and never touches the recursive CTE. The recursive CTE becomes the body of a refresh job that maintains the closure table. State the orientation unprompted: 'the closure table is the read surface; the recursive walk is the body of the refresh job that maintains it.'
Why the closure table is the architecture for high-read workloads:
  • Read is a single point lookup; latency is index-bound, not recursion-bound
  • Refresh job runs the recursive CTE; consumer never sees it
  • Refresh cadence is decoupled from read cadence
  • Maintenance cost paid at write time, amortized across many reads
TRUNCATE org_closure ; INSERT INTO org_closure(ancestor_id, descendant_id, depth) WITH RECURSIVE walk AS(SELECT employee_id AS ancestor_id, employee_id AS descendant_id, 0 AS depth FROM employees UNION ALL SELECT w.ancestor_id, e.employee_id, w.depth + 1 FROM walk w JOIN employees e ON e.manager_id = w.descendant_id WHERE w.depth < 50)
SELECT
ancestor_id,
descendant_id,
depth
FROM walk ;
The recursive walk starts each employee as their own ancestor at depth 0, then walks downward. The result is (ancestor, descendant, depth) for every pair where ancestor is reachable to descendant via the reporting chain. A 10,000-employee company with average fanout 5 produces roughly 50,000-100,000 closure rows. The dashboard query becomes 'SELECT descendant_id FROM org_closure WHERE ancestor_id = 42,' which is a single index lookup.

Refresh strategy: full vs incremental

Full refresh: drop the closure table, rebuild from scratch. Simple, correct, expensive. For a 10,000-employee org refreshed daily, full refresh is fine. For a 1,000,000-employee org with hourly refresh, full rebuild is too slow. Incremental refresh: track which employees have changed since the last refresh (manager_id update, new hire, departure), recompute only the affected subtrees, merge into the closure table. More complex; requires careful handling of dangling rows when an employee is removed. Defend the choice based on the source change rate versus the refresh cadence.

Late-arriving changes and consistency

Hierarchies change. An employee gets a new manager mid-quarter. A department reorganizes. A category tree gets restructured. The closure table has to reflect these changes consistently. The risk is inconsistency: the source has the new structure, but the closure table still has the old descendants. The dashboard reads a mix of old and new and the consumer sees a broken org chart. Mitigations: (1) atomic refresh with a swap (write the new closure to a staging table, then swap atomically), (2) versioned closure tables (each refresh writes a new version; the dashboard pins to a version), (3) read-time consistency check (the dashboard verifies the closure version matches the source version). State the consistency mechanism: 'I would refresh into a staging table and swap atomically; readers always see a self-consistent snapshot.'
Naive refresh strategy
  • Truncate and rebuild on every refresh
  • Cost scales with the full hierarchy size, not delta
  • Reads during refresh see partial state
  • Refresh time grows with the source size
Production refresh strategy
  • Incremental for steady state; full rebuild on schema changes
  • Atomic swap via staging table; readers see consistent snapshots
  • Versioned closures retained for audit and rollback
  • Refresh time bounded by the rate of change, not the source size

The synthesis: tool choice as a function of the workload

Three workload characteristics drive the tool choice. Depth: bounded -> chained join; unbounded -> recursive CTE or closure table. Read rate: low -> recursive CTE on demand; high -> closure table. Write rate: low -> closure table with full refresh; high -> closure table with incremental, or recursive CTE on demand. The matrix is more nuanced than 'always use X.' At the staff level, the interviewer expects you to walk the matrix in your head and pick the right tool based on the specific workload, not based on a default.
WorkloadDepthRead rateWrite rateRight tool
Org chart dashboard10-20High (1000/s)Low (weekly)Closure table, full refresh nightly
Comment thread render5-50HighMedium (per post)Closure table, incremental on post
Manufacturing BOM walk20-100Low (analyst)Low (release)Recursive CTE on demand
Permission check API5-15Very high (10k/s)LowClosure table, atomic refresh
Ad-hoc subtree exportUnknownOne-shotN/ARecursive CTE on demand

The closing summary

Close with a five-sentence wrap. 'Self-join at scale means a recursive CTE for unbounded depth. The recursive CTE walks the hierarchy with an anchor, a recursive step, and a UNION ALL; the path array and depth bound prevent cycles and runaway. For a high-read consumer, I would materialize a closure table that the dashboard reads from; the recursive CTE becomes the body of the refresh job. The refresh strategy is full or incremental depending on the source change rate; either way I would write to a staging table and swap atomically so readers see consistent snapshots. For graph workloads beyond bounded-depth traversal (PageRank, community detection), I would move off SQL to a dedicated graph engine.' Five sentences. Each names a different architectural layer.
PUTTING IT ALL TOGETHER

> You are in a data engineering interview at a permissions-heavy enterprise SaaS company. The interviewer asks: 'Walk an org chart and return every employee under a target manager, with the depth of each. The org chart has 500,000 employees and the permission-check API needs the result in under 10ms per call.'

You frame the surface first: 'The API can't run the recursive CTE per call at that latency. The right architecture is a closure table the API reads from; the recursive walk is the body of the refresh job that maintains it.'
You write the recursive CTE with three safeties: anchor seeds direct reports, recursive step joins on manager_id, the path array prevents cycles, and an explicit depth bound caps total iterations.
You name the path-array as production-grade: 'real hierarchies have cycles from temporary reorgs and bad imports. Without cycle detection the query walks the cycle until the engine's recursion limit, then errors. The path array is a one-line defense.'
You pivot to the closure table: 'the closure table has (ancestor_id, descendant_id, depth) for every reachable pair. The API does a single point lookup by ancestor_id; latency is index-bound, not recursion-bound.'
Follow-up: 'How do you refresh it?' You say: 'Full rebuild nightly for a workload that changes weekly. Incremental refresh only if write rate is high enough to justify the implementation complexity. Either way I refresh into a staging table and swap atomically; readers always see a self-consistent snapshot.'
Closing: 'For graph workloads beyond bounded-depth subtree walking (PageRank, community detection, repeated shortest-path queries), I would move off SQL to a dedicated graph engine. Recursive CTEs handle bounded traversal correctly; they do not scale to global graph algorithms.'
KEY TAKEAWAYS
Three tools carry different cost profiles: a chained LEFT JOIN per level for bounded depth, a RECURSIVE CTE for unbounded depth, and a closure table of (ancestor, descendant, depth) tuples that pays the cost at write time so reads become point lookups.
Guard a recursive walk with both a path array and a depth bound. The path array prevents revisiting a node, the s.depth < 50 bound caps total work, and Atlassian's Jira permissions outage came from a cyclic group import running against neither.
Each iteration costs roughly the previous iteration's row count times the average fanout, so a source where the root has 1000 children reaching 1000 each explodes to a million rows by iteration two; bound both the structure with a depth limit and the domain with a filter such as is_active = TRUE.
For a read-heavy hierarchy, the recursive CTE becomes the body of the closure-table refresh job and the dashboard queries SELECT descendant_id FROM org_closure WHERE ancestor_id = 42, a single index lookup instead of a per-request traversal.
Refresh the closure table into a staging table and swap atomically, or version it and pin readers, so a mid-refresh reader never sees the old descendant set against the new source structure.
Name the boundary where SQL stops: recursive CTEs handle bounded-depth traversal and small subtrees, but PageRank, community detection, and shortest path on a billion-node graph belong on a graph engine.

The table joins itself when the question compares rows to other rows

Category
SQL
Difficulty
advanced
Duration
25 minutes
Challenges
0 hands-on challenges

Topics covered: Hierarchical Self-Joins and Depth, Chained Joins vs Recursive CTE, Closure Tables for Fast Ancestor Lookups, Self-Joins on Large Tables and Indexing, Picking the Shape Before You Write SQL

Lesson Sections

  1. Hierarchical Self-Joins and Depth (concepts: sqlSelfJoin)

    Three tools, three different cost profiles Fixed-depth chained join: works for bounded depth (one or two levels). Each LEFT JOIN adds a level. Query stays flat; optimizer plans it as a normal multi-way join. Recursive CTE: works for unbounded depth. The engine iterates the recursive step until no new rows are added. Cost grows linearly with the number of iterations times the average fanout per iteration. Closure table: pre-computed table of (ancestor, descendant, depth) tuples. Read queries beco

  2. Chained Joins vs Recursive CTE (concepts: sqlRecursiveCte)

    Recursive CTEs are the canonical tool for unbounded hierarchy traversal in SQL. The shape is the same across engines: an anchor query that produces the starting set, a recursive step that joins the previous iteration's result to the source table, and a UNION ALL that combines them. The engine iterates the recursive step until it returns zero new rows. Understanding the iteration mechanics is what separates a query that ships from one that infinite-loops in production. The canonical recursive CTE

  3. Closure Tables for Fast Ancestor Lookups (concepts: sqlRecursiveCte)

    At staff level, the cost conversation is where the depth signal lives. Recursive CTEs do not parallelize across iterations; each iteration is a dependent step on the previous one. The cost per iteration is dominated by the join between the running result set and the source table. If the source table has high fanout (one parent has hundreds of children), the working set grows exponentially across iterations. The path-explosion case is the failure mode at scale. The per-iteration cost model Each i

  4. Self-Joins on Large Tables and Indexing (concepts: sqlRecursiveCte)

    The escalation past hierarchy walking is multi-relationship traversal. Employees report to managers and are also part of project teams; the question becomes 'find every employee within two relationship hops from employee 42 in either direction.' The same recursive CTE shape generalizes, but the recursive step's join condition encodes multiple relationship types. This section is the depth signal at the staff level: can you reason about graph algebra expressed in SQL? Multi-edge recursion The recu

  5. Picking the Shape Before You Write SQL (concepts: sqlRecursiveCte)

    The closing escalation is the architecture conversation. The interviewer wants to know how the hierarchy walk lives in production: what layer it runs in, how it is refreshed, how it survives partial updates and retroactive changes. The candidate at this level is being scored on whether they frame the query as a refresh job, not as a user-facing surface, and whether they design the materialization with the operational concerns surfaced explicitly. The closure table as the dashboard surface For a