Advanced SQL Joins with Examples: Beyond the Basics
Once the basic INNER JOIN, LEFT JOIN, and other join types are clear,
you can start using joins for more practical problems. Sometimes you need UNION
to combine two result sets. Sometimes one join condition is not enough, so you match on two or
more columns. You may also need to join using a calculated value, such as comparing a discounted
price with a target price. Self-joins are useful for relationships such as employee-manager
hierarchies, while recursive CTEs let you follow those relationships through multiple levels.
You can also join a subquery that has already calculated a summary. This guide covers these
techniques using the same customer and order tables throughout the series.
🎯 Key Takeaways
Joins combine with UNION and CASE cleanly
Multiple ON conditions sharpen the match
Self-joins and recursive CTEs handle hierarchies
Subqueries in FROM pre-aggregate before the join
Indexes on join keys drive performance
16 Interview QuestionsIntermediate to AdvancedReal ExamplesUpdated Oct 7, 2026
KW
Kailas Warade
✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines.
He writes the SQL interview guides at Sankalan Data Tech, focused on
practical, real-world answers.
🏆 Oracle Certified💼 Data Engineer📝 SQL & PL/SQL Guides📍 Pune, India
The first page on SQL joins
covered the basics — INNER, LEFT, RIGHT, FULL OUTER, CROSS, SELF, and NATURAL joins,
along with how NULL values behave and the difference between putting a condition in
ON and WHERE. If you are still getting comfortable with joins,
it is a good place to start.
This page takes things a step further. The focus here is on techniques you will use when
a simple join is not enough — combining joins with set operators, matching tables on
multiple conditions, joining to pre-aggregated subqueries, working with hierarchical
data, and writing joins that perform well when the tables contain a lot of rows.
The examples use the same customer and order tables used throughout this series. That
way, you can run the queries side by side and see exactly what changes when you apply
each technique.
The Sample Data We Use in Every Example
Same two tables as the rest of the series. Keeping the data consistent means the numbers
here will match what you have already seen elsewhere in the tutorial set.
One additional table is used in the recursive CTE example, to model a referral hierarchy:
-- customers has an optional referred_by_id column
-- (extends the customers table above)
-- customer_id | referred_by_id
-- C001 | NULL (no referrer)
-- C002 | C001
-- C003 | NULL (no referrer)
-- C004 | C001
-- C005 | C002
-- C006 | C003
-- C007 | C002
Joins with UNION and INTERSECT
A join combines columns. A UNION or INTERSECT combines rows from
two complete result sets. You can use both together — join inside each query, then combine
the results.
The shape of a join-plus-UNION query
SELECT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered'
UNION
SELECT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Pending';
Each side of the UNION is its own joined query. The first returns customers
who have placed a delivered order. The second returns customers who have placed a pending
order. The UNION stacks them and removes duplicates, so a customer who has
both a delivered and a pending order appears once.
On our data: C001 appears in the delivered set (three delivered orders), C003 appears in
the pending set (order 1004). No customer overlaps, so the output is two rows — Alice Corp
and Charlie Ltd.
UNION ALL instead of UNION
SELECT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered'
UNION ALL
SELECT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Pending';
Same query, different combinator. UNION ALL keeps duplicates, which means
C001 shows up three times — once per delivered order. The result is taller because nothing
is collapsed. Use this when you want every occurrence or when you know the two sets do not
overlap and can skip the deduplication cost.
INTERSECT — rows in both
SELECT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered'
INTERSECT
SELECT c.customer_name
FROM customers c
WHERE c.region = 'North';
That returns only customers who are both in the North region and have placed a delivered
order. On our data: Alice Corp, Charlie Ltd (via the pending check would need to be
delivered — let me trace this correctly). Alice Corp is in North and has delivered orders.
Charlie Ltd is in North but his order is Pending, not Delivered. George Wholesale is in
North but has no orders. So the result is one row — Alice Corp.
The two rules for combining result sets
Both sides must return the same number of columns.
The matching columns must have compatible data types.
The column names come from the first query. If you violate either rule, the query fails
immediately. There is no partial result.
Joining Three or More Tables
Real reports often need data from three or four tables at once. Chaining joins is the way
to get them.
SELECT c.customer_name,
o.order_id,
o.order_amount,
p.payment_method
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN payments p ON o.order_id = p.order_id;
Three tables, joined through two conditions. The first join pairs customers with their
orders. The second pairs those order rows with the payments that came in against them.
Each join sees the result of the previous one, which is why the second ON
clause can reference o.order_id — orders is already part of the
working set.
What to watch for as joins accumulate:
Row multiplication. If any intermediate join produces multiple matches per row, the result grows at each step. A three-table join where every level triples the row count produces twenty-seven rows for every starting row.
Alias discipline. The moment two tables share a column name, you must prefix every reference with the alias. Doing it consistently from the start avoids the "ambiguous column" errors that pop up as you add tables.
Readability. Line up the joins and their ON clauses on separate lines. A three-table join written on one line is technically correct and almost impossible to debug.
Mixing join types in one query
SELECT c.customer_name,
o.order_id,
p.payment_method
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
LEFT JOIN payments p ON o.order_id = p.order_id;
Now the output includes every customer, even those with no orders. And every order, even
those with no payment yet. Each LEFT JOIN is independent — the second one applies to the
result of the first, so if the order is NULL (because there were no orders), the second
join also produces NULL for the payment columns.
Joining on Multiple Conditions
Sometimes a single column is not enough to define the match. You might need both a
department id and a location id to identify the right department row, or you might want to
add a filter to the join itself rather than to a WHERE clause.
AND in the ON clause
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
JOIN orders o
ON c.customer_id = o.customer_id
AND o.order_status = 'Delivered'
AND o.order_amount > 5000;
Three conditions in the ON clause. The join only matches rows where the
customer matches and the order is delivered and the order is above 5,000. On our data that
returns Alice Corp's order 1001 and order 1003, plus Alice Corp's order 1007. Three rows,
all Alice Corp.
Functionally, this behaves like the same conditions written in a WHERE clause after an
INNER JOIN. But on a LEFT JOIN, the two positions behave
completely differently. In the ON clause, the conditions filter only which
right-side rows are eligible to match. In a WHERE clause, the conditions
filter the final result — which drops unmatched left-side rows.
Non-equality join conditions
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
JOIN orders o
ON c.customer_id = o.customer_id
AND o.order_amount > c.credit_limit;
The second condition is not an equality — it compares two values with a greater-than. That
is fine. Any boolean expression is valid in an ON clause. The database
evaluates it for each pair of rows and matches when the whole condition is true.
On our data, no order exceeds its customer's credit limit. The query returns zero rows. That
is a valid answer — sometimes the business condition you are checking simply does not
happen to be true anywhere in your sample data.
OR in the ON clause
SELECT c.customer_name,
o.order_id
FROM customers c
JOIN orders o
ON (c.customer_id = o.customer_id AND o.order_status = 'Delivered')
OR (c.region = o.ship_region);
OR lets you define two alternative matching rules. A row matches if either branch is true.
The parentheses matter — without them, AND binds more tightly than
OR and the condition reads as something different. Any time you mix the two
operators, parenthesise for clarity.
Joining on Computed Fields
The ON clause accepts any expression, not just column names. You can match on
a calculated value — a price after discount, a formatted date, a concatenated name.
Matching on an arithmetic expression
SELECT s.customer_id,
s.order_amount,
d.discount_pct,
s.order_amount * (1 - d.discount_pct) AS final_amount
FROM orders s
JOIN discounts d
ON s.customer_id = d.customer_id
AND s.order_amount * (1 - d.discount_pct) > 5000;
The join condition computes the discounted amount for each pair of rows and checks whether
it exceeds 5,000. Matching rows are returned with the computed final amount in the output.
Matching on a concatenated string
SELECT o.order_id,
c.customer_name
FROM orders o
JOIN customers c
ON o.customer_id = 'C' || SUBSTR(c.customer_id, 2)
AND UPPER(c.region) = 'NORTH';
Computed join conditions are not always a good idea, but the syntax allows them and there
are legitimate uses. Formatting differences between two systems that disagree on customer
id prefixes, for example. Or joining on a date truncated to day when one table stores
full timestamps and the other stores dates.
Performance warning: a join on a computed field usually disables any index
on the underlying columns. ON UPPER(c.customer_name) = UPPER(o.customer_name)
cannot use a standard index on customer_name because the index stores the raw
values, not the uppercased ones. If a computed join is the bottleneck, a function-based
index (Oracle, PostgreSQL) or a persisted computed column (SQL Server) can help.
Joins with Subqueries
A subquery is a query inside a query. It can appear in the WHERE clause, the
FROM clause, or the SELECT list. Each position has its own
behaviour and its own performance profile.
Subquery in the WHERE clause — filtering
SELECT c.customer_name
FROM customers c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders o
WHERE o.order_amount > 10000
);
The subquery returns a list of customer ids who have placed an order above 10,000. The
outer query returns those customers by name. It works, but a join can do the same thing
and usually reads more clearly:
SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_amount > 10000;
The join version needs DISTINCT because a customer with multiple large orders
would appear once per matching order. The IN version deduplicates
automatically because it only checks for presence.
Subquery in the FROM clause — a derived table
SELECT c.customer_name,
t.total_spend
FROM customers c
JOIN (
SELECT customer_id,
SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
The subquery in the FROM clause runs first, producing one row per customer
with their total spend. That result is then joined to the customers table. The database
treats the subquery output as if it were a real table — which is why it is called a
derived table.
This pattern is the standard way to join an aggregated result to a detail table. You cannot
join customers to orders and then aggregate inside the same query without
GROUP BY collapsing everything. Wrapping the aggregation in a subquery keeps
the two operations separate and the result readable.
Subquery in the SELECT clause — a computed column
SELECT c.customer_name,
c.region,
(SELECT COUNT(*)
FROM orders o
WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
The subquery runs once per row of the outer query, returning a single value for that
customer. The result is a table with one row per customer and their order count next to
them.
This works, but it can be slow. A scalar subquery in the SELECT list is
evaluated per row. On a table with a million customers, that is a million subquery
executions. A left join with a grouped subquery is almost always faster:
SELECT c.customer_name,
c.region,
COALESCE(t.order_count, 0) AS order_count
FROM customers c
LEFT JOIN (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
Same output, but the subquery runs once instead of a million times.
Self-Joins and Recursive CTEs
A self-join connects a table to itself using two aliases. A recursive CTE walks a
hierarchy of any depth. Between them they handle every tree structure you will find in a
database.
Self-join for one-level relationships
SELECT c1.customer_name AS customer,
c2.customer_name AS referred_by
FROM customers c1
LEFT JOIN customers c2 ON c1.referred_by_id = c2.customer_id;
Each customer is paired with the customer who referred them. On our data, C002 was referred
by C001, C005 by C002, C007 by C002, C004 by C001, C006 by C003. Customers C001 and C003
have no referrer, so they appear with NULL.
A self-join is enough when the relationship is one level deep. Find everyone who was
referred directly by Alice Corp and you are done. But if you want to know everyone in
Alice Corp's referral chain — direct referrals, their referrals, and so on — you need
something that keeps walking the tree.
Recursive CTE for multi-level hierarchies
WITH RECURSIVE referral_chain AS (
SELECT customer_id,
customer_name,
referred_by_id,
1 AS level
FROM customers
WHERE referred_by_id IS NULL
UNION ALL
SELECT c.customer_id,
c.customer_name,
c.referred_by_id,
rc.level + 1
FROM customers c
JOIN referral_chain rc ON c.referred_by_id = rc.customer_id
)
SELECT * FROM referral_chain ORDER BY level, customer_id;
The CTE starts with the roots — customers who were not referred by anyone. Then the
recursive part joins the CTE back to the customers table to find the next level down. The
process repeats until no new rows are produced.
On our data, the output has three levels. Level 1: C001 and C003. Level 2: C002 and C004
(referred by C001), and C006 (referred by C003). Level 3: C005 and C007 (referred by
C002).
When to use a self-join versus a recursive CTE
Self-join — when the depth is fixed (one level or two). Managers and their direct reports. Categories and their parent categories.
Recursive CTE — when the depth varies. Org charts of unknown height, folder trees, bill of materials where a product is made of sub-products.
Recursion safety: a recursive CTE that loops back on itself — say two
rows that reference each other as parents — will run forever until the database hits its
recursion limit. Most databases cap this at 100 or 1000 iterations and error out. If your
hierarchy could contain cycles, add a depth guard to the recursive part of the query
(for example, WHERE rc.level < 10) to bound the work.
Joins with Window Functions
A window function computes a value across a set of rows related to the current row, without
collapsing the result. Combined with a join, it lets you rank, sum, or compare across
tables while keeping every row visible.
Ranking within a group after a join
SELECT c.customer_name,
o.order_id,
o.order_amount,
RANK() OVER (
PARTITION BY c.customer_id
ORDER BY o.order_amount DESC
) AS order_rank
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name, order_rank;
Each order gets a rank within its customer's set of orders. Alice Corp's highest-value
order gets rank 1, next-highest gets rank 2, and so on. Every row is preserved — the
window function adds a column without collapsing anything.
On our data, Alice Corp has three orders: 12,000 (rank 1), 9,000 (rank 2), 7,500 (rank 3).
Bob Traders has two: 4,500 (rank 1), 3,200 (rank 2). This is the pattern behind "top N per
group" reporting.
Finding the top order per customer
WITH ranked AS (
SELECT c.customer_name,
o.order_id,
o.order_amount,
ROW_NUMBER() OVER (
PARTITION BY c.customer_id
ORDER BY o.order_amount DESC
) AS rn
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
)
SELECT customer_name, order_id, order_amount
FROM ranked
WHERE rn = 1;
Three rows back — Alice Corp's biggest order (12,000), Bob Traders's biggest (4,500), and
Charlie Ltd's only order (22,000). Each customer's top order, exactly.
Window function vs GROUP BY
Both can compute per-customer totals, but they produce different shapes. GROUP BY
collapses rows — one per customer. A window function keeps every row and adds the total
beside it. Choose based on whether you want one row per group or every detail row
annotated.
Joins with CASE Expressions
CASE is the SQL if-then-else. Combined with a join, it lets you transform
the joined output based on rules, or match rows based on conditional logic.
CASE in the SELECT list — transforming the output
SELECT c.customer_name,
o.order_id,
o.order_amount,
CASE
WHEN o.order_amount > 15000 THEN 'High'
WHEN o.order_amount > 5000 THEN 'Medium'
ELSE 'Low'
END AS order_size
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
Every order comes back with a size category next to it. The join brings the customer name
and order amount together; the CASE adds the human-readable label. This is the pattern for
any report where a raw value needs to be bucketed or labelled.
CASE in the ON clause — conditional matching
SELECT c.customer_name,
o.order_id
FROM customers c
JOIN orders o
ON c.customer_id = o.customer_id
AND (
CASE
WHEN c.region = 'North' THEN 'Priority'
ELSE 'Standard'
END
) = o.priority_class;
The join condition computes a category for the customer based on their region, and matches
it against the order's priority class. This is more elaborate than most joins need, but it
shows that CASE works anywhere an expression is allowed — including inside
an ON clause.
Where CASE pays off most: in the SELECT list, converting
codes to labels. Raw status codes like "D" or "P" mean nothing to a reader; CASE turns them
into "Delivered" or "Pending". If you find yourself doing this across multiple queries, it
is worth considering whether the database should store the labels directly or expose them
through a lookup table.
Joins with Aggregate Functions
Aggregates combined with joins are the backbone of reporting. The join brings columns from
multiple tables into the working set, and the aggregate summarises them.
Per-customer aggregation after a join
SELECT c.customer_name,
c.region,
COUNT(o.order_id) AS orders,
SUM(o.order_amount) AS revenue,
AVG(o.order_amount) AS avg_order,
MAX(o.order_amount) AS largest_order
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_name, c.region
ORDER BY revenue DESC NULLS LAST;
One row per customer with their order count, total revenue, average order value, and
largest single order. The LEFT JOIN keeps customers who have no orders — they
come back with zero count and NULL for the amount columns.
Aggregating across a three-table join
SELECT c.region,
COUNT(DISTINCT o.order_id) AS orders,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY c.region;
One row per region, with the number of distinct orders and total revenue. Note the
COUNT(DISTINCT o.order_id) — without the DISTINCT, the count would include
one row per order item, not one row per order. This is a subtle but common bug when joins
multiply rows.
Hiding the join behind a subquery
SELECT c.customer_name,
t.total_spend
FROM customers c
LEFT JOIN (
SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
Same output as the direct join version but computed differently. The subquery aggregates
first, producing one row per customer, and then that result is joined. Some databases
optimise this shape better than a direct join-plus-group-by, because the aggregation can
be done before the join rather than after.
Optimizing Queries with Multiple Joins
Multi-join queries are where performance problems live. Three habits prevent most of them,
and one tool shows you what is actually happening.
Index the join columns
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
Without an index on orders.customer_id, a join from customers to orders has to
scan every order row to find matches. With it, the database looks up the matching orders
directly. On a table with millions of rows, that is the difference between seconds and
minutes.
Every join column in a foreign-key relationship should have an index. If you are unsure
which columns to index, look at the ON clauses of your slowest queries — those
columns are the answer.
Filter early
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
JOIN orders o
ON c.customer_id = o.customer_id
AND o.order_status = 'Delivered'
WHERE c.region = 'North';
Both the ON condition and the WHERE clause narrow the working set before the join produces
intermediate rows. The optimizer usually pushes these filters down automatically, but
writing them explicitly reduces the risk of a bad plan on a particular database or data
distribution.
Select only the columns you need
-- Better
SELECT c.customer_name, o.order_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
-- Worse
SELECT *
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
The second version transfers more data, uses more memory, and cannot benefit from a
covering index that only includes the two columns you actually want. On small tables this
is invisible; on large ones it is measurable.
Use INNER JOIN when the outer isn't needed
INNER JOIN is generally cheaper than OUTER JOIN because it does
not have to track unmatched rows and fill them with NULLs. If you find yourself writing a
LEFT JOIN and then filtering the right-side NULLs away in a WHERE clause, you
probably meant an INNER JOIN to begin with.
Look at the execution plan
EXPLAIN ANALYZE
SELECT c.customer_name, o.order_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE c.region = 'North';
Every database has a version of this. EXPLAIN in MySQL and PostgreSQL,
EXPLAIN PLAN in Oracle, and execution plans in SQL Server through Management
Studio. The plan shows which tables were scanned, which indexes were used, and how many
rows each step processed. If you see "sequential scan" on a large table, an index is
missing. If you see an estimated row count that is very different from the actual, the
statistics are stale and the optimizer is guessing wrong.
The order of the biggest wins: (1) indexes on join columns, (2) filtering
before the join, (3) selecting only needed columns, (4) using INNER JOIN where possible,
(5) reviewing the query plan. Points 1 through 3 solve the vast majority of join performance
problems in the real world.
16 Interview Questions and Answers
These are the advanced join questions that come up once the basics are covered. Every
answer stands on its own — read them in any order, or work through them in sequence to
build up the full picture.
/
🔍
No matching questions found
Try a keyword like UNION, CASE, or recursive.
Yes. You write each query as its own joined SELECT, then combine the two result
sets with a set operator. UNION merges them and removes duplicates.
UNION ALL merges without removing duplicates. INTERSECT
keeps only rows that appear in both result sets.
SELECT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered'
UNION
SELECT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Pending';
Each side of the UNION is a complete joined query. The first finds
customers with delivered orders. The second finds customers with pending orders.
The UNION stacks them and removes duplicates so any customer appearing
in both sets shows up only once.
The output on our data is two rows — Alice Corp (delivered) and Charlie Ltd
(pending). If we used UNION ALL instead, Alice Corp would appear three
times because she has three delivered orders.
Two rules govern this: both SELECTs must return the same number of columns, and the
matching columns must have compatible data types. Column names come from the first
SELECT. Break either rule and the query fails on the spot.
Oracle, PostgreSQL, MySQL, and SQL Server all support this pattern with the same
syntax. The optimizer's ability to push predicates down into each side of the
UNION varies by database — sometimes each side runs independently and the results
are combined, sometimes the database is smarter about it.
NULL in a join means the value is unknown. Since unknown never equals anything —
including another unknown — a join condition that involves a NULL in the join
column never matches that row.
What that means for your result depends on the join type:
INNER JOIN — the row with NULL in the join column is silently dropped. No warning, no error. It just does not appear.
LEFT JOIN — the left row is kept and NULLs appear in all the right-table columns, because no matching right row was found.
RIGHT JOIN — mirror of LEFT, from the right side.
FULL OUTER JOIN — both sides kept, with NULLs filling in wherever the match failed.
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
Customers who have no orders come back with o.order_id as NULL. That is
the intended behaviour of a LEFT JOIN — it is how the report shows every customer,
whether they have ordered or not.
The implication worth remembering: if you add a WHERE clause that references a
right-side column with a non-NULL condition, you silently convert the LEFT JOIN
into an INNER JOIN. Every customer row that had NULL in that right-side column fails
the WHERE test and drops out.
To replace the NULLs in the output with a display value, use
COALESCE(o.order_id, 0) or NVL in Oracle or
ISNULL in SQL Server. Same effect as COALESCE, different names. Stick
with COALESCE if you want portable code.
You chain the joins. Each additional JOIN clause connects to a table
already in the working set, and the database applies them left to right. Each join
sees the result of the previous one.
SELECT c.customer_name,
o.order_id,
o.order_amount,
p.payment_method
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN payments p ON o.order_id = p.order_id;
Three tables, joined through two conditions. The first join pairs customers with
their orders. The second pairs those order rows with their payments. The result has
one row per payment, with the customer name and order details next to it.
The number of tables is not the limit. You can join as many as you need. What
matters is that each join condition references a table already in scope — you
cannot join to a table you have not mentioned yet.
Two things to watch for as joins accumulate:
Row multiplication. If any intermediate join produces multiple matches per row, the result grows. A four-table join where each level doubles the row count produces sixteen rows for every starting row.
Ambiguous column names. Two tables with a column named customer_id require you to prefix every reference with the table alias. Do it consistently from the start.
You can also mix join types. A LEFT JOIN from customers to orders, followed by a
LEFT JOIN from orders to payments, keeps every customer even when they have no
orders and no payments. A single INNER JOIN in the chain would drop all the
unmatched rows from that point forward.
Join the tables first, then apply the window function to the joined result. The
window function sees every column the join produced and computes a value across a
set of related rows without collapsing them.
SELECT c.customer_name,
o.order_id,
o.order_amount,
RANK() OVER (
PARTITION BY c.customer_id
ORDER BY o.order_amount DESC
) AS order_rank
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
Each order gets a rank within its customer's orders. Alice Corp's three orders get
ranks 1, 2, 3 in descending order of amount. Bob Traders's two orders get ranks 1
and 2. Every row is preserved — the window function adds a column, it does not
collapse anything.
The pattern for "top N per group" combines a window function with a subquery or CTE:
WITH ranked AS (
SELECT c.customer_name,
o.order_id,
o.order_amount,
ROW_NUMBER() OVER (
PARTITION BY c.customer_id
ORDER BY o.order_amount DESC
) AS rn
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
)
SELECT customer_name, order_id, order_amount
FROM ranked
WHERE rn = 1;
That returns each customer's single largest order. It is the standard recipe for
"show me the top item per category" reporting, and it works in every major database
that supports window functions (MySQL 8.0+, PostgreSQL, SQL Server, Oracle).
Window functions are more expensive than simple aggregates because they need to sort
the data within each partition. On large tables, the performance depends heavily on
whether the partition and order columns are indexed. When a "top N per group" query
is slow, the fix is usually an index that covers the partition and sort columns.
Yes. You combine conditions with AND or OR inside the
ON clause. Every condition has to be true (for AND) or at least one
has to be true (for OR) for a pair of rows to match.
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
JOIN orders o
ON c.customer_id = o.customer_id
AND o.order_status = 'Delivered'
AND o.order_amount > 5000;
Three conditions in one join. The database pairs each customer with each order and
keeps only the pairs where all three conditions are true. On our data, only Alice
Corp's orders qualify — order 1001, 1003, and 1007.
Multiple join conditions are common when the business rule for matching involves
more than one column. A department identified by both a code and a location. A
price that depends on both a product and a date range. Anywhere a single column is
not enough to say "these two rows belong together".
Non-equality conditions work too. ON o.order_amount > c.credit_limit
matches orders that exceed their customer's credit limit. Non-equi joins are less
common than equality joins but the syntax is identical — the condition is just a
comparison instead of an equality.
On a LEFT JOIN, remember the distinction between ON and
WHERE. A condition in ON filters only which right-side
rows are eligible to match; it does not drop left-side rows that have no match. A
condition in WHERE filters the final result, which removes unmatched
left-side rows entirely.
The optimizer's job is to find an execution plan — a sequence of operations that
gets the answer as cheaply as possible. For a multi-join query there are many
possible plans: which table to read first, which join algorithm to use (nested
loop, hash join, merge join), which indexes to leverage. The optimizer estimates
the cost of each alternative and picks the one it thinks is cheapest.
The estimation uses statistics about the tables — how many rows, how many distinct
values per column, how the data is distributed. When those statistics are stale or
missing, the optimizer guesses wrong and the plan is bad.
Four things you can do to help it pick a good plan:
Index the join columns. Every column in an ON clause should have an index unless the table is tiny. This is the single biggest win.
Filter early. Write WHERE conditions that reduce the row count before the join. Even if the optimizer could push them down automatically, writing them explicitly helps it notice.
Select only the columns you need. SELECT * forces the database to read every column from every table, even the ones you will not use. Listing columns explicitly lets the database use narrower, faster index-only scans.
Use INNER JOIN where possible. INNER JOIN is generally cheaper than OUTER JOIN because it does not track unmatched rows.
To see what the optimizer actually chose, run EXPLAIN in front of the
query. PostgreSQL has EXPLAIN ANALYZE, MySQL has EXPLAIN
and EXPLAIN FORMAT=JSON, Oracle has EXPLAIN PLAN and
DBMS_XPLAN. The plan shows which tables were scanned, which indexes
were used, and how many rows each step processed. A plan that says "sequential
scan" on a large table is almost always a missing index.
One more habit worth building: if a query is slow and adding indexes has not helped,
the row count at each step in the execution plan tells you where the work is
happening. If a step that should produce a hundred rows is producing a million,
something is wrong with the join condition or the statistics.
A subquery is a query inside a query. It can appear in three places, and each
position has its own behaviour and performance profile.
Subquery in WHERE — filtering the rows
SELECT c.customer_name
FROM customers c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders o
WHERE o.order_amount > 10000
);
The inner query returns the list of customers who have placed an order above 10,000.
The outer query filters customers to that list. You could also do this with a join
and DISTINCT, and the optimizer usually treats the two forms similarly.
Subquery in FROM — a derived table
SELECT c.customer_name,
t.total_spend
FROM customers c
JOIN (
SELECT customer_id, SUM(order_amount) AS total_spend
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
The subquery runs first, producing one row per customer with their total spend. That
result is joined to the customers table as if it were a real table. This is the
standard pattern for joining an aggregated result to a detail table — you cannot
join and aggregate at the same level without GROUP BY collapsing everything.
Subquery in SELECT — a computed column
SELECT c.customer_name,
(SELECT COUNT(*)
FROM orders o
WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
A scalar subquery runs once per row of the outer query, producing a single value for
that row. It works, but it can be slow — on a million-row customers table, that is a
million subquery executions.
The faster alternative is a LEFT JOIN against a grouped subquery, which runs the
aggregation once and joins the result:
SELECT c.customer_name,
COALESCE(t.order_count, 0) AS order_count
FROM customers c
LEFT JOIN (
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
Same output, much faster on large tables. If you ever see a correlated subquery in
a SELECT list and the query is slow, this rewrite is the first thing to try.
Six techniques cover the vast majority of join performance work. They are listed
roughly in order of impact.
1. Index the join columns. Every column referenced in an
ON clause should have an index unless the table is tiny. Without an
index, the database has to scan the whole table to find matching rows. With one, it
looks them up directly.
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
2. Filter early. Apply WHERE conditions that reduce
the row count before the join, and put extra matching rules inside the
ON clause so only the interesting rows get combined. On a table with
millions of rows, filtering to ten thousand before joining is the difference between
a plan that works and one that times out.
3. Select only the columns you need.SELECT * forces
the database to read every column from every table. Naming the columns you actually
want lets it use narrower indexes and reduces the amount of data moving through the
pipeline.
4. Use INNER JOIN where possible. INNER JOIN does not have to
track unmatched rows and fill them with NULLs. If you do not need the unmatched
rows, do not use an OUTER JOIN just because it feels safer — it costs more.
5. Consider subqueries or temporary tables. Sometimes pre-filtering
or pre-aggregating into a temporary table and then joining to that is faster than a
single complex query, especially when the same aggregated result is used by multiple
downstream queries.
6. Look at the execution plan. Run EXPLAIN in front of
the query and read the plan. It shows where the time is actually going. Inefficient
joins, unexpected full table scans, and mismatch between estimated and actual row
counts are all visible in the plan. Fixing what the plan identifies is far more
effective than guessing.
A self-join connects a table to itself using two aliases. It handles hierarchies one
level deep. A recursive CTE walks a hierarchy to any depth by repeatedly joining the
result back to the source.
Self-join for direct relationships
SELECT c1.customer_name AS customer,
c2.customer_name AS referred_by
FROM customers c1
LEFT JOIN customers c2 ON c1.referred_by_id = c2.customer_id;
Each customer appears with the customer who referred them. Two aliases
(c1 and c2) are essential — without them the database
cannot tell which customer_name you mean. On our data, C002 is referred
by C001, C005 by C002, and so on. Customers with no referrer come back with NULL.
Recursive CTE for multi-level hierarchies
WITH RECURSIVE referral_chain AS (
SELECT customer_id,
customer_name,
referred_by_id,
1 AS level
FROM customers
WHERE referred_by_id IS NULL
UNION ALL
SELECT c.customer_id,
c.customer_name,
c.referred_by_id,
rc.level + 1
FROM customers c
JOIN referral_chain rc ON c.referred_by_id = rc.customer_id
)
SELECT * FROM referral_chain ORDER BY level, customer_id;
The CTE has two parts. The anchor query selects the root rows — the customers with
no referrer. The recursive part joins the CTE back to the source and produces the
next level. The database keeps running the recursive part until it stops producing
new rows.
On our data, the output has three levels: C001 and C003 at level 1 (no referrer),
C002, C004, C006 at level 2, and C005, C007 at level 3.
When to use which
Use a self-join when the depth is fixed. Managers and their direct reports.
Categories and their parent category. Use a recursive CTE when the depth varies.
Full organisational charts, folder trees, bill of materials.
Recursive CTEs can loop forever if the data contains cycles — two rows that point at
each other as parents. Add a depth guard like WHERE rc.level < 10 in
the recursive part to bound the work. Most databases cap recursion at 100 iterations
by default and error out before anything dangerous happens, but explicit protection
is worth having.
Support varies by database. PostgreSQL, SQL Server, and Oracle support recursive
CTEs natively. MySQL added support in version 8.0. If you are on an older MySQL, you
need a stored procedure or a loop in the application layer instead.
Join first, then aggregate. The join produces the working set of rows from multiple
tables, and GROUP BY with an aggregate function collapses those rows
into per-category summaries.
SELECT c.customer_name,
COUNT(o.order_id) AS order_count,
SUM(o.order_amount) AS total_spend,
AVG(o.order_amount) AS avg_order
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_name
ORDER BY total_spend DESC NULLS LAST;
One row per customer with their order count, total spend, and average order value.
The LEFT JOIN keeps customers who have no orders — they appear with a
count of zero and NULL for the amount columns.
If you want zero instead of NULL for those columns, wrap the aggregates in
COALESCE:
COALESCE(SUM(o.order_amount), 0) AS total_spend
The one subtle bug to watch for: COUNT(*) vs COUNT(column)
on the joined result. If the join multiplies rows — a customer with three orders
produces three joined rows — then COUNT(*) counts the multiplied rows.
Use COUNT(DISTINCT o.order_id) when you actually want the number of
distinct orders, not the number of joined rows.
On our data, using COUNT(*) would give Alice Corp a value of 3 (three
joined rows). Using COUNT(DISTINCT o.order_id) would also give 3,
because each joined row corresponds to a distinct order. The distinction matters
when a join multiplies through two one-to-many relationships — for example customers
joined to orders joined to order items, where one order can have many items.
The ON clause accepts any expression, not just column names. You can
join on a value computed from arithmetic, functions, or string concatenation.
SELECT o.order_id,
o.order_amount,
d.discount_pct,
o.order_amount * (1 - d.discount_pct) AS final_amount
FROM orders o
JOIN discounts d
ON o.customer_id = d.customer_id
AND o.order_amount * (1 - d.discount_pct) > 5000;
The join condition computes the discounted price for each pair of rows and keeps
only the pairs where the computed value exceeds 5,000. Both the join and the SELECT
list use the same expression, so the output is consistent with the filter.
Computed joins turn up whenever two systems disagree on formatting. Customer IDs
stored with different prefixes. Dates stored as timestamps in one table and plain
dates in the other. Values stored with currency symbols embedded.
SELECT o.order_id, c.customer_name
FROM orders o
JOIN customers c
ON o.customer_id = 'C' || SUBSTR(c.customer_id, 2)
AND UPPER(c.region) = 'NORTH';
The performance cost is real. A computed join condition usually disables any index
on the underlying columns, because the index stores raw values, not computed ones.
UPPER(c.customer_name) = UPPER(o.customer_name) cannot use the index
on customer_name — the index would need to be over the uppercased
expression, not the raw column.
For occasionally-run queries, this is fine. For frequently-run queries that compute
the same expression every time, consider a function-based index (Oracle,
PostgreSQL) or a persisted computed column (SQL Server) so the database can index
the computed value directly.
They combine data in fundamentally different ways. A
FULL OUTER JOIN merges columns side by side based on a matching
condition, filling NULLs where one side has no match. A UNION stacks
rows from two queries on top of each other, without any matching logic.
-- FULL OUTER JOIN: side-by-side, one row per matched pair or unmatched row
SELECT c.customer_name, o.order_id
FROM customers c
FULL OUTER JOIN orders o ON c.customer_id = o.customer_id;
-- UNION: stacked, one row per unique value across both queries
SELECT customer_name FROM customers
UNION
SELECT customer_name FROM suppliers;
The FULL OUTER JOIN returns ten rows on our data — all seven customers (three of
them with NULL order columns because they have no orders) plus all seven orders
(all matched, so no orphan order rows). The UNION returns a single column with every
distinct customer name and every distinct supplier name, one row per unique name.
Which you use depends on the question. "Show me every customer alongside their
orders, including customers who have none" is a FULL OUTER JOIN. "Show me every
distinct name in either table, without duplicates" is a UNION.
One practical difference: FULL OUTER JOIN requires a matching condition between two
tables. UNION requires two queries that return the same column shape. They solve
different problems with different requirements.
MySQL is worth noting here. It does not natively support FULL OUTER JOIN.
To emulate it, you write a LEFT JOIN and a RIGHT JOIN and
combine them with UNION:
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
UNION
SELECT c.customer_name, o.order_id
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id;
That produces the same result as a native FULL OUTER JOIN. The UNION handles the
overlap between the two joins.
The two joins differ in what happens to rows where the join column does not match.
INNER JOIN drops them. LEFT JOIN keeps the left-side row and fills the right-side
columns with NULLs.
-- INNER JOIN: only customers who have orders
SELECT c.customer_name, o.order_id
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id;
-- Returns 7 rows (one per order)
-- LEFT JOIN: every customer, with their orders where they exist
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
-- Returns 10 rows (7 orders + 3 customers with no orders)
On our data, the INNER JOIN returns seven rows — every order has a valid customer, so
every order shows up. Customers C005, C006, and C007 do not appear because they have
no orders to match against. The LEFT JOIN returns ten rows — the same seven orders,
plus the three customers who have no orders, with NULL for their
order_id.
The choice between them is really a question of which side is "primary." If you want
a report on orders, use INNER JOIN — you only care about orders that have a customer.
If you want a report on customers, use LEFT JOIN — you want every customer, whether
they have ordered or not.
The NULLs a LEFT JOIN produces in the right-side columns are your signal that a
match did not happen. To count unmatched left rows, add
WHERE o.order_id IS NULL after the LEFT JOIN and the result is the
customers who have never ordered. That pattern — LEFT JOIN plus NULL check on the
right side — is the standard way to find missing relationships.
Yes. A subquery in the FROM clause produces a derived table — an
intermediate result that the outer query joins to as if it were a real table. This
is the standard pattern for combining an aggregated result with detail data.
SELECT c.customer_name,
t.total_spend,
t.order_count
FROM customers c
JOIN (
SELECT customer_id,
SUM(order_amount) AS total_spend,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
) t ON c.customer_id = t.customer_id;
The inner query aggregates orders by customer, producing one row per customer with
their total spend and order count. The outer query joins that result to the
customers table. Each customer appears once, with their aggregated metrics next to
them.
The reason this pattern exists: you cannot join customers to orders and then apply
SUM without a GROUP BY that collapses the join's output.
The subquery keeps the two operations at separate levels — aggregate first, join
second. The result is clearer and often faster, because the aggregation can use an
index on orders.customer_id without involving the customers table at
all.
The derived table must have an alias (here, t). Every database requires
it. Without an alias, the query fails with "subquery in FROM must have an alias" —
which is one of those error messages that never makes sense until someone explains
it once.
You can chain multiple derived tables in a single query. Each one runs its own
aggregation or transformation, and the outer query joins them together. On very
complex reports, this is often clearer than a single query with five joins and a
dozen CASE expressions in the SELECT list.
Joins are usually the most expensive operation in a query, and they are almost always
where a slow report spends its time. The cost depends on four things: how many rows
each table has, which join type you use, whether the join columns are indexed, and
how the optimizer chooses to execute the join.
The biggest single factor is indexes. Without an index on the join column, the
database has to scan the whole table to find matches. With one, it looks them up
directly. On a table with a million rows, that is the difference between a few
hundred milliseconds and a few seconds.
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
The join type matters too. INNER JOIN is generally cheaper than OUTER JOIN because it
does not track unmatched rows. If you do not need the outer rows, do not pay for
them.
Reducing the working set before the join is the third lever. A tight
WHERE clause reduces the number of rows that need to be combined. A
specific column list in the SELECT reduces the amount of data that needs to be moved
through the pipeline.
The fourth thing is knowing what the optimizer actually chose. Run
EXPLAIN in front of the query, or EXPLAIN ANALYZE for the
version with real timings. The plan shows you which tables were scanned, which
indexes were used, and how many rows each step processed. When the plan says
"sequential scan" on a large table, an index is missing. When the estimated row
count is far from the actual, statistics are stale and the optimizer is guessing
wrong.
In practice, the biggest performance wins come from: (1) indexing the join columns,
(2) filtering before the join, (3) selecting only needed columns, (4) using the
simplest join type that solves the problem, and (5) reviewing the execution plan
when the first four have not helped. Points one through three solve most real-world
problems.
The JOIN goes in the FROM clause as usual. The
CASE goes in the SELECT list and transforms the joined
output based on conditions. The two work together cleanly — the join brings columns
into scope, and the CASE decides how to present them.
SELECT c.customer_name,
o.order_id,
o.order_amount,
CASE
WHEN o.order_amount > 15000 THEN 'High'
WHEN o.order_amount > 5000 THEN 'Medium'
ELSE 'Low'
END AS order_size
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
ORDER BY o.order_amount DESC;
Every order comes back with a size category next to it. The join pairs each order
with its customer, and the CASE categorises the order amount into High, Medium, or
Low. On our data, order 1004 (22,000) is High, order 1006 (18,000) is High, order
1001 (12,000) is Medium, and the smaller ones land in Low.
The same CASE pattern can appear in an ON clause if the matching itself
depends on conditional logic:
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o
ON c.customer_id = o.customer_id
AND o.order_status = CASE
WHEN c.region = 'North' THEN 'Delivered'
ELSE 'Shipped'
END;
That join matches North-region customers only to delivered orders, and everyone else
to shipped orders. It is unusual to write joins this way, but the syntax allows it
and there are cases where the business rule is exactly this conditional.
Where CASE pays off most in practice is in the SELECT list, converting raw codes to
readable labels. Status codes like "D" or "P" mean nothing to a report reader; CASE
turns them into "Delivered" or "Pending". This is one of the simplest ways to make a
query's output immediately useful to a non-technical audience.
If you find yourself repeating the same CASE expression across many queries, that is
usually a signal that the database should store the labels directly, or expose them
through a lookup table that you join to. The database schema is often the right
place to solve this problem, not the query layer.
Mistakes I Keep Seeing in Interviews
These come up once the basic join questions are out of the way. Every one is easy to avoid
once you know it is coming.
Mistake one: multiplying rows without realising it. When a join produces
more rows than either input, one side has multiple matches per row. In an aggregation query,
this silently inflates every SUM and COUNT. If your totals look
higher than expected, check whether a one-to-many join is the cause.
Mistake two: writing a FULL OUTER JOIN when you meant a
UNION, or vice versa. The two operations do completely different things. FULL
OUTER JOIN combines columns side by side. UNION stacks rows. If the query runs but the output
has the wrong shape, this is often why.
Mistake three: using a self-join when the hierarchy goes deeper than one
level. A self-join can only reach the parent. If you need grand-parents, great-grand-parents,
or the full path to the root, you need a recursive CTE. Trying to reach deeper with a
self-join produces confusing queries that only handle fixed depth.
Mistake four: joining on a computed value and expecting it to be fast.
ON UPPER(c.customer_name) = UPPER(o.customer_name) disables any index on the
underlying column. If the query is slow, this is why. A function-based index on the computed
expression or a restructured query that avoids the computation is the fix.
Mistake five: wrapping aggregates in COALESCE without thinking
about what the NULL meant. On a LEFT JOIN, a NULL total usually means "this customer has no orders" — which is a different fact from "this customer has orders worth zero." Making the
NULL into 0 is fine for display but erases the distinction. Be deliberate about which one
the report should say.
Mistake six: forgetting to index the foreign-key column. New developers
often add a foreign key constraint and assume the database indexes the column automatically.
Some databases do, some do not. Explicitly creating an index on every column used in an
ON clause is the safe habit.
Follow-Up Questions Interviewers Ask
Getting the first answer right is only half the interview. What usually decides the
outcome is what happens next. Here is what to prepare for on the advanced joins topics.
After "Can you join with UNION?" — expect
"What is the difference between UNION and UNION ALL?"
"What happens if the two sides return a different number of columns?"
"Which columns define the output column names?"
"How does INTERSECT differ from an inner join?"
After "How do you join three tables?" — expect
"How do you decide which table to join first?"
"What happens if an intermediate join produces duplicate rows?"
"Can you mix INNER JOIN and LEFT JOIN in the same query?"
"How does join order affect performance?"
After "How do you handle hierarchical data?" — expect
"What is the difference between a self-join and a recursive CTE?"
"How would you find all descendants of a specific node?"
"What happens if the hierarchy contains a cycle?"
"Which databases support recursive CTEs?"
After "How do you optimize joins?" — expect
"What is the impact of an index on a join column?"
"How would you find out which join in a complex query is slow?"
"What does EXPLAIN show you?"
"When would you use a temporary table instead of a direct join?"
For every question above, prepare a second-level answer. The candidates who pass are not
the ones who memorised the first answer — they are the ones who kept going for another
thirty seconds and said something useful about why it matters.
Quick Quiz
Three questions to check whether the topics above have landed.
1. What is the difference between UNION and UNION ALL?
2. When would you use a recursive CTE instead of a self-join?
3. What is the biggest performance win for a slow join?