Knowledge Guide
HomeDatabasesSQL Practice Problems

SQL Practice Patterns II — Window-Function Ties, WHERE-vs-ON Placement, Self-Join Fan-out & Portability (Deep Dive)

Five SQL practice problems keep reappearing in interviews wearing different names, and each one hides the same mechanism: a window function that must count distinct values, not rows; a filter that changes an outer join's meaning depending on which clause it sits in; a self-join that silently multiplies rows; a conditional aggregate whose index story depends on how many distinct values a column has; and a query that works on one engine but breaks — or breaks on realistic data — on another. This page continues the SQL Practice Patterns I companion (NULL three-valued-logic traps, COUNT(*) vs COUNT(col) vs COUNT(DISTINCT), sargability rewrites, the missing-group INNER-vs-LEFT+COALESCE pattern) one level deeper into the judgment calls those problems actually test. It also leans on two sibling deep dives for the cost mechanics behind pattern 4: Query Execution — Selectivity, Cost Model, Join Algorithms, Spills & Sargability and Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls.

1. Window-function ties: DENSE_RANK vs RANK vs ROW_NUMBER

Mechanism first: the three ranking functions only disagree when there is a tie, and they disagree in three different ways — ROW_NUMBER() pretends the tie never happened and hands out a unique integer to each row in whatever order the tiebreak falls; RANK() gives tied rows the same number but then skips ahead by the tie's size; DENSE_RANK() gives tied rows the same number and does not skip. Only DENSE_RANK()'s output answers the question "how many distinct values have I seen so far?" — which is exactly what "the Nth-highest salary" is asking.

Traced worked example

Employee salaries, ordered descending: 300, 200, 200, 100 — two employees tie at 200. Asking for the 3rd-highest distinct salary (the correct answer is 100):

salaryROW_NUMBER()RANK()DENSE_RANK()
300111
200222
200322
100443

Filter each result set for row/rank = 3: ROW_NUMBER returns the second 200 row — wrong, that value already had rank 2. RANK has no row numbered 3 at all — it jumped straight from 2 to 4 — so the query returns nothing. Only DENSE_RANK lands on 100, the actual 3rd distinct value.

Ranking-ties diagram: ROW_NUMBER, RANK and DENSE_RANK computed over 300, 200, 200, 100, showing that filtering for rank 3 gives the wrong row, no row, and the correct row respectively
Ranking-ties diagram: ROW_NUMBER, RANK and DENSE_RANK computed over 300, 200, 200, 100, showing that filtering for rank 3 gives the wrong row, no row, and the correct row respectively
-- General template: Nth-highest DISTINCT value via DENSE_RANK
SELECT salary
FROM (
    SELECT salary,
           DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
    FROM Employee
) ranked
WHERE rnk = 2;   -- swap 2 for N; wrap in SELECT (...) AS x to get NULL instead of no rows when N is out of range

Use ROW_NUMBER() only when you deliberately want "exactly one row, tie or not" (e.g. picking any one representative row per group). Use RANK() when the output should expose competition-style gaps (Olympic scoring: two golds means the next medal is bronze, not silver). Use DENSE_RANK() whenever the question is phrased around distinct values — "2nd highest salary," "3rd most recent distinct order date," "top-3 distinct price tiers."

2. Filter placement: WHERE vs ON in outer joins

Mechanism first: conceptually, an outer join's ON clause runs during the join — it decides which rows on the optional side get attached, but every row on the preserved (left) side survives regardless, with NULLs filled in where nothing matched. WHERE runs after the join is already complete, over the joined result — including those NULL-filled rows. A predicate on the optional table's column, written in WHERE, is evaluated against those NULLs too. In SQL's three-valued logic, NULL = anything is UNKNOWN, and WHERE discards any row that isn't TRUE — so it silently deletes exactly the unmatched-left rows an outer join exists to keep, collapsing the LEFT JOIN down to INNER JOIN behavior.

Traced worked example — Unused Accounts

Find accounts with no transaction in 2020. Accounts: Alice, Bob, Charlie; only Charlie made no 2020 transaction. Written correctly, the year predicate sits in ON:

SELECT a.account_name
FROM Accounts a
LEFT JOIN Transactions t
       ON a.account_id = t.account_id
      AND t.transaction_date >= '2020-01-01'
      AND t.transaction_date <  '2021-01-01'
WHERE t.account_id IS NULL
ORDER BY a.account_name;

(The half-open range here is also the sargable rewrite of YEAR(transaction_date) = 2020 — wrapping the column in YEAR() defeats a B-tree index on transaction_date because the index is ordered by the raw value, not the function's output; see the Query Execution deep dive's sargability section.)

Now watch what happens if the year condition is written in WHERE instead, alongside the IS NULL check that is supposed to find the unused accounts:

SELECT a.account_name
FROM Accounts a
LEFT JOIN Transactions t
       ON a.account_id = t.account_id
WHERE t.account_id IS NULL
  AND YEAR(t.transaction_date) = 2020;   -- moved from ON to WHERE

