SQL Joins · Guide + 25 Interview Questions

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
25 Interview Questions Beginner to Advanced Real Examples Updated Oct 6, 2026
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
📤 Share:

On This Page

What Joins Really Do

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

And the orders table used in the join examples:

-- orders table
-- order_id | customer_id | order_amount | order_status
-- 1001     | C001        | 12000        | Delivered
-- 1002     | C002        | 4500         | Shipped
-- 1003     | C001        | 7500         | Delivered
-- 1004     | C003        | 22000        | Pending
-- 1005     | C002        | 3200         | Delivered
-- 1006     | C004        | 18000        | Shipped
-- 1007     | C001        | 9000         | Delivered

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?