Displaying Data from Multiple Tables: SQL Joins Explained
In a real database, the information you need is rarely stored in one table. A customer's
details may be in one table, their orders in another, and their region somewhere else.
JOIN is what brings that data together. It sounds simple, but joins are also
where many SQL mistakes happen — rows get dropped unexpectedly, a WHERE condition
changes the result of a LEFT JOIN, or a filter is placed in the wrong part of the
query and gives you results that look correct but are not. This guide covers all the major
join types using the same customer and order tables throughout, followed by twenty-five
practical questions commonly asked in SQL interviews.
🎯 Key Takeaways
INNER JOIN keeps only matched rows
LEFT / RIGHT JOIN keep one side complete
FULL OUTER keeps both sides
ON filters the match; WHERE filters after
Indexes on join columns drive performance
25 Interview QuestionsBeginner to AdvancedReal ExamplesUpdated Oct 6, 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
Most real databases split related information across different tables. A customer's
name may be in one table, their orders in another, and the products they bought in a
third. When you need all of that information in one result — for example, finding the
top customers by revenue this quarter — you use a JOIN to bring the related
rows together.
A useful way to understand a join is to think about matching rows from two tables. The
database looks at rows from the left and right tables and uses the join condition to
decide which combinations belong in the result. For example, when you join customers
with orders using customer_id, a customer's row is matched with the orders
that belong to that customer.
This also explains why joins can become expensive when the tables are large. A database
with a million customers and a hundred million orders has a lot of data to work through.
The database does not normally build every possible combination first. Its query planner
uses things such as indexes, statistics, and different join algorithms to find matching
rows efficiently. When a join is slow, understanding how those pieces work helps you
figure out where the problem is.
The Full Join Syntax
Every join follows the same shape. The differences between types come down to which rows
survive when the match fails.
SELECT columns
FROM table_a a
[INNER | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS] JOIN table_b b
ON a.column = b.column
[AND other_conditions]
WHERE filter_conditions
GROUP BY grouping_columns
ORDER BY sort_columns;
The rules worth memorising
Always alias the tables. FROM customers c beats FROM customers the moment you add a second table.
Always prefix the columns with the table alias in the SELECT list. c.customer_name is clearer than customer_name when three tables each have a column with that name.
INNER JOIN and plain JOIN are the same thing. LEFT JOIN and LEFT OUTER JOIN are the same thing. The OUTER keyword is optional and adds nothing but letters.
The ON clause is required for every join except CROSS JOIN. Leaving it off by mistake usually produces an enormous cartesian product.
A WHERE clause that references the right-hand table's columns after a LEFT JOIN silently turns it into an INNER JOIN — unless the condition is IS NULL.
The habit that catches the most bugs: when you write a LEFT JOIN, ask yourself whether you are filtering on the right-hand table anywhere. If you filter in WHERE, you have undone the join. Move the filter into the ON clause if you meant to keep every left row, or switch to INNER JOIN if you meant to drop the unmatched ones.
The Order SQL Runs Your Join
You write the query top to bottom. The database evaluates it in a different sequence, and
understanding that sequence is what makes join bugs obvious before they reach production.
1 FROM + JOIN→2 ON→3 WHERE→4 GROUP BY→5 HAVING→6 SELECT→7 ORDER BY
Here is what each step means for the join specifically:
FROM + JOIN — the engine identifies the tables involved and starts building the working set of rows.
ON — the join condition is applied. Rows that do not satisfy it are dropped or kept depending on the join type. For a LEFT JOIN, unmatched left rows survive with NULLs for the right-side columns. For an INNER JOIN, they simply disappear.
WHERE — runs after the join. Rows the join produced are then filtered by the WHERE condition. This is why filtering the right-hand table in WHERE kills a LEFT JOIN — the NULL rows the join produced fail the WHERE test.
GROUP BY, HAVING, SELECT, ORDER BY — all downstream of the join. They see the joined result as if it were a single table.
From a debugging session worth remembering: A colleague once added a filter to a report and watched the row count drop from 5,000 to 1,200. The filter was WHERE o.order_status = 'Delivered' on a LEFT JOIN from customers to orders. She expected the report to keep every customer and show their delivered orders, but what she got was only the customers who actually had a delivered order — everyone else had been silently removed because their o.order_status was NULL and failed the WHERE test. Moving the condition into the ON clause fixed it in one line.
The Sample Data We Use in Every Example
Every example below runs against the same two tables. Keeping the data consistent means
you can compare results side by side and see exactly how each join changes the output.
Set up the same rows and the numbers will line up.
-- customers table
-- customer_id | customer_name | region | credit_limit
-- C001 | Alice Corp | North | 50000
-- C002 | Bob Traders | South | 25000
-- C003 | Charlie Ltd | North | 75000
-- C004 | Diana Retail | West | 40000
-- C005 | Ethan Supplies | South | 55000
-- C006 | Fiona Mart | West | NULL
-- C007 | George Wholesale | North | 80000
Five things in this dataset are deliberate. They show up in real join scenarios almost
every time.
C005, C006, and C007 have no orders. A LEFT JOIN from customers to orders will show them; an INNER JOIN will not.
C001 appears three times in orders. Joining with duplicates produces multiple rows per customer, which is the pattern behind most join-count surprises.
C004 has exactly one order — useful for showing side-by-side differences between join types.
Every order has a valid customer_id. That means an INNER JOIN from orders to customers matches all seven rows. A FULL OUTER JOIN would produce the same seven rows, with no orphan orders.
Fiona Mart has a NULL credit_limit, which is the column most often used to demonstrate NULL behaviour in joins.
INNER JOIN — The Strict Join
INNER JOIN is the default join, the one you get when you write plain
JOIN with no modifier. It keeps only the rows where the join condition
matches on both sides. Everything else disappears.
Basic INNER JOIN
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name, o.order_id;
Seven rows come back — one per order, each showing the customer who placed it. Because
every order has a valid customer, all seven match. If an order had an orphan
customer_id, it would drop out.
The output is ordered by customer name, so Alice Corp's three orders appear together,
followed by Bob Traders's two, then Charlie Ltd's one and Diana Retail's one. Ethan
Supplies, Fiona Mart, and George Wholesale do not appear at all — they have no orders to
match against.
INNER JOIN with additional conditions
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
INNER JOIN orders o
ON c.customer_id = o.customer_id
AND o.order_amount > 10000;
Three rows now: order 1001, order 1004, and order 1006 — every order above 10,000, paired
with its customer. The extra condition in the ON clause narrows which orders
are eligible to match. It behaves the same as an INNER JOIN followed by a WHERE
filter, but keeping the condition in the ON clause makes the intent clearer.
The reason INNER JOIN is the default: in most reporting, you want to see
only the rows that have data on both sides. A customer with no orders should not appear
in an order report, and an order with no valid customer should not appear in a customer
report. INNER JOIN's strictness matches the reader's expectation most of the time.
LEFT, RIGHT, and FULL OUTER Joins
Outer joins exist because sometimes you need to see rows even when the match fails. That
is the whole reason they exist — the word "outer" means "keep the rows that do not
match."
LEFT JOIN — the left side stays complete
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name, o.order_id;
Now ten rows come back. Every customer appears at least once. Alice Corp still shows three
order rows, Bob Traders two, Charlie Ltd and Diana Retail one each. Ethan Supplies, Fiona
Mart, and George Wholesale each show up once with NULL in the order columns —
because the join kept the customer row and had nothing on the right side to fill in.
The classic use case is finding customers who have not ordered. Combine a
LEFT JOIN with a filter on the right-hand NULL:
SELECT c.customer_name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
Three rows: Ethan Supplies, Fiona Mart, George Wholesale. These are the customers with no
orders. The IS NULL check is what tells you the join found no match on the
right side.
RIGHT JOIN — the mirror image
SELECT c.customer_name,
o.order_id
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id;
Every order appears. If an order had no matching customer, the customer columns would be
NULL, but on our data every order has a valid customer, so all seven rows match. Most
teams pick one orientation — usually LEFT — and reorder the tables rather than use RIGHT.
That habit keeps queries reading consistently from left to right.
FULL OUTER JOIN — everything from both sides
SELECT c.customer_name,
o.order_id,
o.order_amount
FROM customers c
FULL OUTER JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name NULLS LAST;
Ten rows on our data. All seven customers appear — the three without orders show NULLs for
the order columns. All seven orders appear — matched to their customers where possible.
The total count comes out to ten because three customers have no orders and produce one
row each, while the other four customers produce seven rows between them (C001 three,
C002 two, C003 one, C004 one).
Full outer joins are less common in day-to-day work, but they are the right tool when you
are reconciling two datasets. "Show me everything from both sides so I can see which rows
line up and which do not." Note that MySQL does not support FULL OUTER JOIN
directly — you emulate it by combining a LEFT JOIN and a
RIGHT JOIN with UNION.
Side by side on our data
Join type
Rows returned
What gets dropped
INNER JOIN
7
Customers with no orders
LEFT JOIN (customers → orders)
10
Nothing on the customer side
RIGHT JOIN (customers → orders)
7
Nothing on the order side
FULL OUTER JOIN
10
Nothing on either side
CROSS JOIN and SELF JOIN
Two join types fall outside the inner/outer family. One produces every possible
combination. The other lets a table join to itself.
CROSS JOIN — every combination
SELECT c.customer_name,
o.order_id
FROM customers c
CROSS JOIN orders o;
Seven customers times seven orders equals forty-nine rows. Every customer paired with
every order, including combinations that do not correspond to anything real. There is no
matching condition because there is nothing to match — the operation is just
multiplication of row counts.
You almost never want a CROSS JOIN in a production query. Where it earns its keep is in
generating combinations for test data — creating a grid of every customer against every
month, for example, so you can plot a chart with a fixed set of x-axis values. Or in
scheduling, where you need every employee paired with every shift template as a starting
point.
SELF JOIN — a table that joins to itself
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;
Two aliases for the same table. The database treats them as if they were two separate
tables, which lets you compare rows within one table. In this example, each customer is
paired with the customer who referred them.
Self-joins are how you model hierarchies. Employee-manager relationships, category trees,
referral chains — any structure where a row points at another row in the same table. The
two aliases are not optional here; without them, the database has no way to tell which
column you mean.
A pattern worth remembering: a self-join with LEFT JOIN is
the standard way to find the top of a hierarchy. If c2.customer_id is NULL
after the join, then c1 has no parent — it is a root node. The same trick
finds employees with no manager, categories with no parent, and threads with no
reply-to.
NATURAL JOIN and USING
Both of these are shortcuts. They exist to save typing when the join condition is obvious
from the column names. The trade-off is that they hide the join condition, which makes
the query shorter but sometimes harder to debug.
NATURAL JOIN — automatic matching on shared column names
SELECT *
FROM customers
NATURAL JOIN orders;
The database looks at both tables, finds every column that has the same name and a
compatible type, and joins on all of them. On our tables that is just
customer_id, so this behaves like an INNER JOIN on that column.
The danger: if the two tables happen to share a column name that is not actually the join
key, NATURAL JOIN uses it anyway. Suppose both tables had a column called
created_on that stores unrelated timestamps. NATURAL JOIN would join on both
customer_id and created_on, and you would silently get the wrong
result. Most teams avoid NATURAL JOIN in production for exactly this reason.
USING — the explicit version of the same shortcut
SELECT *
FROM customers c
JOIN orders o USING (customer_id);
USING tells the database which column to match on, but saves you from writing the
ON a.column = b.column part. The column is named once instead of twice. It
is safer than NATURAL JOIN because you are still explicit about the join column — you
just do not have to type both sides.
One nice side-effect of USING: the joined column appears only once in the output, not
twice. With an ON clause, you would see both c.customer_id and
o.customer_id in the result. With USING, you get one. Same for NATURAL JOIN,
but for reasons that are less obvious.
How NULL Behaves in Joins
NULL is where joins get subtle. The behaviour is consistent across every database, but it
is not intuitive the first time you hit it.
The one rule that explains everything
NULL does not equal anything. Not another NULL, not zero, not an empty string. The
expression NULL = NULL evaluates to unknown, which the database treats as
false when filtering.
This means a join condition that involves NULL in the join column never matches. If a
customer row has a NULL customer_id (which would be unusual, but possible
in a staging table), no order would ever join to it. The row would appear in a
LEFT JOIN output as a lone row with NULL order columns, or vanish entirely
from an INNER JOIN.
NULL in the join column versus NULL in the output
Two different things. NULL in the join column prevents matching. NULL in the output
columns is what an outer join produces when no match is found. They look the same in a
result set but they come from very different places.
SELECT c.customer_name,
o.order_id,
o.order_status
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
Customers with no orders appear with o.order_id and
o.order_status as NULL. That NULL is not because the orders table has NULLs
in those columns — it is because the join had no matching order to pull data from.
Handling the NULLs a join produces
SELECT c.customer_name,
COALESCE(o.order_id, 0) AS order_id,
COALESCE(o.order_status, 'None') AS status
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
COALESCE gives the NULL a display value. Order id becomes 0. Status becomes the string
"None". The report reads more cleanly, and downstream code does not have to special-case
the NULLs.
ON vs WHERE — The Distinction That Matters
This is the interview question that separates people who memorised joins from people who
understand them. The two clauses look similar, both filter, and both appear in the same
query. But they filter at different stages, and the difference shows up the moment you
use an outer join.
With an INNER JOIN, they are usually equivalent
-- Version A: condition in ON
SELECT c.customer_name, o.order_id
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id AND o.order_status = 'Delivered';
-- Version B: condition in WHERE
SELECT c.customer_name, o.order_id
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered';
Both queries return the same four rows. On an INNER JOIN, the ON and WHERE filters are
functionally interchangeable because both remove rows that would not have survived
anyway.
With an outer join, they behave completely differently
-- Version C: filter in ON
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id AND o.order_status = 'Delivered';
-- Version D: filter in WHERE
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered';
Version C returns every customer. The LEFT JOIN keeps all seven customer
rows, and the o.order_status = 'Delivered' condition only decides which
orders are eligible to attach. Customers with no delivered orders appear with NULL in the
order columns. Total: seven rows (or more, if a customer has multiple delivered orders).
Version D returns only four rows. The LEFT JOIN first produces seven rows
(with NULLs for the three customers without orders), and then the WHERE
clause filters out every row where o.order_status is not "Delivered" — which
includes every row where it is NULL. The three customers with no orders drop out. The
query effectively becomes an INNER JOIN.
The mistake this produces in production: a report that should show every
customer with their delivered activity silently loses the customers who have no delivered
orders. The filter was added to the WHERE clause because the developer was
thinking about "showing delivered orders" rather than "keeping every customer". Moving
the condition into the ON clause is the fix, but the bug is easy to miss
because the row count still looks plausible.
Joining More Than Two Tables
You can join as many tables as you need. Each additional JOIN clause
connects to a table that is already part of the working set.
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. Each join sees the result
of the previous one.
The order of joins matters for readability but not for the final result, as long as the
conditions are correct. The database's optimizer is free to reorder them internally if
it finds a faster plan. What matters to you is that each condition references a table
that is already in scope — you cannot join to a table you have not mentioned yet.
Two things to watch for as you add tables:
Row multiplication. If any of the intermediate joins produce multiple matches per row, the result grows at each step. A three-table join where each level triples the row count ends up with 27x the original.
Ambiguous column names. The moment two tables have a column with the same name, you have to prefix every reference with the alias. Getting into the habit early saves errors.
Join Performance on Large Tables
Joins are usually the most expensive operation in a query, and they are almost always
where a slow report spends its time. Understanding what makes them fast or slow is worth
the investment.
Indexes on the join columns are the biggest win
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
Without an index on orders.customer_id, the database has to read every row
of the orders table to find matches for each customer. That is a full table scan per
customer, or a hash join that reads the whole table once. With the index, it can look up
the matching orders directly. On a table with millions of rows, that difference is often
the line between a query that takes a second and one that takes a minute.
INNER JOIN is generally faster than OUTER JOIN
An INNER JOIN only has to track matched rows. An OUTER JOIN has
to additionally track which left-side rows have not matched, so it can emit them with
NULLs. That extra bookkeeping matters on large tables. If you do not need the unmatched
rows, do not use an outer join just because it feels safer — it costs.
Filter before you join when you can
SELECT c.customer_name, o.order_id
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered'
AND o.order_amount > 5000;
The optimizer usually pushes these filters down and applies them early, but writing them
explicitly does not hurt. On a table with a hundred million orders, filtering to the ten
thousand delivered ones before joining is the difference between a plan that works and
one that runs out of memory.
Reduce the number of tables when you can
Every additional join multiplies the potential row count. If a report only needs two
tables, do not include a third. If a subquery can return the same information as a join,
and the subquery is cheaper, use the subquery. The instinct to "join everything just in
case" is what turns a fast report into a slow one.
How to check whether your join is fast: run EXPLAIN (or
EXPLAIN ANALYZE) in front of the query. It shows you the plan the database
chose — which tables it scanned, which indexes it used, which join algorithms it picked.
A plan that says "sequential scan" on a large table is usually a sign that an index is
missing. A plan that says "nested loop" with a huge outer row count is usually a sign
that the optimizer underestimated the size of an intermediate result.
25 Interview Questions and Answers
These are the questions that come up most often in interviews and training sessions.
Every answer stands on its own — read them in any order. If you are preparing for an
interview, read straight through once, then use the search box and the quiz at the bottom
to test yourself.
/
🔍
No matching questions found
Try a keyword like LEFT JOIN, NULL, or CROSS JOIN.
Every join answers the same question differently: what happens when a row on one
side has no matching partner on the other?
INNER JOIN — the row is dropped. Only matched rows survive.
LEFT JOIN — the left-side row survives, with NULLs filling the right-side columns.
RIGHT JOIN — the right-side row survives, with NULLs filling the left-side columns.
FULL OUTER JOIN — both sides survive. Matched rows appear once, unmatched rows appear with NULLs on the missing side.
-- 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;
-- 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;
On our data, the first query returns seven rows (all the orders), the second returns
ten rows (all the customers, with NULLs for the three who have no orders).
The four joins are supported the same way in SQL Server, PostgreSQL, Oracle, and
every other major database, with one exception: MySQL does not have a native
FULL OUTER JOIN. You emulate it there by combining a
LEFT JOIN and a RIGHT JOIN with UNION.
NULL means "unknown." That single fact explains everything about how NULLs behave
in a join. The expression NULL = NULL is not true — it is unknown, and
the database treats unknown as false when filtering. So a join condition that
involves NULL in the join column never matches.
The practical consequence depends on the join type:
An INNER JOIN silently drops any row whose join column is NULL, because nothing matches against it.
A LEFT JOIN keeps the left row and fills the right columns with NULL.
A RIGHT JOIN does the mirror.
A FULL OUTER JOIN keeps everything, with NULLs on whichever side did not match.
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
NULL is not because the orders table has NULL order ids — it is because the join
had no matching order to attach.
If you want to replace those NULLs with something readable, wrap the column in
COALESCE(o.order_id, 0). That keeps the row count the same but makes
the output cleaner.
A self-join connects a table to itself. You use two different aliases so the
database treats the one table as if it were two, and you can then compare rows
within that single table.
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 row is paired with the customer who referred them. The aliases
c1 and c2 refer to the same table but in different roles.
Without them, the query would not run — the database could not tell which
customer_name you meant.
Self-joins are how you model any hierarchy stored in a single table. Employees and
their managers. Categories and their parent categories. Threads and the post they
reply to. The two most common uses are finding the parent of each row, and finding
the rows that have no parent (the root nodes).
The pattern for finding roots is a LEFT JOIN plus a NULL check on the
parent side:
SELECT c1.customer_name
FROM customers c1
LEFT JOIN customers c2 ON c1.referred_by_id = c2.customer_id
WHERE c2.customer_id IS NULL;
That returns every customer who was not referred by anyone — the ones at the top of
the referral chain.
A CROSS JOIN pairs every row from the first table with every row from
the second. It has no ON clause because there is no matching rule —
every combination is valid by definition.
SELECT c.customer_name, o.order_id
FROM customers c
CROSS JOIN orders o;
Seven customers times seven orders equals forty-nine rows. Every customer paired
with every order, including pairs that do not correspond to any real transaction.
The other joins all have a matching condition. INNER JOIN keeps only
matched pairs. LEFT JOIN keeps all left rows. RIGHT JOIN
keeps all right rows. FULL OUTER JOIN keeps everything.
CROSS JOIN has no filter at all — it is the cartesian product, which
is what joins conceptually start from before any filtering.
You almost never write a CROSS JOIN in a business query. The two
legitimate uses are generating test data (creating a grid of every combination)
and producing a report where every entity needs to be checked against every other
entity. On large tables, a CROSS JOIN can produce an unmanageable
number of rows — a thousand-row table joined with itself produces a million rows.
None. They are exactly the same operation. OUTER is an optional
keyword that some developers include for clarity and others leave off.
-- These two queries are identical
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
SELECT c.customer_name, o.order_id
FROM customers c
LEFT OUTER JOIN orders o ON c.customer_id = o.customer_id;
Every major database — MySQL, PostgreSQL, SQL Server, Oracle — treats the two
spellings the same way. The OUTER keyword exists in the SQL standard
for historical reasons, but it does not change the behaviour.
On a team, pick one spelling and stick with it. Mixed styles in the same codebase
are a small readability tax that adds up over time. Both are fine; the important
thing is that everyone knows what the style is.
Join first, then group. The join produces the working set of rows, and
GROUP BY collapses those rows into categories with an aggregate
function applied to each.
SELECT c.customer_name,
COUNT(o.order_id) AS order_count,
SUM(o.order_amount) AS total_spend
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 and total spend. Using
LEFT JOIN here means customers with no orders still appear — their
count is 0 and their total is NULL (or 0 if you wrap it in
COALESCE). Using INNER JOIN instead would drop the
customers with no orders entirely.
The result on our data: Alice Corp has 3 orders totalling 28,500. Bob Traders has
2 totalling 7,700. Charlie Ltd and Diana Retail each have one. Ethan Supplies,
Fiona Mart, and George Wholesale come back with zero orders.
This pattern is the backbone of almost every customer summary report. The join
brings the customer's name and orders together, and the aggregation collapses each
customer's orders into a single line.
NATURAL JOIN figures out the join condition on its own. It looks at
both tables, finds every column that has the same name and a compatible type, and
joins on all of them.
SELECT *
FROM customers
NATURAL JOIN orders;
On our two tables, the only shared column name is customer_id, so this
behaves like an INNER JOIN on that column. The query returns seven
rows — the same as an inner join.
The nice part is that you do not have to write the ON condition. The
dangerous part is that the join condition is invisible. If the tables happen to
share a column name that is not actually the join key — say both have a
created_on column for unrelated reasons — the NATURAL JOIN will use
it anyway and silently produce the wrong result.
The consensus in most teams is: skip NATURAL JOIN in production. The readability
gain is small, and the surprise factor when it goes wrong is large. An
INNER JOIN with an explicit ON clause is barely more
typing and much more predictable. If you want to save keystrokes, USING
is the safer shortcut.
USING is a shorthand for the ON clause when the join
column has the same name in both tables.
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o USING (customer_id);
That is the same as writing
ON c.customer_id = o.customer_id, just shorter. The column name is
mentioned once instead of twice.
The behavior is identical to an ON clause with one small difference:
the joined column appears only once in the output. With ON, you would
see both c.customer_id and o.customer_id in the result.
With USING, you get one. That cleaner output is one of the small
advantages — no need to pick which one to show when both are identical.
USING is safer than NATURAL JOIN because you still
explicitly name the column. It saves the typing of writing the condition twice
without hiding the join key from the reader. Every major database supports it.
RIGHT OUTER JOIN keeps every row from the right table and fills in
NULLs on the left side when there is no match. It is the mirror of
LEFT OUTER JOIN.
SELECT c.customer_name, o.order_id
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id;
Every order comes back, with its customer where one exists. If an order had no
valid customer, the customer columns would be NULL. On our data every order has a
customer, so all seven rows match.
Functionally, RIGHT JOIN with the tables in one order is identical to
LEFT JOIN with the tables swapped:
-- Same result as the RIGHT JOIN above
SELECT c.customer_name, o.order_id
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id;
Because of that symmetry, most teams standardize on LEFT JOIN and
reorder their tables when needed. It keeps the join logic reading consistently
top-to-bottom and reduces the mental tax of switching between LEFT and RIGHT.
Pair a join with GROUP BY and an aggregate function, and you get
per-group summaries that span multiple tables. This is the shape of almost every
reporting query.
SELECT 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
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.region
ORDER BY revenue DESC;
One row per region, with the total orders, revenue, average order value, and
largest single order. The join brings customer region and order amount into the
same working set, and the aggregate functions summarise it per region.
On our data: North region has 3 customers who placed 5 orders between them. South
has 2 customers with 2 orders. West has 2 customers with 1 order.
The choice between JOIN and LEFT JOIN matters here. With
a plain JOIN, regions that have customers but no orders would not
appear at all. With LEFT JOIN, they would appear with zero counts.
Which one you want depends on whether "no orders" is a meaningful answer or not.
Most joins match on equality, but the ON clause accepts any boolean
condition. Using >, <, >=, or
<= is called a non-equi join, and it lets you connect rows based
on ranges or comparisons.
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;
That query returns the orders whose amount exceeds the customer's credit limit. It
is a business-realistic example of what non-equi joins are for — flagging cases
where one value has exceeded another.
On our data, order 1004 (22,000) exceeds Charlie Ltd's credit limit of 75,000?
No. Order 1004 is 22,000 and Charlie's limit is 75,000, so it does not match. What
does match is orders over the credit limit. Only C002 with a limit of 25,000 has
any order exceeding it — order 1002 at 4,500 is under, order 1005 at 3,200 is
under. So on our data the non-equi join returns zero rows, which is a valid answer
and a reminder that sometimes a join legitimately returns nothing.
The other common use is temporal joins — pairing an event with the state of some
other table at the time of the event. A price history table joined to an orders
table on order_date BETWEEN price_start AND price_end is a classic
example.
They do completely different things, and confusing them leads to queries that run
without error but produce the wrong shape of data.
JOIN combines columns from two tables side by side. The result is wider — more columns.
UNION stacks rows from two queries on top of each other. The result is taller — more rows.
-- JOIN: side by side, wider result
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
-- UNION: stacked, taller result
SELECT customer_name FROM customers
UNION
SELECT customer_name FROM suppliers;
The JOIN produces one row per order with two columns — customer name and order id.
The UNION produces a single column containing every distinct name from both tables,
one name per row.
UNION has one subtlety worth knowing: it removes duplicates by default. If a name
appears in both tables, UNION returns it once. UNION ALL keeps every
occurrence. UNION ALL is faster because the database does not have to sort and
deduplicate, so it is worth using when you know the data has no duplicates or you
specifically want them.
One more thing: for UNION to work, both SELECT statements must return the same
number of columns with compatible types. Column names come from the first SELECT.
If you violate either rule, the query fails.
Duplicates after a join happen when one side of the join has multiple matching
rows. If a customer has three orders, joining that customer to the orders table
produces three rows for them. That is expected behaviour, not a bug — the join is
doing exactly what you asked.
But if you only want one row per customer, you have two tools. The first is
DISTINCT:
SELECT DISTINCT c.customer_name, c.region
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
That returns one row per unique combination of customer name and region. The join
still produces three rows for Alice Corp, but DISTINCT collapses them into one
because the two selected columns are the same across all three.
The second tool is GROUP BY, which is better when you want to
actually summarise the duplicates rather than just hide them:
SELECT c.customer_name, COUNT(*) AS orders
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_name;
That returns one row per customer, with the count of their orders instead of the
individual order rows. Same number of rows, but the duplicates have been converted
into a useful summary.
One thing to know: DISTINCT is not free. The database has to sort or
hash the result to find the duplicates, which costs time on large tables. Reach for
it when you actually need it, not as a reflex.
EXISTS is designed for a specific job: checking whether a matching
row exists. It does not retrieve data. The moment it finds one match, it stops
searching. That makes it potentially much faster than a join for existence checks
on large tables.
SELECT c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
That returns every customer who has placed at least one order. But it does not
return any columns from the orders table — the subquery produces a single value per
customer (true or false), and the outer query decides whether to keep the row.
Compare that to a join for the same purpose:
SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
Same output, but the join has to actually find every matching order, combine the
columns, and then DISTINCT has to deduplicate the result. EXISTS gets away with
finding just one order per customer and stopping.
The rule of thumb: use EXISTS when you need to know whether something
exists. Use a JOIN when you need to see data from the other table.
They are not interchangeable — they answer different questions.
You chain the joins. Each additional JOIN clause connects to a table
that is already part of the working set, through a condition that references a
table already in scope.
SELECT c.customer_name,
o.order_id,
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. Each join sees
the result of the previous one, which is why the second ON clause can reference
o.order_id — orders is already in the working set.
The number of tables is not the limit. You can join as many as you need, and the
syntax scales cleanly. What scales less cleanly is the performance and the cognitive
load — a five-table join is harder to read than a two-table join and much harder to
debug when the output is wrong.
Two things to watch for as joins accumulate:
Row multiplication. If any join in the chain produces multiple matches per row, the result grows. A three-table join where each level doubles the row count produces eight rows from a starting point of one.
Alias discipline. With many tables, columns with the same name become ambiguous. Prefixing every column with its table alias is not optional — it is how you keep the query understandable.
It depends on what the WHERE clause checks. If it references the
right-hand table's columns with a normal comparison, the LEFT JOIN
quietly becomes an INNER JOIN. If it checks for NULL on the
right-hand side, it becomes a way to find unmatched rows.
-- Case A: WHERE kills the LEFT JOIN
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_status = 'Delivered';
That query returns only four rows — the customers who actually have delivered
orders. The three customers without any orders had NULL in o.order_status
after the join, and NULL fails the equality test. So they were filtered out. The
LEFT JOIN kept them initially, but the WHERE clause removed them afterwards.
-- Case B: WHERE finds unmatched rows
SELECT c.customer_name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.customer_id IS NULL;
That returns three customers — Ethan Supplies, Fiona Mart, and George Wholesale.
The IS NULL check is specifically looking for rows the join could
not match, which is the whole point of a LEFT JOIN combined with this filter.
The lesson: WHERE runs after the join. Any condition that would exclude NULLs will
undo the LEFT JOIN's work. If you want to keep the LEFT JOIN behaviour while still
filtering on the right-hand table, move the condition into the ON
clause.
The answer depends on what you want to happen to those rows. If you are fine with
them disappearing, use INNER JOIN. If you need to see them, use an
outer join.
-- Every customer, matched or not
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
Unmatched customers appear with NULL in the order columns. If you want to make the
output cleaner, wrap the columns in COALESCE:
SELECT c.customer_name,
COALESCE(o.order_id, 0) AS order_id,
COALESCE(o.order_status, 'None') AS status
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
Now instead of NULL, order id is 0 and status is the literal string "None". The
report reads more naturally, and downstream code that consumes the output does not
have to special-case NULLs.
The choice of LEFT versus RIGHT is really a question of
which table is the "primary" one for your report. If customers are the primary
entity, use LEFT JOIN with customers on the left. If orders are, flip
the tables and either use LEFT JOIN again or RIGHT JOIN.
Functionally equivalent; pick one style for consistency.
Joins are usually the most expensive operation in a query, and on large tables they
are almost always where the time goes. Three factors decide how much they cost.
Indexes on the join columns are the biggest lever. Without an
index on orders.customer_id, a join from customers to orders has to
scan every order row to find matches. With an index, the database looks up the
matching orders directly. On a table with a hundred million orders, that is the
difference between a query that runs in a second and one that runs in a minute.
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
Join type matters. INNER JOIN is generally faster
than OUTER JOIN because it 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.
Query complexity compounds. Each additional join multiplies the
potential row count at each step. A four-table join where every level triples the
row count ends up with eighty-one rows for every starting row. Reducing the number
of tables in the query, or filtering the working set with WHERE before joining,
keeps the intermediate results small.
How to know if your join is actually the problem: run EXPLAIN in front
of the query. A plan that says "sequential scan" on a large table is usually a
missing index. A plan that says "nested loop" with an unexpectedly high row count
is usually a signal that the optimizer underestimated the intermediate result.
Four habits, and they are the same on every database.
Define the join condition precisely. Use the correct columns —
usually the primary key on one side and the foreign key on the other. Make sure the
data types are compatible. A join between a text column and an integer column
either fails or silently coerces, and both outcomes are worse than fixing the
schema.
Test against rows that have no match. On our data, C005, C006, and
C007 have no orders. Run any join against those specific customers first and check
the output. A LEFT JOIN should keep them with NULLs; an
INNER JOIN should drop them. If the behaviour surprises you, the join
is not doing what you thought.
Check for unexpected row multiplication. If a join produces more
rows than either input, that is because one side has multiple matches per row. If
you did not expect that, your join condition is wrong or your data has duplicates
you did not account for. Either way, the fix is to understand why, not to slap
DISTINCT on the result.
Verify the count. Before you trust a join, compare the row count
to what you expect. Seven customers joined to seven orders should not produce
fourteen rows unless one side has multiple matches. Numbers that do not add up are
the fastest signal that something is off.
You simply reference the two different column names in the ON clause.
There is no requirement that the columns share a name — only that they hold
related values.
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
That example happens to use the same name on both sides, but the following is just
as valid:
SELECT c.customer_name, o.order_id
FROM customers c
JOIN orders o ON c.cust_ref = o.customer_key;
Different column names, same result — as long as cust_ref and
customer_key hold matching values. The database does not care about
the names; it compares the values.
One consequence of different column names: USING and
NATURAL JOIN will not work, because both of those shortcuts depend on
the column names matching. You are stuck writing the explicit
ON clause, which is honestly the better habit anyway. Explicit beats
implicit whenever the two are equally easy to write.
It is worth noting that different names for related columns usually mean the
tables came from different sources or have been maintained by different teams.
That is normal in enterprise data, and it is one of the reasons the schema-on-read
approach (as opposed to schema-on-write) is so common in analytics.
Same pattern as joining with any other aggregation: join the tables, then
GROUP BY the entity you want to summarise and apply the aggregate
functions.
SELECT c.customer_name,
COUNT(o.order_id) AS orders,
SUM(o.order_amount) AS total_spend,
AVG(o.order_amount) AS avg_order,
MIN(o.order_amount) AS smallest,
MAX(o.order_amount) AS largest
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 six summary columns. On our data, Alice Corp shows three
orders, a total of 28,500, an average of 9,500, a smallest order of 7,500, and a
largest of 12,000. Ethan Supplies shows zero orders and NULL for all the amount
columns, because LEFT JOIN kept the customer row but there were no
orders to summarise.
The one thing to be deliberate about: when you use LEFT JOIN,
customers with no orders get NULL for the aggregate columns, not 0. If you want 0,
wrap the aggregate in COALESCE(SUM(o.order_amount), 0). Otherwise the
report shows blanks where a reader might expect "zero" and the difference matters.
Aliases are short nicknames you give to tables within a query. They do not change
the table's actual name in the database — they exist only for the duration of the
query and only for your readability.
SELECT c.customer_name, o.order_id, o.order_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
Without aliases, the same query would be:
SELECT customers.customer_name, orders.order_id, orders.order_amount
FROM customers
JOIN orders ON customers.customer_id = orders.customer_id;
Same result, twice the typing, and it gets worse as more tables join in. With three
or four tables and long names, the aliases stop being a convenience and start being
the only way to keep the query readable.
Aliases are also mandatory when you self-join. Two references to the same table
need two different aliases, or the database cannot tell which column you mean. That
is not optional style — it is a syntax requirement.
The convention I use: single letters for tables you reference often in the query,
and short descriptive names for anything longer. c for customers,
o for orders. Two or three letters when there are many tables and
single letters get confusing. The important thing is consistency within a query —
do not mix conventions in the same statement.
INNER JOIN is usually faster than any outer join, and the reason is
bookkeeping. An inner join only has to find matches. An outer join also has to
track which rows did not match and emit them separately with NULLs.
On a small table, that difference is invisible. On a table with millions of rows,
the extra work shows up clearly. An OUTER JOIN may need to sort one or
both sides to track unmatched rows, and sorting is expensive.
The gap is smallest for LEFT JOIN and RIGHT JOIN, and
largest for FULL OUTER JOIN. A full outer join has to track unmatched
rows on both sides, so it is the most expensive of the four.
Three things to remember when tuning:
Indexes reduce the cost of every join type. A well-indexed outer join can beat a poorly-indexed inner join.
Filtering early reduces the working set for everything downstream. A tight WHERE clause before the join removes rows from both sides.
Do not use an outer join unless you need it. If your WHERE clause immediately filters out the NULLs the outer join produced, you have done extra work for nothing. Switch to an inner join.
The best way to know which is faster on your data is to measure. Run
EXPLAIN ANALYZE (or the equivalent on your database) on both versions
and compare the plans. Intuition about join performance is often wrong; the plan is
always right.
Outer joins produce NULLs to represent "no matching row on the other side." That
is correct data, but it is not always good presentation. Two things you can do
about it.
Substitute a display value with COALESCE. This replaces the NULL
with something readable in the output without changing the underlying data.
SELECT c.customer_name,
COALESCE(o.order_id, 0) AS order_id,
COALESCE(o.order_status, 'None') AS status
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
Customers with no orders now show 0 and "None" instead of NULL. The report reads
more naturally, and downstream code does not have to interpret NULL.
Filter out the NULLs if they should not appear. If a customer
with no orders should not be in the report at all, use a plain INNER JOIN instead
of filtering with WHERE x IS NOT NULL. The inner join drops them at
the source, which is both clearer and faster.
Two database-specific alternatives to COALESCE that you will see in
existing code: NVL in Oracle and ISNULL in SQL Server.
Both do the same thing as COALESCE for the two-argument case. If you are writing
new code, use COALESCE — it is standard and works everywhere.
You do not do anything special. The database handles it consistently: NULL in a
join column never matches anything. The row is either dropped (INNER JOIN) or
kept with NULLs on the other side (LEFT or FULL OUTER).
SELECT c.customer_name, o.order_id
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;
If a customer row had NULL in customer_id (unusual, but possible in a
staging table), that row would appear once with NULL in every order column. No
order would ever join to it, because NULL never equals anything.
When you are debugging and a join produces fewer rows than expected, NULLs in the
join column are one of the first things to check. Run a query that counts them:
SELECT COUNT(*) AS total,
COUNT(customer_id) AS with_value,
COUNT(*) - COUNT(customer_id) AS null_count
FROM orders;
If null_count is greater than zero, those orders will never match a
customer. Whether that is correct or a data quality issue depends on the business
rules. Sometimes an order really does not have a customer yet, and the NULL is
accurate. Sometimes it means data is broken and a join is quietly hiding the
problem.
If you need to join NULLs to each other — which is unusual but occasionally
wanted — you cannot do it with =. Instead write the condition as
ON (a.col = b.col OR (a.col IS NULL AND b.col IS NULL)). The explicit
NULL check makes the intent clear, and both sides are compared as matching when
neither has a value. Most databases also support this through a null-safe equality
operator (IS NOT DISTINCT FROM in PostgreSQL, <=>
in MySQL), but the OR version works everywhere.
Mistakes I Keep Seeing in Interviews
These come up repeatedly. None of them are exotic, and every one of them is easy to avoid
once you know it is coming.
Mistake one: turning a LEFT JOIN back into an
INNER JOIN by filtering the right-hand table in the WHERE
clause. The condition needs to move to ON, or you need to accept that you
actually wanted an inner join all along.
Mistake two: forgetting the ON clause. In most databases
this produces a cartesian product — the number of rows explodes and the query runs for
a long time before it dies or returns something absurd. Always check that every join has
its condition.
Mistake three: assuming a LEFT JOIN guarantees one row per
left row. It does not — if the right table has multiple matches per left row, you get one
row per match. A customer with three orders appears three times. This is the most common
source of unexpected row counts in real queries.
Mistake four: trusting the row count without checking. If seven
customers joined to seven orders returns fourteen rows, something is off in the join
condition or the data has duplicates. Running a quick count before and after saves a lot
of head-scratching.
Mistake five: using NATURAL JOIN on tables that share more
than the intended join key. The query runs, returns rows, and quietly produces the wrong
result because it joined on a column nobody meant to include. Explicit ON
clauses prevent this.
Mistake six: skipping the index on the join column in production. The
query works fine on development data with a few hundred rows, then takes a minute in
production against a million rows. Indexes on foreign keys are not optional — they are
the reason joins run at all.
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 topics in this guide.
After "What is a LEFT JOIN?" — expect
"What happens if you filter on the right-hand table in the WHERE clause?"
"How would you find rows in the left table that have no match on the right?"
"What is the difference between LEFT JOIN and LEFT OUTER JOIN?"
"How do you get one row per left-table row when the right table has many matches?"
After "What is an INNER JOIN?" — expect
"When would you use INNER JOIN over LEFT JOIN?"
"What happens to rows with NULL in the join column?"
"How do you join three tables?"
"How is INNER JOIN different from CROSS JOIN?"
After "What is a SELF JOIN?" — expect
"Give me a real example of when you would use one."
"How would you find all root nodes in a hierarchy?"
"What is the difference between a self-join and a subquery?"
"How do you handle self-referencing hierarchies that go many levels deep?"
After "What is NULL in a join?" — expect
"Why does NULL not equal NULL?"
"How would you count rows with NULL in the join column?"
"How would you join two tables where the join column is NULL on both sides?"
"What does COALESCE do with the NULLs a join produces?"
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. Which join keeps every row from the left table, even when there is no match on the right?
2. What happens when you add WHERE o.order_status = 'Delivered' after a LEFT JOIN from customers to orders?
3. How many rows does a CROSS JOIN of 7 customers and 7 orders return?