INNER JOIN (or just JOIN) is the SQL command for combining two tables by a shared key. The simplest and most common JOIN type — you can't go far with relational databases without it.
Quick analogy. You have two tables:
users — users (id, name)
orders — orders (id, user_id, amount)
There's no buyer name in orders — only user_id. To get "buyer name + order amount" pairs, you have to glue these tables back together by a common key — users.id matches orders.user_id. That gluing is INNER JOIN.
Why JOIN matters
A normal database splits data across tables to avoid duplication. The user's name lives only in users; orders references the user via user_id. This is called normalisation.
When you need to show "name + order", you have to assemble the data back together — that's what JOIN is for. It's one of the most important SQL topics — without JOIN you can't write a single real-world query.
Basic syntax
SELECT columns
FROM table_A
INNER JOIN table_B
ON table_A.key = table_B.key;
The word INNER is optional — JOIN without a prefix means INNER JOIN. Most code says just JOIN.
Example: a shop
users table:
orders table:
| id | user_id | amount | created_at |
|---|
| 10 | 1 | 500 | 2024-03-01 |
| 11 | 1 | 1500 | 2024-03-05 |
| 12 | 2 | 200 | 2024-03-07 |
| 13 | 5 | 3000 | 2024-03-10 |
Query — join and show buyer name with amount:
SELECT users.name, orders.amount, orders.created_at
FROM users
INNER JOIN orders
ON users.id = orders.user_id;
Result:
| name | amount | created_at |
|---|
| Anna | 500 | 2024-03-01 |
| Anna | 1500 | 2024-03-05 |
| Boris | 200 | 2024-03-07 |
What happened:
- Each row from
users was paired with each row from orders where users.id = orders.user_id.
- Anna has two orders → two rows in the result, name duplicated.
- Boris has one order → one row.
- Vera and Grisha aren't there — they have no orders. That's the INNER JOIN trait: only pairs with a match in both tables.
- Order #13 isn't there —
user_id = 5 doesn't exist in users. Sometimes happens, e.g. user got deleted but the order stayed.
If you also want users without orders, that's LEFT JOIN (separate article).
Aliases — short table names
Writing users.name, orders.amount every time is long. Real-world code uses aliases:
SELECT u.name, o.amount, o.created_at
FROM users u
INNER JOIN orders o
ON u.id = o.user_id;
users u means "give the users table the alias u". Doesn't change the logic — just shorter. In big queries with three-to-five tables, queries without aliases are unreadable.
Note: aliases become mandatory if both tables have a column with the same name. Otherwise the database can't tell whose id you mean.
Joining three or more tables
JOINs chain: A JOIN B JOIN C. Add a products table:
| id | name | price |
|---|
| 100 | Bosch kettle | 49 |
| 101 | iPhone 15 | 999 |
| 102 | Book "Clean Code" | 22 |
And extend orders with product_id:
| id | user_id | product_id | amount |
|---|
| 10 | 1 | 100 | 49 |
| 11 | 1 | 102 | 22 |
| 12 | 2 | 100 | 49 |
Query — who bought what:
SELECT u.name AS buyer, p.name AS product, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
INNER JOIN products p ON p.id = o.product_id;
Result:
| buyer | product | amount |
|---|
| Anna | Bosch kettle | 49 |
| Anna | Book "Clean Code" | 22 |
| Boris | Bosch kettle | 49 |
How it works:
- The database first joins
users and orders on user_id.
- Then the result is joined with
products on product_id.
- The output keeps only rows where both joins matched.
These chains can be as long as you need. "Users, their orders, products in those orders, product categories, and stores" — that's five JOINs in a row.
JOIN with WHERE
JOIN usually goes with WHERE — JOIN glues tables, WHERE filters the result. E.g. Anna's orders above 1000:
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.name = 'Anna'
AND o.amount > 1000;
Extremely common combo — almost any business query is "join the tables + filter with WHERE + maybe aggregate".
INNER JOIN is intersection
In set-theory terms: INNER JOIN is the intersection of two tables on a key. Only the rows present in both make it.
users orders
┌──┐ ┌──┐
│ │ │ │
│ ●│ ← → │● │ ← pairs: only these end up in the result
│ │ │ │
└──┘ └──┘
User with no orders — not in the result. Order with no user (broken FK) — not in the result either.
Bigger example: a library
books:
| id | title | author_id |
|---|
| 1 | War and Peace | 10 |
| 2 | Anna Karenina | 10 |
| 3 | Crime and Punishment | 11 |
| 4 | The Idiot | 11 |
| 5 | (orphan — author not in DB) | 99 |
authors:
| id | full_name | country |
|---|
| 10 | Leo Tolstoy | Russia |
| 11 | Fyodor Dostoevsky | Russia |
| 12 | Erich Maria Remarque | Germany |
Query — every book with author name and country:
SELECT b.title, a.full_name AS author, a.country
FROM books b
JOIN authors a ON a.id = b.author_id
ORDER BY a.full_name, b.title;
Result:
| title | author | country |
|---|
| Anna Karenina | Leo Tolstoy | Russia |
| War and Peace | Leo Tolstoy | Russia |
| Crime and Punishment | Fyodor Dostoevsky | Russia |
| The Idiot | Fyodor Dostoevsky | Russia |
The orphan book is gone — author_id = 99 isn't in authors. Remarque is gone — he has zero books in our books. INNER JOIN dropped both.
Common beginner mistakes
1. Forgetting the ON clause. Writing JOIN orders with no ON — most DBs either error out or fall back to a CROSS JOIN (Cartesian product, every row times every row). Either an error or a very slow query.
2. Wrong columns in ON. ON u.id = o.id instead of ON u.id = o.user_id is a classic typo. The query won't crash — it'll return nonsense (user #5 paired with order #5, which is meaningless).
3. Ambiguous column names. If both users and orders have id, and you write SELECT id, name FROM users JOIN orders ON …, you get "column reference "id" is ambiguous". Use users.id or aliases.
4. Duplicate rows in the result. If a user has 5 orders, the result has 5 rows with that user's name repeated. Not a bug — it's how JOIN works. Want unique users? Wrap in SELECT DISTINCT u.name or combine with GROUP BY.
5. Thinking JOIN itself is slow. JOINs are fast when there are indexes on the join columns. That's why FK columns (user_id and similar) are always indexed in production.
6. Old comma-style joins. Technically you can write FROM users, orders WHERE users.id = orders.user_id. Don't. The modern JOIN ... ON ... syntax reads better and clearly separates joining from filtering.
Quick recap
INNER JOIN combines two tables by a shared key.
- Only pairs with a match in both tables make it to the result.
- The join condition goes after
ON.
- The word
INNER is optional; people just write JOIN.
- You can chain many JOINs (three- and five-table queries are normal).
- Use aliases (
users u) to keep things readable.
- If you also want users with no orders, that's
LEFT JOIN, a different scenario.
Try it yourself
On the users and orders tables above:
- List buyer-name and amount pairs, sorted by amount descending.
- Find all users who placed an order in March 2024.
- Count the total order amount per user (combo
JOIN + GROUP BY + SUM).
When JOIN clicks for you, it's a freeing moment in learning SQL. Before — you can read one table. After — real-world data opens up fully.
INNER JOIN(or justJOIN) is the SQL command for combining two tables by a shared key. The simplest and most common JOIN type — you can't go far with relational databases without it.Quick analogy. You have two tables:
users— users (id, name)orders— orders (id, user_id, amount)There's no buyer name in
orders— onlyuser_id. To get "buyer name + order amount" pairs, you have to glue these tables back together by a common key —users.idmatchesorders.user_id. That gluing isINNER JOIN.Why JOIN matters
A normal database splits data across tables to avoid duplication. The user's name lives only in
users;ordersreferences the user viauser_id. This is called normalisation.When you need to show "name + order", you have to assemble the data back together — that's what JOIN is for. It's one of the most important SQL topics — without JOIN you can't write a single real-world query.
Basic syntax
SELECT columns FROM table_A INNER JOIN table_B ON table_A.key = table_B.key;The word
INNERis optional —JOINwithout a prefix meansINNER JOIN. Most code says justJOIN.Example: a shop
userstable:orderstable:Query — join and show buyer name with amount:
SELECT users.name, orders.amount, orders.created_at FROM users INNER JOIN orders ON users.id = orders.user_id;Result:
What happened:
userswas paired with each row fromorderswhereusers.id = orders.user_id.user_id = 5doesn't exist inusers. Sometimes happens, e.g. user got deleted but the order stayed.If you also want users without orders, that's
LEFT JOIN(separate article).Aliases — short table names
Writing
users.name,orders.amountevery time is long. Real-world code uses aliases:SELECT u.name, o.amount, o.created_at FROM users u INNER JOIN orders o ON u.id = o.user_id;users umeans "give theuserstable the aliasu". Doesn't change the logic — just shorter. In big queries with three-to-five tables, queries without aliases are unreadable.Note: aliases become mandatory if both tables have a column with the same name. Otherwise the database can't tell whose
idyou mean.Joining three or more tables
JOINs chain: A JOIN B JOIN C. Add a
productstable:And extend
orderswithproduct_id:Query — who bought what:
SELECT u.name AS buyer, p.name AS product, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id INNER JOIN products p ON p.id = o.product_id;Result:
How it works:
usersandordersonuser_id.productsonproduct_id.These chains can be as long as you need. "Users, their orders, products in those orders, product categories, and stores" — that's five JOINs in a row.
JOIN with WHERE
JOINusually goes withWHERE— JOIN glues tables, WHERE filters the result. E.g. Anna's orders above 1000:SELECT u.name, o.amount FROM users u JOIN orders o ON u.id = o.user_id WHERE u.name = 'Anna' AND o.amount > 1000;Extremely common combo — almost any business query is "join the tables + filter with WHERE + maybe aggregate".
INNER JOIN is intersection
In set-theory terms: INNER JOIN is the intersection of two tables on a key. Only the rows present in both make it.
User with no orders — not in the result. Order with no user (broken FK) — not in the result either.
Bigger example: a library
books:authors:Query — every book with author name and country:
SELECT b.title, a.full_name AS author, a.country FROM books b JOIN authors a ON a.id = b.author_id ORDER BY a.full_name, b.title;Result:
The orphan book is gone —
author_id = 99isn't inauthors. Remarque is gone — he has zero books in ourbooks. INNER JOIN dropped both.Common beginner mistakes
1. Forgetting the ON clause. Writing
JOIN orderswith noON— most DBs either error out or fall back to a CROSS JOIN (Cartesian product, every row times every row). Either an error or a very slow query.2. Wrong columns in ON.
ON u.id = o.idinstead ofON u.id = o.user_idis a classic typo. The query won't crash — it'll return nonsense (user #5 paired with order #5, which is meaningless).3. Ambiguous column names. If both
usersandordershaveid, and you writeSELECT id, name FROM users JOIN orders ON …, you get "column reference "id" is ambiguous". Useusers.idor aliases.4. Duplicate rows in the result. If a user has 5 orders, the result has 5 rows with that user's name repeated. Not a bug — it's how JOIN works. Want unique users? Wrap in
SELECT DISTINCT u.nameor combine withGROUP BY.5. Thinking JOIN itself is slow. JOINs are fast when there are indexes on the join columns. That's why FK columns (
user_idand similar) are always indexed in production.6. Old comma-style joins. Technically you can write
FROM users, orders WHERE users.id = orders.user_id. Don't. The modernJOIN ... ON ...syntax reads better and clearly separates joining from filtering.Quick recap
INNER JOINcombines two tables by a shared key.ON.INNERis optional; people just writeJOIN.users u) to keep things readable.LEFT JOIN, a different scenario.Try it yourself
On the
usersandorderstables above:JOIN+GROUP BY+SUM).When JOIN clicks for you, it's a freeing moment in learning SQL. Before — you can read one table. After — real-world data opens up fully.