What this quiz covers
This quiz focuses on Self Joins, giving you a quick way to practice the rules, question types, and explanations that matter most for SQL.
The PriceHistory table has columns product_id, effective_date, and price. Each product has at most one row for a given effective_date. A query must return the most recent price row for every product without using a subquery or window function.
Which query uses a self-join to return the required rows?
SELECT p1.* FROM PriceHistory p1 JOIN PriceHistory p2 ON p1.product_id = p2.product_id WHERE p2.effective_date > p1.effective_date;SELECT p1.* FROM PriceHistory p1 LEFT JOIN PriceHistory p2 ON p1.product_id = p2.product_id AND p2.effective_date > p1.effective_date WHERE p2.product_id IS NULL;SELECT p1.* FROM PriceHistory p1 LEFT JOIN PriceHistory p2 ON p1.product_id = p2.product_id WHERE p2.effective_date > p1.effective_date OR p2.product_id IS NULL;SELECT p1.* FROM PriceHistory p1 LEFT JOIN PriceHistory p2 ON p1.product_id = p2.product_id AND p2.effective_date < p1.effective_date WHERE p2.product_id IS NULL;SQL Quiz
Practice Self Joins in SQL with focused quiz questions that help you check what you know, review explanations, and build confidence with test-style prompts.
This quiz focuses on Self Joins, giving you a quick way to practice the rules, question types, and explanations that matter most for SQL.
Try each quiz question before looking at the correct answer. Use the explanations to review missed ideas, then come back to similar questions until the pattern feels familiar.
The PriceHistory table has columns product_id, effective_date, and price. Each product has at most one row for a given effective_date. A query must return the most recent price row for every product without using a subquery or window function.
Which query uses a self-join to return the required rows?
SELECT p1.* FROM PriceHistory p1 JOIN PriceHistory p2 ON p1.product_id = p2.product_id WHERE p2.effective_date > p1.effective_date;SELECT p1.* FROM PriceHistory p1 LEFT JOIN PriceHistory p2 ON p1.product_id = p2.product_id AND p2.effective_date > p1.effective_date WHERE p2.product_id IS NULL; (correct answer)SELECT p1.* FROM PriceHistory p1 LEFT JOIN PriceHistory p2 ON p1.product_id = p2.product_id WHERE p2.effective_date > p1.effective_date OR p2.product_id IS NULL;SELECT p1.* FROM PriceHistory p1 LEFT JOIN PriceHistory p2 ON p1.product_id = p2.product_id AND p2.effective_date < p1.effective_date WHERE p2.product_id IS NULL;p1 to p2 on the same product_id and requiring p2.effective_date > p1.effective_date, you're trying to find a row that is newer than p1. When the LEFT JOIN finds no such match — meaning p2.product_id IS NULL — it proves that nothing is newer than p1, so p1 must be the most recent row. That's exactly the pattern B uses, and it correctly returns one row per product.
Option A uses an INNER JOIN and filters WHERE p2.effective_date > p1.effective_date. This keeps only rows where a newer row exists — the exact opposite of what you want. It eliminates the most recent rows entirely.
Option C moves the condition p2.effective_date > p1.effective_date into the WHERE clause instead of the JOIN condition, then ORs it with p2.product_id IS NULL. This creates a logical mess: the OR means you'd include rows where a newer record exists OR where there's no match at all, returning incorrect results.
Option D joins on p2.effective_date < p1.effective_date (looking for older rows) and then checks for NULL, which would return only products with a single row — not the most recent row for every product.
As a study tip, remember: in a self-join "greatest-per-group" pattern, the join condition finds rows that beat the current row, and the IS NULL filter confirms no such row exists — making the current row the winner.The Employees table contains employee_id, employee_name, and manager_id, where manager_id references another employee. A report should include only employees who have both an immediate manager and a grandmanager, displaying all three names.
Which FROM clause correctly traverses the hierarchy by two levels?
FROM Employees e JOIN Employees m ON e.manager_id = m.employee_id JOIN Employees g ON e.manager_id = g.employee_idFROM Employees e JOIN Employees m ON e.manager_id = m.employee_id JOIN Employees g ON g.manager_id = m.employee_idFROM Employees e JOIN Employees m ON e.manager_id = m.employee_id JOIN Employees g ON m.manager_id = e.employee_idFROM Employees e JOIN Employees m ON e.manager_id = m.employee_id JOIN Employees g ON m.manager_id = g.employee_id (correct answer)Employees table: e (employee), m (manager), and g (grandmanager). The first JOIN correctly links e.manager_id = m.employee_id, establishing that m is e's direct manager. The second JOIN must then climb one more level by finding m's own manager — meaning you need m.manager_id = g.employee_id. That's exactly what D does, making g the grandmanager. The chain reads: e → m → g, each hop moving one level up.
A is wrong because both JOINs use e.manager_id, which links both m and g to the same person — e's direct manager. You never climb to the second level at all.
B reverses the direction of the second JOIN by writing g.manager_id = m.employee_id. This actually makes m the manager of g, meaning g would be lower in the hierarchy than m, not higher — you'd be going sideways or downward, not upward.
C is logically broken: m.manager_id = e.employee_id would make the grandmanager column point back to the employee themselves, creating a nonsensical circular reference.
Strategy tip: When writing self-joins for hierarchies, sketch the chain on paper first — each arrow should point one level up, and the right side of each ON clause should always be employee_id of the next-higher alias.The Reservations table contains reservation_id, room_id, start_date, and end_date. Both endpoint dates are included in a reservation. A report must identify every unordered pair of different reservations that overlap in the same room, returning each pair once.
Which self-join condition correctly identifies the required pairs?
ON r1.room_id = r2.room_id AND r1.reservation_id < r2.reservation_id AND r1.end_date < r2.start_dateON r1.room_id = r2.room_id AND r1.reservation_id <> r2.reservation_id AND r1.start_date <= r2.end_dateON r1.room_id = r2.room_id AND r1.reservation_id < r2.reservation_id AND r1.start_date <= r2.end_date AND r2.start_date <= r1.end_date (correct answer)ON r1.room_id = r2.room_id AND r1.reservation_id < r2.reservation_id AND r1.start_date < r2.start_date AND r1.end_date < r2.end_dateReservations produces every ordered pair, so you need r1.reservation_id < r2.reservation_id to guarantee each unordered pair appears exactly once.
Option C combines both requirements perfectly: r1.room_id = r2.room_id ensures you're comparing the same room, r1.reservation_id < r2.reservation_id eliminates duplicates and self-joins, and the two date conditions r1.start_date <= r2.end_date AND r2.start_date <= r1.end_date correctly capture all forms of overlap — whether one reservation is fully inside the other, they partially overlap, or they share a single boundary date.
Option A uses r1.end_date < r2.start_date, which actually identifies non-overlapping reservations where r1 ends before r2 begins — the opposite of what's needed. Option B correctly uses <> to avoid self-joins but only checks one direction of the overlap condition, missing cases where r2 starts before r1 ends; it would also return duplicate pairs (r1,r2) and (r2,r1). Option D restricts to cases where r1 strictly starts and ends before r2, missing overlaps where the reservations partially interleave or one contains the other.
A reliable study tip: memorize the interval-overlap test as "neither ends before the other begins" — two ranges overlap when Astart≤Bend and Bstart≤Aend. Apply both halves every time.The ProjectAssignments table contains one row per employee-project assignment, with columns employee_id and project_id. A report must list each pair of different employees assigned to the same project. Each unordered pair must appear only once.
Which self-join condition correctly identifies the required employee pairs?
ON a1.project_id = a2.project_id AND a1.employee_id <> a2.employee_idON a1.project_id = a2.project_id AND a1.employee_id <= a2.employee_idON a1.project_id = a2.project_id AND a1.employee_id < a2.employee_id (correct answer)ON a1.project_id <> a2.project_id AND a1.employee_id < a2.employee_ida1.project_id = a2.project_id is always required as the first condition. That narrows it down to A, B, or C immediately — D fails right away because it uses <> on project_id, which would match employees on different projects, the exact opposite of what's needed.
Now the challenge is handling duplicates. Suppose employees 3 and 7 share a project. A naive join produces both (3, 7) and (7, 3) — the same pair twice — plus self-matches like (3, 3). You need a condition on employee_id that eliminates both problems.
Option A uses <>, which removes self-matches but still returns both (3, 7) and (7, 3). Every unordered pair appears twice, violating the requirement.
Option B uses <=, which does reduce duplicates but still allows (3, 3) — a self-match where both sides are equal — since 3 ≤ 3 is true. That means employees can be paired with themselves.
Option C uses <, which is the correct answer. Strict less-than eliminates self-matches (a number is never less than itself) and ensures each unordered pair appears exactly once by enforcing a consistent ordering. Only (3, 7) is produced, never (7, 3).
A useful tip: whenever you need unique, unordered pairs from a self-join, < on the ID column is your go-to pattern — it does double duty by blocking both self-pairings and reversed duplicates.In the Employees table, manager_id references employee_id. Due to a data-entry problem, two different employees may each identify the other as manager. A report must list every such two-employee cycle exactly once and must not report an employee who incorrectly identifies themself as manager.
Which query correctly finds the cycles?
SELECT e.employee_id, m.employee_id FROM Employees e JOIN Employees m ON e.manager_id = m.employee_id WHERE m.manager_id = e.employee_id;SELECT e.employee_id, m.employee_id FROM Employees e JOIN Employees m ON e.manager_id = m.manager_id WHERE e.employee_id < m.employee_id;SELECT e.employee_id, m.employee_id FROM Employees e JOIN Employees m ON e.manager_id = m.employee_id WHERE m.manager_id = e.employee_id AND e.employee_id <> m.employee_id;SELECT e.employee_id, m.employee_id FROM Employees e JOIN Employees m ON e.manager_id = m.employee_id WHERE m.manager_id = e.employee_id AND e.employee_id < m.employee_id; (correct answer)e lists m as their manager, AND m lists e as their manager. That translates to joining on e.manager_id = m.employee_id (finding who e points to) and then filtering with WHERE m.manager_id = e.employee_id (confirming the relationship is mutual). Option D does exactly this, and the additional condition e.employee_id < m.employee_id is the key deduplication trick — it ensures each pair appears only once (e.g., the pair {101, 202} appears as 101 < 202, never as 202 < 101) and simultaneously eliminates self-references since no number is less than itself.
Option A gets the cycle detection right but omits both the self-reference exclusion and the deduplication guard, meaning an employee who incorrectly lists themselves as manager would appear, and every valid cycle would be reported twice.
Option B is fundamentally broken in its JOIN — matching on e.manager_id = m.manager_id finds employees who share the same manager, not employees who point to each other. The e.employee_id < m.employee_id condition here is correct in spirit but applied to the wrong problem.
Option C correctly detects cycles and excludes self-references with e.employee_id <> m.employee_id, but it still reports each cycle twice (once as (A, B) and once as (B, A)).
Study tip: Whenever a self-join can produce reciprocal duplicates, replacing <> with < in your deduplication condition kills two birds with one stone — it removes duplicates AND eliminates self-matches.The Routes table contains one row per directed route, with columns route_id, origin, and destination. There are no duplicate directed routes. A report must list city pairs for which a route exists in both directions, with each city pair shown only once.
Which self-join condition correctly supports the report?
ON r1.origin = r2.destination AND r1.route_id <> r2.route_id AND r1.origin < r1.destinationON r1.origin = r2.destination AND r1.destination = r2.origin AND r1.origin < r1.destination (correct answer)ON r1.origin = r2.destination AND r1.destination = r2.origin AND r1.route_id <> r2.route_idON r1.origin = r2.origin AND r1.destination = r2.destination AND r1.origin < r1.destinationr1.origin = r2.destination AND r1.destination = r2.origin. This says: "r1 goes from City A to City B, and r2 goes from City B back to City A" — exactly the bidirectional relationship you want. To show each city pair only once (avoiding both A→B and B→A appearing as separate result rows), you add a tiebreaker like r1.origin < r1.destination, which arbitrarily picks one ordering per pair. That combination is precisely what B provides, making it the correct answer.
A is close but flawed. It uses r1.origin = r2.destination without the second condition r1.destination = r2.origin, so it doesn't fully constrain the join to true reverse routes — any route ending at r1's origin would qualify, even if the destinations don't match. The route_id inequality also does unnecessary work here.
C has the correct matching logic but uses r1.route_id <> r2.route_id instead of r1.origin < r1.destination to handle duplicates. Since the table has no duplicate directed routes, different route IDs are already guaranteed — this condition removes nothing and still returns each pair twice (once as A→B, once as B→A).
D joins on matching origins and destinations, which would only find identical routes — the opposite of what you want.
As a study tip: in self-join problems, always separate your thinking into two questions — "Does this condition find the right pairs?" and "Does this condition show each pair only once?"The Employees table contains employee_id, employee_name, manager_id, and active. A report must retain every employee but display manager information only when the referenced manager is active. Employees with no manager or with an inactive manager must still appear with a NULL manager name.
Which query satisfies the requirement?
SELECT e.employee_name, m.employee_name FROM Employees e LEFT JOIN Employees m ON e.manager_id = m.employee_id AND m.active = 1; (correct answer)SELECT e.employee_name, m.employee_name FROM Employees e LEFT JOIN Employees m ON e.manager_id = m.employee_id WHERE m.active = 1;SELECT e.employee_name, m.employee_name FROM Employees e LEFT JOIN Employees m ON e.manager_id = m.employee_id AND e.active = 1;SELECT e.employee_name, m.employee_name FROM Employees e LEFT JOIN Employees m ON e.manager_id = m.employee_id WHERE m.active = 1 OR m.employee_id IS NULL;LEFT JOIN in SQL, the most important concept to internalize is the difference between filtering in the ON clause versus the WHERE clause. This distinction becomes critical when you need to preserve all rows from the left table regardless of whether a match exists.
A LEFT JOIN guarantees every row from the left table appears in the result. However, if you add conditions to the WHERE clause, you effectively convert it into an INNER JOIN by discarding rows where the right-side columns are NULL. Conditions placed in the ON clause, by contrast, only control which rows from the right table qualify as a match — unmatched left-side rows still survive with NULL values for all right-side columns.
Option A is correct because m.active = 1 sits inside the ON clause. This means the join only matches a manager row when that manager is active. If the manager is inactive or missing, the employee row still appears — just with a NULL manager name, exactly as required.
Option B places m.active = 1 in the WHERE clause, which eliminates any employee whose manager is inactive or absent, defeating the entire purpose of the LEFT JOIN.
Option C filters on e.active = 1 instead, which silently drops all inactive employees from the result — wrong table, wrong column, wrong behavior.
Option D tries to recover dropped rows by adding OR m.employee_id IS NULL, but this still excludes employees with inactive managers (who have a non-null employee_id).
The key rule to remember: filter the right-side table in ON, not WHERE, whenever you need the left table fully preserved.A Residents table contains three residents in household 10, two residents in household 20, and one resident in household 30. Each resident has a distinct resident_id. The table is self-joined using r1.household_id = r2.household_id AND r1.resident_id < r2.resident_id.
How many rows does the self-join return?
r1.resident_id < r2.resident_id, your job is to count unordered pairs within each group. The < condition eliminates duplicate pairs (like swapping r1 and r2) and self-matches (a resident paired with themselves), giving you exactly the combinations formula: (2n)=2n(n−1)
For each household, apply this formula. Household 10 has 3 residents: (23)=3 pairs. Household 20 has 2 residents: (22)=1 pair. Household 30 has 1 resident: (21)=0 pairs. Summing across all households gives 3+1+0=4 rows total, confirming B is correct.
Answer A is wrong because it ignores household 20's contribution. Even a group of two residents produces one valid pair, so you can't discard smaller households. Answer C describes what would happen if you used != or no inequality at all — both orientations (r1→r2 and r2→r1) would appear, doubling the count to 8. The < condition explicitly prevents that. Answer D confuses the condition with <=, which would allow a resident to match itself, adding one self-pair per resident (6 total residents + 4 cross-pairs = 10). The strict < rules that out entirely.
A quick study tip: whenever you see a self-join, immediately identify whether the join condition uses <, <=, !=, or = — each produces a fundamentally different row count, and exam questions frequently exploit that distinction.The Employees table contains employee_id, employee_name, and manager_id. For top-level employees, manager_id is NULL. Management wants a report containing every employee, including top-level employees, together with the name of the employee's immediate manager when one exists.
Which query produces the required report?
SELECT e.employee_name, m.employee_name AS manager_name FROM Employees e LEFT JOIN Employees m ON e.manager_id = m.employee_id; (correct answer)SELECT e.employee_name, m.employee_name AS manager_name FROM Employees e INNER JOIN Employees m ON e.manager_id = m.employee_id;SELECT e.employee_name, m.employee_name AS manager_name FROM Employees e LEFT JOIN Employees m ON e.employee_id = m.manager_id;SELECT e.employee_name, m.employee_name AS manager_name FROM Employees e RIGHT JOIN Employees m ON e.manager_id = m.employee_id;Employees table where manager_id points back to another employee_id — you're dealing with a self-join. The key questions to ask are: (1) which join type preserves rows with no match, and (2) is the join condition written in the correct direction?
Option A is correct because it uses a LEFT JOIN, which keeps all rows from the left table (e, every employee) even when no matching row exists in the right table (m, the manager). The condition e.manager_id = m.employee_id correctly links each employee's manager reference to the manager's own record. Top-level employees have NULL for manager_id, so they find no match — and LEFT JOIN handles that gracefully by returning NULL for manager_name rather than dropping the row.
Option B uses INNER JOIN, which only returns rows where a match exists on both sides. Top-level employees (with manager_id = NULL) would be silently excluded from the results — violating the requirement to include everyone.
Option C flips the join condition to e.employee_id = m.manager_id, which actually retrieves each employee's subordinates, not their manager. This is a classic direction trap — always trace which column is the "child" and which is the "parent."
Option D uses RIGHT JOIN, which would preserve all rows from the right (manager) side. This risks excluding employees who aren't managers themselves, again breaking the "every employee" requirement.
Your study tip: in self-joins, always alias the table twice (e for employee, m for manager) and verify the join condition direction — the foreign key (manager_id) should match the primary key (employee_id) on the manager side.The ProductRevisions table contains revision numbers 1, 2, and 4 for product P, and revision numbers 2 and 3 for product Q. Revision numbers are unique within each product. Consider this query: SELECT c.product_id, c.revision_no, p.revision_no FROM ProductRevisions c JOIN ProductRevisions p ON c.product_id = p.product_id AND c.revision_no = p.revision_no + 1;
How many rows are returned by the query?
NULL for a missing predecessor.ON condition before counting results. This query is a self-join on ProductRevisions, aliased as c (current) and p (predecessor), matching rows where they share the same product and c.revision_no = p.revision_no + 1 — meaning c is exactly one revision higher than p.
Let's walk through the data. For product P, the revisions are 1, 2, and 4. The only pair where one revision equals another plus one is (2, 1) — revision 4 has no revision 3 to pair with. For product Q, revisions are 2 and 3, giving exactly one pair: (3, 2). That's two total rows, confirming answer A is correct.
Answer B is wrong because it assumes every non-minimum revision produces a row. Revision 4 for product P is skipped because there's no revision 3 — the join condition requires a consecutive predecessor, not just any lower revision. Answer C confuses this with a non-equijoin using < or <=, which would pair every higher revision with all lower ones, producing a many-to-many explosion of rows. Answer D describes the behavior of a LEFT JOIN, which would preserve unmatched rows with NULL for missing predecessors — but this query uses an inner JOIN, which silently drops rows with no match.
As a study strategy, when you see a self-join question, manually enumerate the matching pairs row by row rather than reasoning abstractly. It only takes a moment and prevents the counting errors that make B, C, and D look tempting.