For Charlie, t.account_id IS NULL is TRUE — but because the whole t row is NULL, t.transaction_date is NULL too, so YEAR(NULL) = 2020 evaluates to UNKNOWN. TRUE AND UNKNOWN is UNKNOWN, which WHERE treats as "reject." Charlie's row — the only row that should have survived — is dropped, and the query returns zero rows every time, regardless of the data.

Unused Accounts diagram: filter in ON preserves Charlie's unmatched row and correctly outputs Charlie; filter in WHERE alongside the NULL check drops Charlie too and outputs zero rows
Unused Accounts diagram: filter in ON preserves Charlie's unmatched row and correctly outputs Charlie; filter in WHERE alongside the NULL check drops Charlie too and outputs zero rows

The rule generalizes past this one problem: any predicate that should only narrow the optional side's rows, without affecting which left-side rows survive, belongs in ON. A predicate that should apply to the join's final result (after deciding survivorship) belongs in WHERE. WHERE t.account_id IS NULL is safe in WHERE precisely because it is testing the outcome of the join (did anything match?), not filtering the optional table's own columns before that outcome is decided.

3. Self-join fan-out and DISTINCT

Mechanism first: a self-join matches every qualifying row on one side against every qualifying row on the other side that shares the join key. When a middle entity appears on both sides with more than one partner each, the join doesn't add those partners — it multiplies them, one combined row per (left-partner, right-partner) pair. That multiplication is fan-out, and it is invisible until you count.

Worked example — Second Degree Follower

A second-degree follower both follows someone and is followed by someone; report each such user with their follower count:

SELECT f1.follower AS follower,
       COUNT(DISTINCT f2.follower) AS num
FROM Follow f1
JOIN Follow f2 ON f1.follower = f2.followee
GROUP BY f1.follower
ORDER BY f1.follower;

Illustrative data for user Bob: Bob follows 2 people (Alice, Cena) — that's 2 rows of f1 with f1.follower = 'Bob'. Separately, 2 people follow Bob (Donald, Edward) — that's 2 rows of f2 with f2.followee = 'Bob'. The join condition f1.follower = f2.followee pairs every one of Bob's 2 "follows" rows against every one of Bob's 2 "followed-by" rows: 2 × 2 = 4 combined rows, all carrying f1.follower = 'Bob'. COUNT(f2.follower) over those 4 rows would report 4 — but Bob only has 2 distinct followers (Donald and Edward each get counted twice, once per person Bob follows). Only COUNT(DISTINCT f2.follower) collapses the duplicates back to the true count, 2.

When a half-open condition replaces DISTINCT

DISTINCT is the right tool when the fan-out creates duplicate values you must collapse (as above). A different self-join shape — finding mutual pairs ("users who follow each other back") — fans out differently: it produces both (A, B) and (B, A) as separate, non-duplicate rows, so DISTINCT alone won't remove them (they aren't identical rows). The fix there is a half-open ordering condition on the join itself:

SELECT f1.follower AS user_a, f1.followee AS user_b
FROM Follow f1
JOIN Follow f2
  ON f1.follower = f2.followee
 AND f1.followee = f2.follower
WHERE f1.follower < f1.followee;   -- keeps (A,B), drops the mirror-image (B,A)

The distinction that matters: use DISTINCT when the join produces genuinely repeated values for the same logical answer (Second Degree Follower); use an ordering condition like a < b when the join produces two symmetric-but-distinct rows describing the same unordered pair.

4. Conditional aggregation cost and low-cardinality index selectivity

Mechanism first: SUM(CASE WHEN ... THEN 1 ELSE 0 END) (or the equivalent COUNT(CASE WHEN ... THEN 1 END)) cannot skip a single row — every row in the group must be inspected to decide which branch it falls into, so the cost is always O(rows in the group), one full pass, regardless of how skewed the categories are. That part is unavoidable and cheap: three SUM(CASE...) columns computed in the same query cost the same one pass as computing just one.

The senior-level question that follows is different: would an index help this query go faster? For Employee Attendance Record — pivoting status ('Present'/'Absent'/'Late') into per-status counts — a covering index on Attendance(employee_id, status) helps, but not by making the conditional aggregation itself skip rows; it helps by letting the LEFT JOIN probe per employee via an ordered index walk that also satisfies ORDER BY employee_id, avoiding a separate sort.

Contrast that with Green Product Identification, filtering Inventory on two 2-value enum columns, organic and biodegradable. A composite index (organic, biodegradable) has at most 4 distinct key combinations. If roughly a quarter of rows are (Y, Y), the optimizer's selectivity estimate for that predicate is around 25% of the table — nowhere near the roughly-single-digit-percent crossover below which an index scan beats a sequential scan (see the Query Execution deep dive's selectivity crossover). An index seek that then does random heap I/O for a quarter of all rows costs more than one linear pass over every page. The planner will — correctly — pick a sequential scan and ignore the index. A covering index can still help here, but for a different reason: if the index carries all needed columns (e.g. (organic, biodegradable, product_id)), the engine can answer straight from the index without touching the heap at all (an index-only scan) — it isn't narrowing the search, it's skipping the heap lookup entirely.

