SQL INNER JOIN: Syntax, Examples & Practice
SQL INNER JOIN returns only the rows that have a match in both tables. It pairs each row of one table with the rows of another that satisfy the ON condition, and leaves out anything without a partner. Plain JOIN means the same thing.
What Is an INNER JOIN in SQL?
In the Playground database, orders stores who placed each order as a number, user_id. The customer's name lives in users. An INNER JOIN puts the two together by matching orders.user_id with users.id:
SELECT o.id AS order_id, o.order_date,
u.first_name, u.last_name, o.total
FROM orders o
INNER JOIN users u
ON u.id = o.user_id
ORDER BY o.id
LIMIT 5;
▶ Run this query in the SQL Playground
| order_id | order_date | first_name | last_name | total |
|---|---|---|---|---|
| 1 | 2024-04-24 | Amanda | Mitchell | 2638.11 |
| 2 | 2024-03-15 | Timothy | Campbell | 2947 |
| 3 | 2023-02-15 | Sarah | Hernandez | 683.24 |
| 4 | 2022-01-10 | Linda | Allen | 3920.44 |
| 5 | 2023-06-16 | Robert | Lewis | 1120.99 |
Each result row is built from two tables: the order columns come from orders and the names from users. The ON clause says which rows belong together. o and u are table aliases, short names that show which table each column comes from.
"Inner" means only matches are returned. A customer with no orders does not appear, and neither would an order whose customer did not exist. To keep the unmatched rows, use a LEFT JOIN.
INNER JOIN Syntax
SELECT columns
FROM table_a
INNER JOIN table_b
ON table_a.key = table_b.key;
| Part | What it does |
|---|---|
FROM table_a | The first table. For an INNER JOIN the order of the two tables does not change the result. |
INNER JOIN table_b | The table to combine with it. |
ON … | The matching rule, usually a foreign key compared with the primary key it points to. |
JOIN | The same join. INNER is the default and may be left out. |
INNER JOIN vs JOIN vs a comma in FROM
There are three ways to write the same inner join. All three return the same rows:
SELECT 'INNER JOIN … ON' AS syntax, COUNT(*) AS rows_returned
FROM orders o INNER JOIN users u ON u.id = o.user_id
UNION ALL
SELECT 'JOIN … ON', COUNT(*)
FROM orders o JOIN users u ON u.id = o.user_id
UNION ALL
SELECT 'FROM a, b WHERE', COUNT(*)
FROM orders o, users u WHERE u.id = o.user_id;
▶ Run this query in the SQL Playground
| syntax | rows_returned |
|---|---|
| INNER JOIN … ON | 200 |
| JOIN … ON | 200 |
| FROM a, b WHERE | 200 |
| Form | Notes |
|---|---|
INNER JOIN … ON | Explicit and the clearest. Recommended. |
JOIN … ON | Identical; INNER is implied. |
FROM a, b WHERE a.id = b.a_id | The older "implicit" join. It works, but the join rule is mixed in with the filters, and forgetting it silently pairs every row with every row. |
How INNER JOIN Matches Rows
For each row of one table, the database finds the rows of the other table for which ON is true and outputs one combined row per pair:
| A row has… | Rows in the result |
|---|---|
| One match | 1 |
| Several matches | One per match |
| No match | 0 — the row is left out |
Joining customers to orders shows all three cases. Look at who is missing and who repeats:
SELECT u.id, u.first_name, o.id AS order_id, o.status
FROM users u
INNER JOIN orders o
ON o.user_id = u.id
WHERE u.id <= 5
ORDER BY u.id, o.id;
▶ Run this query in the SQL Playground
| id | first_name | order_id | status |
|---|---|---|---|
| 1 | Jennifer | 109 | delivered |
| 2 | Donna | 13 | delivered |
| 4 | Michael | 11 | processing |
| 4 | Michael | 87 | delivered |
| 4 | Michael | 181 | shipped |
| 5 | Robert | 127 | delivered |
Customer 3 has no orders and is absent. Customer 4 has three orders and appears three times. Across the whole tables:
SELECT (SELECT COUNT(*) FROM users) AS customers,
(SELECT COUNT(*) FROM orders) AS orders,
COUNT(*) AS joined_rows,
COUNT(DISTINCT u.id) AS customers_in_result
FROM users u
INNER JOIN orders o
ON o.user_id = u.id;
▶ Run this query in the SQL Playground
| customers | orders | joined_rows | customers_in_result |
|---|---|---|---|
| 150 | 200 | 200 | 114 |
Every one of the 200 orders finds its customer, so the join returns 200 rows. Only 114 of the 150 customers are in it; the rest have never ordered.
INNER JOIN With Three or More Tables
Add one INNER JOIN … ON for each extra table. Every join needs its own condition linking the new table to one already in the query. This lists the items of order 1 with the customer and the product name, using four tables:
SELECT oi.id AS item_id, u.first_name AS customer,
o.order_date, p.name AS product,
oi.quantity, oi.subtotal
FROM order_items oi
INNER JOIN orders o
ON o.id = oi.order_id
INNER JOIN users u
ON u.id = o.user_id
INNER JOIN products p
ON p.id = oi.product_id
WHERE o.id = 1
ORDER BY oi.id;
▶ Run this query in the SQL Playground
| item_id | customer | order_date | product | quantity | subtotal |
|---|---|---|---|---|---|
| 1 | Amanda | 2024-04-24 | Giant Stuffed Teddy Bear | 5 | 961.05 |
| 2 | Amanda | 2024-04-24 | Marvel Action Figures 5pk | 3 | 321.3 |
| 3 | Amanda | 2024-04-24 | Waterproof Hiking Boots | 1 | 5.39 |
| Join | Links |
|---|---|
order_items → orders | Each item to the order it belongs to |
orders → users | The order to the customer who placed it |
order_items → products | Each item to the product that was bought |
INNER JOIN With WHERE, GROUP BY and Aggregates
A join produces a normal set of rows, so everything else works on top of it: WHERE filters the joined rows, GROUP BY groups them and aggregate functions summarise them. Revenue per product category from delivered orders:
SELECT p.category,
COUNT(DISTINCT o.id) AS orders,
SUM(oi.quantity) AS units,
ROUND(SUM(oi.subtotal), 2) AS revenue
FROM order_items oi
INNER JOIN orders o
ON o.id = oi.order_id
INNER JOIN products p
ON p.id = oi.product_id
WHERE o.status = 'delivered'
GROUP BY p.category
ORDER BY revenue DESC;
▶ Run this query in the SQL Playground
| category | orders | units | revenue |
|---|---|---|---|
| Office Supplies | 25 | 92 | 22858.48 |
| Food & Beverage | 24 | 69 | 19930.66 |
| Books | 23 | 84 | 19514.61 |
| Automotive | 17 | 68 | 18032.88 |
| Health & Beauty | 19 | 60 | 13963.53 |
The category comes from products, the status from orders and the amounts from order_items. No single table could answer the question. Adding HAVING filters the groups, here customers with at least four orders:
SELECT u.id, u.first_name, u.last_name,
COUNT(*) AS orders,
ROUND(SUM(o.total), 2) AS total_spent
FROM users u
INNER JOIN orders o
ON o.user_id = u.id
GROUP BY u.id, u.first_name, u.last_name
HAVING COUNT(*) >= 4
ORDER BY total_spent DESC;
▶ Run this query in the SQL Playground
| id | first_name | last_name | orders | total_spent |
|---|---|---|---|---|
| 118 | Paul | Young | 4 | 15384.26 |
| 77 | Joseph | King | 5 | 14593.17 |
| 124 | Michael | Rodriguez | 4 | 12059.32 |
| 54 | Laura | Martin | 4 | 11780.53 |
| 83 | Steven | Rivera | 4 | 9700.36 |
| 37 | Andrew | Wright | 5 | 6432.94 |
ON or WHERE for a filter?
With an INNER JOIN it makes no difference to the result. A condition can sit in either clause:
SELECT 'condition in ON' AS version, COUNT(*) AS rows_returned
FROM users u
INNER JOIN orders o ON o.user_id = u.id AND o.status = 'delivered'
UNION ALL
SELECT 'condition in WHERE', COUNT(*)
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE o.status = 'delivered';
▶ Run this query in the SQL Playground
| version | rows_returned |
|---|---|
| condition in ON | 82 |
| condition in WHERE | 82 |
The convention is to keep the matching rule in ON and filters in WHERE, which is easier to read. The placement only changes the result for outer joins; see ON vs WHERE in a LEFT JOIN.
Joining on More Than One Condition
ON accepts any condition, including several joined with AND. This finds employees who live in the city where their department is based: the department must match and the two cities must be equal.
SELECT e.first_name, e.last_name, e.title,
d.name AS department, d.location
FROM employees e
INNER JOIN departments d
ON d.id = e.department_id
AND d.location = e.city
ORDER BY e.id;
▶ Run this query in the SQL Playground
| first_name | last_name | title | department | location |
|---|---|---|---|---|
| Melissa | Smith | VP Engineering | Engineering | Fort Worth |
| Steven | Adams | Group PM | Product | Charlotte |
| Jeffrey | Lewis | Marketing Manager | Marketing | Chicago |
| Richard | Brown | Director of People | HR | Indianapolis |
| Donald | Brown | Director of Product | Product | Charlotte |
9 employees qualify. Two other forms are worth recognising:
-- USING: shorthand when the column has the same name in both tables
SELECT *
FROM order_items
INNER JOIN payments USING (order_id);
-- a join does not have to use =
SELECT e.first_name, b.band
FROM employees e
INNER JOIN salary_bands b
ON e.salary BETWEEN b.min_salary AND b.max_salary;
USING (column) is supported by MySQL, PostgreSQL, Oracle and SQLite, but not SQL Server. NATURAL JOIN goes further and joins on every column name the tables share; avoid it, because adding a column later can silently change the join.
INNER JOIN vs LEFT JOIN
SELECT 'INNER JOIN' AS join_type, COUNT(*) AS rows_returned,
COUNT(DISTINCT u.id) AS customers
FROM users u
INNER JOIN orders o ON o.user_id = u.id
UNION ALL
SELECT 'LEFT JOIN', COUNT(*), COUNT(DISTINCT u.id)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;
▶ Run this query in the SQL Playground
| join_type | rows_returned | customers |
|---|---|---|
| INNER JOIN | 200 | 114 |
| LEFT JOIN | 236 | 150 |
The INNER JOIN returns 114 customers; the LEFT JOIN returns all 150, adding one NULL row for each customer without orders.
| INNER JOIN | LEFT JOIN | FULL OUTER JOIN | |
|---|---|---|---|
| Matching rows | Returned | Returned | Returned |
| Unmatched rows of the first table | Dropped | Kept, NULLs on the right | Kept |
| Unmatched rows of the second table | Dropped | Dropped | Kept |
| Typical question | "Orders with their customer" | "All customers, with orders if any" | "Everything from both, matched where possible" |
Choose INNER JOIN when a row without a match is of no interest, and LEFT JOIN when the first table must stay complete. Counting "customers" from an inner join to orders really counts customers who have ordered. The SQL JOINs overview compares every join type.
Why an INNER JOIN Loses or Repeats Rows
Two things surprise people: rows that vanish, and totals that come out wrong. Both show up when orders are joined to payments. Some orders have no payment; others have several.
SELECT 'orders alone' AS source,
COUNT(*) AS row_count, ROUND(SUM(total), 2) AS order_total
FROM orders
UNION ALL
SELECT 'orders joined to payments',
COUNT(*), ROUND(SUM(o.total), 2)
FROM orders o
INNER JOIN payments p
ON p.order_id = o.id;
▶ Run this query in the SQL Playground
| source | row_count | order_total |
|---|---|---|
| orders alone | 200 | 510970.71 |
| orders joined to payments | 200 | 488892.6 |
The row count happens to be the same, but the total is different. Orders with no payment were dropped, and orders with two or more payments were counted once per payment. Neither the count nor the sum describes the orders any more.
| Symptom | Cause | Fix |
|---|---|---|
| Rows are missing | No matching row in the other table, or the join column is NULL | Use a LEFT JOIN if those rows should stay |
| Rows are repeated | One-to-many: a row matches several rows on the other side | Expected. Group the result, or aggregate the "many" table in a subquery before joining |
| Totals are too high | A value from the "one" side is summed once per match | Sum it before the join, or use COUNT(DISTINCT …) for counts |
NULL never matches
A join column that is NULL equals nothing, so the row cannot match. Ten employees have no manager (manager_id is NULL) and disappear from an inner join of the table to itself:
SELECT (SELECT COUNT(*) FROM employees) AS employees,
COUNT(*) AS rows_from_inner_join
FROM employees e
INNER JOIN employees m
ON m.id = e.manager_id;
▶ Run this query in the SQL Playground
| employees | rows_from_inner_join |
|---|---|
| 120 | 110 |
Joining a table to itself like this is a self join; the two aliases make it behave as two tables.
Where INNER JOIN Is Used
| Question | Tables joined |
|---|---|
| Orders with the customer's name and email | orders + customers |
| Revenue by product category | order_items + products |
| Employees with their department and office | employees + departments |
| Sales by region and sales rep | sales + reps + regions |
| Students and the courses they are enrolled in | students + enrollments + courses |
| Invoice lines with product descriptions | invoice_lines + products |
| Transactions with account holder details | transactions + accounts |
| Appointments with patient and doctor names | appointments + patients + doctors |
In BI tools the same join is the default "Inner" option when two tables are merged or related.
Common INNER JOIN Mistakes
1. Leaving out the join condition
SELECT o.id, u.first_name
FROM orders o, users u;
SELECT o.id, u.first_name
FROM orders o
INNER JOIN users u
ON u.id = o.user_id;
Without a condition the result is 200 × 150 = 30,000 rows. Writing INNER JOIN … ON makes the condition hard to forget.
2. Joining on the wrong columns
SELECT o.id, u.first_name
FROM orders o
INNER JOIN users u
ON u.id = o.id;
SELECT o.id, u.first_name
FROM orders o
INNER JOIN users u
ON u.id = o.user_id;
Both columns are numbers, so the wrong version runs and returns plausible rows, each order paired with an unrelated customer. Join the foreign key (orders.user_id) to the key it references.
3. Ambiguous column names
SELECT id, total
FROM orders o
INNER JOIN users u
ON u.id = o.user_id;
SELECT o.id, o.total
FROM orders o
INNER JOIN users u
ON u.id = o.user_id;
Both tables have an id column, so the database reports an "ambiguous column" error. Qualify every column in a join.
4. Using INNER JOIN when rows must be kept
SELECT u.id, COUNT(o.id) AS orders
FROM users u
INNER JOIN orders o
ON o.user_id = u.id
GROUP BY u.id;
SELECT u.id, COUNT(o.id) AS orders
FROM users u
LEFT JOIN orders o
ON o.user_id = u.id
GROUP BY u.id;
An inner join can never show a zero count, because rows with no match are not there to count.
5. Summing across a one-to-many join
SELECT SUM(o.total)
FROM orders o
INNER JOIN payments p
ON p.order_id = o.id;
SELECT SUM(total)
FROM orders;
Each order row is repeated for every payment it has. Aggregate a table before joining it to a table with several rows per key.
INNER JOIN Performance
| Tip | Why it helps |
|---|---|
| Index the join columns | Primary keys are indexed already. Foreign keys such as orders.user_id usually need an index so matches can be looked up instead of scanned for. |
| Filter early | A WHERE condition that removes rows before or during the join leaves less to match. |
| Select only the columns you need | SELECT * across several tables returns every column of all of them. |
| Join on columns of the same type | Comparing a number with text forces a conversion on every row and can stop an index being used. |
| Do not worry about table order | For inner joins the optimiser picks the order itself; write the query in the order that reads best. |
Use EXPLAIN (EXPLAIN QUERY PLAN in SQLite) to see whether an index is used. On the small Playground tables every join is instant.
INNER JOIN Cheat Sheet
| Goal | Pattern |
|---|---|
| Match rows from two tables | FROM a INNER JOIN b ON b.a_id = a.id |
| Same thing, shorter | FROM a JOIN b ON b.a_id = a.id |
| Three tables | … JOIN b ON … JOIN c ON … |
| Two matching conditions | ON b.a_id = a.id AND b.x = a.x |
| Filter the joined rows | WHERE after the joins |
| Summarise per group | GROUP BY with COUNT, SUM, AVG |
| Keep unmatched rows too | Use LEFT JOIN instead |
Practice INNER JOIN Queries
8 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy List each employee's first name, last name and title with the name and location of their department. Show the first ten by employee id. Table:
employees, departmentsShow solution
SELECT e.first_name, e.last_name, e.title, d.name AS department, d.location FROM employees e INNER JOIN departments d ON d.id = e.department_id ORDER BY e.id LIMIT 10;▶ Run this query in the SQL Playground
employees.department_idis the foreign key anddepartments.idthe primary key it points to. -
2Easy Show the five most helpful reviews: product name, rating and helpful votes. Table:
reviews, productsShow solution
SELECT p.name AS product, r.rating, r.helpful_votes FROM reviews r INNER JOIN products p ON p.id = r.product_id ORDER BY r.helpful_votes DESC, r.id LIMIT 5;▶ Run this query in the SQL Playground
Each review is matched to its product; sorting and LIMIT are applied to the joined rows.
-
3Medium List the orders placed by customers in Canada during 2024: order id, date, customer name and total, newest first. Table:
orders, usersShow solution
SELECT o.id, o.order_date, u.first_name, u.last_name, o.total FROM orders o INNER JOIN users u ON u.id = o.user_id WHERE u.country = 'Canada' AND o.order_date >= '2024-01-01' AND o.order_date < '2025-01-01' ORDER BY o.order_date DESC, o.id;▶ Run this query in the SQL Playground
WHERE can filter on columns from either table once they are joined. 7 orders match.
-
4Medium For each department, show the number of employees and the average salary, highest average first. Table:
departments, employeesShow solution
SELECT d.name AS department, COUNT(*) AS employees, ROUND(AVG(e.salary)) AS avg_salary FROM departments d INNER JOIN employees e ON e.department_id = d.id GROUP BY d.id, d.name ORDER BY avg_salary DESC;▶ Run this query in the SQL Playground
Every department has employees, so the inner join returns all 10. If one were empty it would be missing, and a LEFT JOIN would be needed.
-
5Medium Find the ten best-selling products by units sold. Show the product id, name and total units. Table:
order_items, productsShow solution
SELECT p.id, p.name, SUM(oi.quantity) AS units_sold FROM order_items oi INNER JOIN products p ON p.id = oi.product_id GROUP BY p.id, p.name ORDER BY units_sold DESC, p.id LIMIT 10;▶ Run this query in the SQL Playground
Grouping by the product id as well as the name keeps products that share a name apart.
-
6Medium List each employee with their manager's name and title, for the first ten employees who have a manager. Table:
employeesShow solution
SELECT e.first_name AS employee, e.title, m.first_name AS manager, m.title AS manager_title FROM employees e INNER JOIN employees m ON m.id = e.manager_id ORDER BY e.id LIMIT 10;▶ Run this query in the SQL Playground
A self join: the same table under two aliases. Employees with no manager are left out because their
manager_idis NULL. -
7Challenging Show total revenue per customer country and product category for delivered orders. Return the ten largest combinations. Table:
order_items, orders, users, productsShow solution
SELECT u.country, p.category, ROUND(SUM(oi.subtotal), 2) AS revenue FROM order_items oi INNER JOIN orders o ON o.id = oi.order_id INNER JOIN users u ON u.id = o.user_id INNER JOIN products p ON p.id = oi.product_id WHERE o.status = 'delivered' GROUP BY u.country, p.category ORDER BY revenue DESC LIMIT 10;▶ Run this query in the SQL Playground
Four tables, three join conditions. The amounts are summed from
order_items, the "many" table, so nothing is double counted. -
8Challenging Find reviews written by a customer who actually ordered that product. Show the review id, user id, product id and rating, each review once. Table:
reviews, orders, order_itemsShow solution
SELECT DISTINCT r.id AS review_id, r.user_id, r.product_id, r.rating FROM reviews r INNER JOIN orders o ON o.user_id = r.user_id INNER JOIN order_items oi ON oi.order_id = o.id AND oi.product_id = r.product_id ORDER BY r.id;▶ Run this query in the SQL Playground
The second join has two conditions: the item must be in one of the reviewer's orders and be the reviewed product. Only 5 reviews pass.
📝 Quiz — INNER JOIN
Test your understanding · 0/10 answered
What does an INNER JOIN return?
Only pairs of rows that satisfy the ON condition are returned. Rows without a match are left out.
What is the difference between JOIN and INNER JOIN?
INNER is the default join type, so the two are identical.
A customer has no orders. What does users INNER JOIN orders show for that customer?
With no matching order there is nothing to pair the customer with, so no row is produced.
A customer has three orders. How many rows does users INNER JOIN orders return for that customer?
One row per matching pair, so three.
What happens if you list two tables in FROM with a comma and no condition?
With no join condition the result is the Cartesian product: rows of one table times rows of the other.
How many join conditions are needed to join four tables properly?
Each new table must be linked to one already in the query: n tables need at least n − 1 conditions.
For an INNER JOIN, does moving a filter from WHERE into ON change the result?
For inner joins the two are equivalent. It matters only for outer joins such as LEFT JOIN.
A row's join column is NULL. What does an INNER JOIN do with it?
NULL is not equal to anything, including NULL, so the row has no match and is dropped.
You need every department with its employee count, including departments with none. Which join?
An inner join cannot return a department with no employees. LEFT JOIN from departments keeps it with a count of 0.
Why can SUM(o.total) be too high after orders INNER JOIN payments?
An order with two payments appears twice, so its total is added twice. Aggregate before joining a one-to-many table.
INNER JOIN Interview Questions
Q1. What is an INNER JOIN?
A join that returns only the rows for which the ON condition is true in both tables. Rows of either table with no match are not returned. JOIN on its own means INNER JOIN.
Q2. What is the difference between INNER JOIN and LEFT JOIN?
INNER JOIN returns matches only. LEFT JOIN also returns the rows of the first table that have no match, with NULL in the second table's columns. They give the same result when every row of the first table has a match.
Q3. Is there a difference between a condition in ON and in WHERE for an INNER JOIN?
Not in the result. For inner joins a predicate filters the same rows in either place. By convention ON holds the matching rule and WHERE holds filters. For outer joins the placement does change the result.
Q4. Can an INNER JOIN return more rows than either table has?
Yes. In a one-to-many or many-to-many relationship a row is returned once per match, so the result can be larger than both inputs.
Q5. What happens to NULL values in the join column?
They never match, because NULL = NULL is not true. Rows with a NULL key are dropped by an inner join.
Q6. How do you join three tables?
Chain the joins: FROM a JOIN b ON … JOIN c ON …. Each new table needs a condition that links it to a table already in the query.
Q7. What is the difference between an INNER JOIN and a comma join with WHERE?
They return the same rows. The explicit form separates the join rule from the filters and makes a missing condition obvious; the comma form silently becomes a cross join if the condition is forgotten.
Q8. What is the difference between INNER JOIN and CROSS JOIN?
CROSS JOIN pairs every row with every row and has no condition. INNER JOIN keeps only the pairs that satisfy ON.
Q9. Does the order of tables matter in an INNER JOIN?
Not for the result, and usually not for speed, because the optimiser chooses the join order. It does matter for LEFT and RIGHT joins.
Q10. What is USING, and how does it differ from ON?
USING (col) is shorthand for an equality join on a column that has the same name in both tables, and returns that column once. ON accepts any condition. SQL Server does not support USING.
Q11. Why did my totals change after adding a join?
The new table has several rows per key, so each row of the original table is repeated and its values are summed more than once; or rows without a match were dropped. Aggregate the detail table first, or use a LEFT JOIN if rows must be kept.
Q12. How do you make an INNER JOIN fast?
Index the join columns, especially foreign keys; filter rows early; join columns of the same data type; and return only the columns needed. Check the plan with EXPLAIN.
Frequently Asked Questions
What is INNER JOIN in SQL?
INNER JOIN combines rows from two tables and returns only the pairs that satisfy the ON condition. Rows that have no match in the other table are not returned.
Is JOIN the same as INNER JOIN?
Yes. INNER is the default join type, so JOIN and INNER JOIN are identical in every major database.
What is the difference between INNER JOIN and LEFT JOIN?
INNER JOIN returns only matching rows. LEFT JOIN returns the matching rows and also every unmatched row of the first table, with NULL in the columns of the second table.
How do I INNER JOIN three tables?
Write one INNER JOIN … ON clause per additional table, each with a condition that links the new table to one already in the query. Three tables need two join conditions.
Why is my INNER JOIN returning duplicate rows?
A row is returned once for each row it matches. If a customer has three orders, the customer appears three times. This is expected for one-to-many relationships; group the result if you need one row per customer.
Why is my INNER JOIN missing rows?
Rows with no match in the other table, and rows whose join column is NULL, are left out. Use a LEFT JOIN if those rows should stay in the result.
Can I use WHERE with INNER JOIN?
Yes. WHERE filters the joined rows and can use columns from any of the joined tables. For an inner join, a filter gives the same result in WHERE or in ON.
Does INNER JOIN work the same in MySQL, PostgreSQL and SQL Server?
Yes. INNER JOIN … ON is standard SQL and behaves the same in MySQL, PostgreSQL, SQL Server, Oracle and SQLite.