Self-Join: Intermediate
Directional Pairs and Double-Counting
Recognize that questions comparing pairs within a table (manager/employee, previous/next, duplicate detection) require self-joins.
Here is the shape that recurs in interviews past the basics: 'find pairs of customers who placed orders within ten minutes of each other.' The pattern is a self-join. The complication is the inequality, the time-window, and the deduplication. Each of those is a decision point. The candidate who pauses to name those decisions out loud before writing SQL has already cleared the bar. The candidate who writes a fast first version and discovers the duplicate-pair bug while tracing the output spends the rest of the interview defending it.
- ▸"find pairs of X that ..."
- ▸"detect rows that share a value with another row"
- ▸"compare each row to others in the same group"
- ▸"events that happened within N minutes of another event"
- ▸"find the parent or predecessor of each row"
- ▸Any cross-row comparison within a single table
The decision the interviewer is silently scoring
- Comparison is to a fixed row offset in a sorted partition (previous row, next row, N rows back)
- The output keeps one row per input row
- The engine can compute the comparison without materializing the join
- The query reads top-to-bottom as a single pipeline
- Comparison is on a join predicate, not a row position
- The output produces pairs or expands per match
- Multi-column conditions or range-based comparisons are required
- The engine plans the join more predictably than the optimizer rewrites a correlated subquery
What the interviewer is actually testing
- ▸Cardinality anticipation: name N² before it ships
- ▸Tool choice: LAG for adjacent rows, self-join for predicate matches
- ▸Deduplication: < inequality on a unique column for symmetric pairs
Before you write the self-join, name the cardinality out loud. 'If both sides are unconstrained, this produces up to N times N rows. The join condition has to bound the cross-product to something tractable.' That sentence is the move; it tells the interviewer you treat join cardinality as a first-class concern, not as something the engine will figure out.
Self-Join vs Window Function
Write a self-join with clear aliases and a precise ON clause that avoids Cartesian explosions.
The canonical self-join with all three constraints
- the equality on the shared key (customer_id, region) makes the join meaningful, not a global cross-product.
- the inequality (o2.order_time > o1.order_time) eliminates self-pairs and ensures each pair appears exactly once.
- the upper bound (within ten minutes, same calendar day) converts a potential N² join into one bounded by local cardinality.
Why the inequality is in the ON clause, not the WHERE
- Inequalities and range conditions in the WHERE clause
- Engine may materialize the unbounded cross-product first
- Filter applies after the join produces all candidate rows
- Plan is harder to optimize on engines without good predicate pushdown
- All join-related conditions in the ON clause
- Engine prunes during the join, not after
- Range conditions can use indexes on the join column
- Plan is portable across engines
Multi-column join conditions
State each constraint's purpose out loud while you type. 'customer_id equality to bind the pair to one customer. Time inequality to deduplicate and order. Time window to bound the cross-product. Region inequality to filter to cross-region pairs.' Each sentence is a small move; collectively they tell the interviewer you treat the ON clause as the place where the query's correctness is decided.
Avoiding the (a,b)/(b,a) Duplicate
Prevent duplicate pairs using a < condition (a.id < b.id) and explain why to the interviewer.
The bug and the standard fix
Joining customers to themselves on city alone produces (Alice, Alice), (Alice, Bob), (Bob, Alice), (Bob, Bob), and so on. The self-pairs are nonsense. The (Alice, Bob) and (Bob, Alice) duplicates are the same logical pair counted twice. Adding c1.customer_id < c2.customer_id to the ON clause fixes both at once: strict inequality drops self-pairs, and the ordering keeps each unordered pair exactly once.
- ▸Drops every self-pair (the inequality is strict)
- ▸Keeps each unordered pair exactly once
- ▸Generalizes to triples (chain c1.id < c2.id < c3.id) and higher
- ▸Requires a unique column; non-unique columns produce wrong dedup
Choosing the column and the direction
- Pairs of customers in the same city
- Pairs of orders placed within ten minutes
- Pairs of products in the same category
- The pair (A, B) is the same as the pair (B, A)
- Customer A referred customer B
- Order O1 was returned and replaced by O2
- Employee A reports to manager B
- The pair (A, B) is meaningfully different from (B, A)
Triple-and-higher cardinality
When you write the deduplication inequality, narrate the alternatives. 'I am using strict less-than (<), so the same pair never appears in both orders and self-pairs are dropped. If the interviewer asked for both directions, I would use != instead, which keeps both orders but still drops self-pairs.' That alternative-naming is what tells the interviewer you have made the choice deliberately, not by reflex.
Comparing a Row to Its Neighbors
Decide when LAG/LEAD or ROW_NUMBER replaces a self-join and when the self-join is the only clean option.
The two solutions side-by-side
When the self-join wins after all
Plan implications
Both tools have legitimate uses. The mistake is reaching for one without articulating why. Saying 'I default to LAG for adjacent-row comparisons and switch to self-join only when the comparison condition cannot be expressed as a row offset' is the framework. The framework is what the interviewer is reading you for.
Self-Join vs LAG for Adjacent Rows
Handle recursive manager chains, multi-level comparisons, and know when to pivot to a recursive CTE.
The chained-self-join approach
When to switch to a recursive CTE
The cost model and when to switch tools
- manager_id, parent_id, or whichever column the recursive step joins on. Without it, each iteration scans the source table.
- real-world hierarchies have cycles; a visited-set in the recursive step prevents infinite loops.
- engines have a default recursion limit (Postgres: 65535 iterations; SQL Server: 100 by default). Set explicitly for production queries.
- Depth is known and small (1-4 levels)
- Each level needs to be a separate column in the output
- The team's house style avoids recursive constructs
- Performance is critical and you can hand-optimize the join order
- Depth is unbounded or unknown
- The output is one row per descendant with a depth column
- Aggregates need to be computed across the entire subtree
- The hierarchy has natural cycles that need explicit termination
How candidates talk about self-joins, ranked by what the interviewer hears
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| You see a same-table comparison | "I'll join the table to itself." | "This is a self-join. I'll alias employees as e for the employee and m for the manager; the join condition expresses the relationship." |
| The interviewer hands you a symmetric pair question | "I'll add WHERE != to drop duplicates." | "I'll add c1.id < c2.id to the ON clause so each unordered pair appears exactly once and self-pairs are dropped." |
| You spot that the comparison is adjacent-row | "I'll self-join and find the previous row." | "This is an adjacent-row comparison. LAG over (PARTITION BY ... ORDER BY ...) is cleaner; the self-join is the right reach when the match is defined by a predicate, not a row position." |
| The interviewer mentions billion-row scale | "I'd run it and see." | "Unconstrained, this fans out to N². I'd bound the ON clause with a time window or a key equality before deploying. EXPLAIN would tell me whether the optimizer planned it as a hash join or a nested loop." |
| The interviewer asks about unbounded depth | "I'd write a bunch of LEFT JOINs." | "For unbounded depth I'd switch to a recursive CTE with cycle detection. Chained joins cap out around four levels." |
Cycle detection: the subtle bug
The closing summary
> You are in a data engineering interview. The interviewer asks: 'Find pairs of orders by the same customer placed within ten minutes of each other.'
ON clause with an equality, a directional inequality, and a window predicate.ON clause rather than WHERE so the engine prunes candidate matches during the join instead of materializing the cross product and filtering after.c1.customer_id < c2.customer_id on a column that is unique per row; the strict less-than drops self-pairs and keeps each unordered pair once, and the same idea chains to c1.id < c2.id AND c2.id < c3.id for triples.LAG wins whenever the comparison is to an adjacent row in a sorted order, because it plans as a single sorted pass; the self-join is the only clean option when the prior row is defined by a predicate such as same merchant rather than by row offset.LEFT JOIN calls handle a hierarchy up to about four levels; unbounded depth needs a RECURSIVE CTE with an index on manager_id and a visited-path array so a reporting loop cannot spin forever.The table joins itself when the question compares rows to other rows
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: Directional Pairs and Double-Counting, Self-Join vs Window Function, Avoiding the (a,b)/(b,a) Duplicate, Comparing a Row to Its Neighbors, Self-Join vs LAG for Adjacent Rows
Lesson Sections
- Directional Pairs and Double-Counting (concepts: sqlSelfJoin)
The decision the interviewer is silently scoring Self-join is one of three tools for cross-row comparison. The others are window functions and recursive CTEs. Picking the right one for the shape of the comparison is the move. Defaulting to one without articulating why is the yellow flag. The interviewer is checking whether you treat the choice as a tradeoff or as a reflex. At Stripe in late 2022, a fraud detection query that should have been a sliding window comparison was written as an unconstr
- Self-Join vs Window Function (concepts: sqlSelfJoin)
At this level, the aliasing and join syntax is the floor. The interesting decisions are in how you constrain the join: the predicate that determines which pairs survive, the inequality that deduplicates symmetric pairs, and the additional conditions that bound the time or value window. Each of those is a deliberate choice; sloppy choices ship as performance bugs or wrong row counts. The canonical self-join with all three constraints Three constraints in the ON clause. The first bounds the join t
- Avoiding the (a,b)/(b,a) Duplicate (concepts: sqlSelfJoin)
Symmetric pair questions ('find customers in the same region as another customer') are where the duplicate-pair bug ships most often. The naive query returns each pair twice plus every self-pair, inflating the row count by 2N + N². The fix is one inequality, but the choice of which column to use as the tiebreaker has implications for correctness, cardinality, and deduplication semantics. The bug and the standard fix Choosing the column and the direction Two decisions sit underneath the inequalit
- Comparing a Row to Its Neighbors (concepts: sqlSelfJoin)
The most common follow-up at this level is 'now do this with a window function instead.' The conversion teaches you what each tool is good at. For comparing each row to the row immediately before it in a sorted order, the window function (LAG) wins on readability and usually performance. For comparing to a row defined by a join condition rather than a row position, the self-join is the only option. The two solutions side-by-side Question: for each transaction, return the previous transaction's a
- Self-Join vs LAG for Adjacent Rows (concepts: sqlRecursiveCte)
The escalation at this level is depth. A self-join handles one level of hierarchy. Two self-joins handle two levels. Three handle three. At some depth, the chained-join approach breaks down: either because the hierarchy is unbounded, or because the code becomes unreadable, or because the engine's join planner gives up. The pivot point is when you switch to a recursive CTE. Knowing where that pivot point is , and being able to defend the choice , is the depth signal at this level. The chained-sel