5. Portability and edge correctness

Two patterns look bulletproof on the sample data in the problem statement and quietly break somewhere realistic.

Latent bug 1 — row-value IN is not portable

Game Play Analysis IV (fraction of players who logged in again the day after their first login) is often solved with a row-constructor membership test:

SELECT ROUND(
         COUNT(DISTINCT a.player_id)
         / (SELECT COUNT(DISTINCT player_id) FROM Activity), 2) AS fraction
FROM Activity a
WHERE (a.player_id, DATE_SUB(a.event_date, INTERVAL 1 DAY)) IN (
        SELECT player_id, MIN(event_date)
        FROM Activity
        GROUP BY player_id
);

(a, b) IN (SELECT c, d ...) works on MySQL and PostgreSQL, but SQL Server has no multi-column IN — this is a straight syntax error there, not a silent wrong answer. The portable rewrite replaces the row-value membership test with a join to a first-login CTE:

WITH first_login AS (
    SELECT player_id, MIN(event_date) AS first_date
    FROM Activity
    GROUP BY player_id
)
SELECT ROUND(
         COUNT(DISTINCT a.player_id)
         / (SELECT COUNT(*) FROM first_login), 2) AS fraction
FROM Activity a
JOIN first_login f
  ON a.player_id = f.player_id
 AND a.event_date = DATE_ADD(f.first_date, INTERVAL 1 DAY);

Same result, no row-constructor syntax, and it runs unmodified on SQL Server.

Latent bug 2 — the consecutive-calendar-days assumption

High School Attendance (3+ consecutive absent days) is the classic gaps-and-islands trick: subtract a per-student ROW_NUMBER() from the actual attendance_date; rows in the same unbroken run share the same result, because each step forward in the run advances both the date and the row number by exactly one day.

WITH absents AS (
    SELECT student_id, attendance_date,
           ROW_NUMBER() OVER (PARTITION BY student_id ORDER BY attendance_date) AS rn
    FROM Attendance
    WHERE status = 'Absent'
)
SELECT student_id, MIN(attendance_date) AS start_date
FROM absents
GROUP BY student_id, DATE_SUB(attendance_date, INTERVAL rn DAY)
HAVING COUNT(*) >= 3;

This is only correct if the table has exactly one row per consecutive calendar day when a student is absent. If attendance is only ever recorded on school days — no rows exist for weekends or holidays — then Friday, Monday, Tuesday absences are three consecutive school days but not three consecutive calendar dates: subtracting rn from the raw date no longer produces a constant value across that run, and it silently splits into two islands, undercounting the streak. The robust fix ranks over the distinct school-day calendar, not the raw date, so the grouping key advances by exactly 1 per school day regardless of the calendar gap between them:

WITH school_days AS (
    SELECT DISTINCT attendance_date FROM Attendance
),
ranked_days AS (
    SELECT attendance_date,
           DENSE_RANK() OVER (ORDER BY attendance_date) AS day_rnk
    FROM school_days
),
absents AS (
    SELECT a.student_id, a.attendance_date, r.day_rnk,
           ROW_NUMBER() OVER (PARTITION BY a.student_id ORDER BY a.attendance_date) AS rn
    FROM Attendance a
    JOIN ranked_days r ON a.attendance_date = r.attendance_date
    WHERE a.status = 'Absent'
)
SELECT student_id, MIN(attendance_date) AS start_date
FROM absents
GROUP BY student_id, (day_rnk - rn)
HAVING COUNT(*) >= 3;

day_rnk - rn stays constant across a run of consecutive school days even when calendar dates jump over a weekend, because both counters advance in lockstep with the school-day calendar rather than with raw dates.

Pitfalls

Judgment layer: how a senior engineer decides

Takeaways

Related pages


Grounded in this guide's SQL Practice Problems slice — 2nd Highest Salary, Unused Accounts, Second Degree Follower, Green Product Identification, Employee Attendance Record, Game Play Analysis IV, and High School Attendance. Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on SQL Practice Patterns II — Window-Function Ties, WHERE-vs-ON Placement, Self-Join Fan-out & Portability (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **SQL Practice Patterns II — Window-Function Ties, WHERE-vs-ON Placement, Self-Join Fan-out & Portability (Deep Dive)** (Databases) and want to truly understand it. Explain SQL Practice Patterns II — Window-Function Ties, WHERE-vs-ON Placement, Self-Join Fan-out & Portability (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **SQL Practice Patterns II — Window-Function Ties, WHERE-vs-ON Placement, Self-Join Fan-out & Portability (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **SQL Practice Patterns II — Window-Function Ties, WHERE-vs-ON Placement, Self-Join Fan-out & Portability (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **SQL Practice Patterns II — Window-Function Ties, WHERE-vs-ON Placement, Self-Join Fan-out & Portability (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes