Advanced Joins
By the end of this lesson you'll reach beyond INNER and LEFT JOIN to answer the questions they can't: "who has an order?", "who has no order?", "what's each customer's biggest order?". You'll master semi-joins, anti-joins, self-joins, CROSS JOIN , and LATERAL — and the NULL trap that silently breaks NOT IN .
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free SQL course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Every query in this lesson runs against these two small tables. They share one link: orders.customer_id points back to customers.id . Notice Dan has no row in orders — that "missing" customer is the star of the anti-join section.
So Ava (1) has two orders, Ben (2), Cara (3) and Eve (5) have one each, and Dan (4) has none.
A semi-join keeps rows from the left table that have at least one match on the right — but it never adds the right table's columns and never duplicates a row. You're asking "is this customer on the orders list?", not "show me every order".
A semi-join is checking names off a guest list against the RSVP pile. You only care whether someone RSVP'd — not how many cards they sent. The guest appears once, ticked or not.
The same answer reads a little shorter with IN , which checks whether each customer's id appears among the order rows:
Fill in the one blank with the keyword that means "a match exists". The expected result is in the comments so you can check yourself.
An anti-join is the mirror image: keep the left-table rows that have no match on the right. This is how you answer "which customers have never ordered?", "which products never sold?", "which users never logged in?". The cleanest tool is WHERE NOT EXISTS (...) .
Back to the guest list: an anti-join finds everyone who didn't RSVP — the names with an empty spot in the reply pile. In our data that's exactly one person: Dan .
You can write it with NOT IN too — but only if you guard against NULLs in the subquery:
Fill in the blank with the two words that mean "no match exists", so the query returns customers with no orders.
A self-join joins a table to a second copy of itself using two aliases. It's how you compare rows within one table — find pairs, build employee→manager hierarchies, or compare consecutive rows. Here we'll find customers who placed more than one order by pairing each order with another order from the same customer.
A CROSS JOIN pairs every row of one table with every row of another — no ON clause. The output size is the two row counts multiplied . It's invaluable for generating every combination (sizes × colours, dates × products), but a missing join condition silently becomes one and explodes your result.
5 customers × 5 orders = 25 rows. Harmless here — but 1,000 × 1,000 = 1,000,000 rows. Always know why you're cross-joining.
A normal subquery in the FROM clause can't see the outer row. LATERAL removes that wall: it runs the subquery once for each outer row and lets it reference that row's columns — like a for-each loop. It's the cleanest way to get "the top N rows per group", here each customer's single biggest order.
EXISTS only checks whether a row comes back, not what's in it, so the select list is irrelevant. 1 is a conventional placeholder; SELECT * would behave identically.
For correctness with NOT IN , yes — NOT EXISTS avoids the NULL trap. For positive checks, modern optimisers often run IN and EXISTS the same way; EXISTS tends to win on large or correlated subqueries.
Q: When would I use a self-join instead of a window function?
Self-joins shine for pairing rows or walking a hierarchy. For "rank within a group" or "compare to the previous row", window functions (the next lesson) are usually clearer and faster.
PostgreSQL and MySQL 8+ use LATERAL ; SQL Server and Oracle use CROSS APPLY / OUTER APPLY . SQLite has neither — there you'd fall back to a correlated subquery or a window function.
Support is faded now — a brief, a blank canvas, and the expected result in the comments. Write it, then copy it into a playground to confirm.
Practice quiz
What does a semi-join (EXISTS / IN) return for the left table?
- Rows with a match, duplicated per match
- Rows with no match
- Rows that have at least one match, each at most once
- Every combination of rows
Answer: Rows that have at least one match, each at most once. A semi-join keeps left rows that have a match, without duplicating or adding right columns.
Inside WHERE EXISTS (SELECT 1 FROM orders ...), why is the 1 used?
- EXISTS only checks if a row exists, so the select list is irrelevant
- It limits results to one row
- It counts matches
- It is required syntax for the id
Answer: EXISTS only checks if a row exists, so the select list is irrelevant. EXISTS cares only whether a row comes back, not what it contains; 1 is a placeholder.
What is an anti-join used to find?
- Rows that have a match
- Duplicate rows
- The Cartesian product
- Rows with no match on the other table
Answer: Rows with no match on the other table. An anti-join (NOT EXISTS) keeps left rows that have no match — e.g. customers with no orders.
Why can NOT IN return zero rows when its subquery contains a NULL?
- NULL is treated as 0
- id <> NULL is UNKNOWN, never TRUE, so every row is rejected
- NOT IN ignores NULLs safely
- The subquery errors out
Answer: id <> NULL is UNKNOWN, never TRUE, so every row is rejected. A NULL makes id <> NULL UNKNOWN; one stray NULL silently empties the whole result.
Which is the safer default for an anti-join according to the lesson?
- NOT EXISTS
- NOT IN
- LEFT JOIN only
- CROSS JOIN
Answer: NOT EXISTS. NOT EXISTS avoids the NULL trap that breaks NOT IN, so it is the safer default.
In the self-join, what does the condition o1.id < o2.id accomplish?
- Sorts the orders
- Filters by amount
- Stops a row pairing with itself and drops mirror duplicates
- Joins on customer_id
Answer: Stops a row pairing with itself and drops mirror duplicates. o1.id < o2.id removes self-pairs (101,101) and keeps only one of each mirrored pair.
A self-join requires what for the two copies of the table?
- Two databases
- Two distinct aliases so SQL treats them as separate tables
- A UNION
- A GROUP BY
Answer: Two distinct aliases so SQL treats them as separate tables. You give the table two aliases (o1, o2) so it can be joined to itself.
What does a CROSS JOIN produce?
- Rows that match on a key
- Only matching rows
- The left table unchanged
- Every row of A paired with every row of B (a Cartesian product)
Answer: Every row of A paired with every row of B (a Cartesian product). CROSS JOIN has no ON clause and pairs every left row with every right row; sizes multiply.
What does the LATERAL keyword let a FROM-clause subquery do?
- Run only once for the whole query
- Reference the outer row's columns, running once per outer row
- Skip NULL rows
- Sort the result
Answer: Reference the outer row's columns, running once per outer row. LATERAL lets the subquery see the outer row and runs per outer row — ideal for top-N-per-group.
On SQL Server and Oracle, what is the equivalent of CROSS JOIN LATERAL?
- INNER JOIN
- MERGE
- CROSS APPLY
- PIVOT
Answer: CROSS APPLY. SQL Server and Oracle write CROSS APPLY (and OUTER APPLY to keep empty-subquery rows).