Free Online Code Editor
Understand every SQL JOIN with two small tables you can run yourself. See what each returns, when rows go missing, and how to avoid duplicates.
Real databases split information across tables. Customers live in one, their orders in another. A JOIN is how you put them back together in a single result.
The names, INNER, LEFT, RIGHT, and FULL, only differ in one thing: what happens to rows that don't have a match on the other side. Once you see that, the rest is easy.
Run this in the SQL editor to follow along. Notice that Dee has no orders, and one order belongs to a customer who isn't in the customers table.
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, item TEXT, amount REAL);
INSERT INTO customers VALUES (1, 'Ana'), (2, 'Ben'), (3, 'Cy'), (4, 'Dee');
INSERT INTO orders VALUES
(1, 1, 'Book', 12.50),
(2, 1, 'Pen', 2.00),
(3, 2, 'Lamp', 30.00),
(4, 3, 'Desk', 150.00),
(5, 99, 'Mystery box', 9.99);The customer_id column in orders points at customers.id. That's the link we'll join on.
An INNER JOIN returns rows only when both sides match. Everything else disappears.
SELECT customers.name, orders.item, orders.amount
FROM customers
INNER JOIN orders ON orders.customer_id = customers.id
ORDER BY customers.name, orders.item;name item amount
---- ---- ------
Ana Book 12.5
Ana Pen 2.0
Ben Lamp 30.0
Cy Desk 150.0Dee is gone, because she has no orders. The mystery box is gone too, because no customer matches id 99. Ana appears twice, since she has two orders, which is normal. A join produces one row for each matching pair.
INNER JOIN and plain JOIN mean the same thing.
A LEFT JOIN keeps every row from the first (left) table. When there's no match, the right-side columns are filled with NULL.
SELECT customers.name, orders.item
FROM customers
LEFT JOIN orders ON orders.customer_id = customers.id
ORDER BY customers.name, orders.item;name item
---- ----
Ana Book
Ana Pen
Ben Lamp
Cy Desk
Dee NULLNow Dee shows up, with an empty item. This is the join you'll use most often, whenever the question is "show me all the X, and their Y if they have any."
Here's a very useful trick. Do a LEFT JOIN, then keep only the rows where the right side is NULL. That gives you the left rows that have no partner.
SELECT customers.name
FROM customers
LEFT JOIN orders ON orders.customer_id = customers.id
WHERE orders.id IS NULL;name
----
Dee"Customers who never ordered" is one of the most common real-world questions. The same pattern finds users with no logins, products with no sales, and posts with no comments.
A RIGHT JOIN keeps every row from the second (right) table. It's a LEFT JOIN with the tables swapped.
SELECT customers.name, orders.item
FROM customers
RIGHT JOIN orders ON orders.customer_id = customers.id
ORDER BY orders.id;name item
---- -----------
Ana Book
Ana Pen
Ben Lamp
Cy Desk
NULL Mystery boxThe mystery box appears now, with no customer name. Most people just swap the table order and use LEFT JOIN, since it reads more naturally. Some databases, like older SQLite versions, don't support RIGHT JOIN at all. The CompileZ editor does.
A FULL JOIN keeps all rows from both sides. Matches are paired up, and the leftovers on either side get NULLs.
SELECT customers.name, orders.item
FROM customers
FULL JOIN orders ON orders.customer_id = customers.id
ORDER BY customers.name, orders.item;name item
---- -----------
NULL Mystery box
Ana Book
Ana Pen
Ben Lamp
Cy Desk
Dee NULLYou see Dee with no order and the mystery box with no customer, in one result. It's great for reconciliation, such as comparing two lists to find what's missing from each. MySQL doesn't support FULL JOIN directly, so there you'd combine a LEFT and a RIGHT join with UNION.
You'll often combine a join with GROUP BY to answer questions like "how much has each customer spent?"
SELECT customers.name,
COUNT(orders.id) AS order_count,
COALESCE(SUM(orders.amount), 0) AS total_spent
FROM customers
LEFT JOIN orders ON orders.customer_id = customers.id
GROUP BY customers.id, customers.name
ORDER BY total_spent DESC;name order_count total_spent
---- ----------- -----------
Cy 1 150.0
Ben 1 30.0
Ana 2 14.5
Dee 0 0Two details matter here. COUNT(orders.id) counts only real orders, while COUNT(*) would count Dee's empty row as 1. And COALESCE swaps NULL for 0, so Dee's total is 0 instead of blank.
Forgetting the ON condition. Without it, every row pairs with every other row, and you get a huge result called a cross join. 4 customers times 5 orders gives you 20 rows of nonsense.
Filtering the right table in WHERE after a LEFT JOIN. This quietly turns your LEFT JOIN into an INNER JOIN, because NULL rows fail the condition. Put the filter in the ON clause instead:
SELECT customers.name, orders.item
FROM customers
LEFT JOIN orders
ON orders.customer_id = customers.id AND orders.amount > 20
ORDER BY customers.name;name item
---- ----
Ana NULL
Ben Lamp
Cy Desk
Dee NULLEveryone is still listed. Only big orders are attached.
Surprise duplicates. If you join to a table where one row matches many, your totals can get inflated. Check row counts before and after a join when numbers look too high.
Ambiguous column names. If both tables have an id column, write customers.id or give tables short aliases such as c and o.
ON.IS NULL finds rows with no match.ON, not WHERE, when you want to keep unmatched left rows.Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.
Open the SQL editorLearn what a database index is, how to read a query plan, how to design composite indexes, and the habits that quietly stop your indexes from being used.
Learn SQL window functions with a small sales table. Rank rows, compare to the previous row, calculate running totals, and find the top result per group.
Learn how SQL transactions use BEGIN, COMMIT, ROLLBACK, and SAVEPOINT to keep data consistent, with a bank transfer example you can run and break on purpose.