MySQLINNER JOIN

INNER JOIN in MySQL

INNER JOIN is the most commonly used join type. It returns only the rows where the join condition matches in both tables. If a row in the left table has no match in the right table (or vice versa), that row is silently excluded from the results.

This is what you want most of the time: "give me the orders that have a customer, the products that have been categorized, the employees that belong to a department."

INNER JOIN Syntax

SQL
-- Explicit INNER JOIN keyword
SELECT columns
FROM table_a
INNER JOIN table_b ON table_a.key = table_b.key;

-- The INNER keyword is optional — bare JOIN always means INNER JOIN
SELECT columns
FROM table_a
JOIN table_b ON table_a.key = table_b.key;

-- USING shorthand when column names match
SELECT columns
FROM table_a
JOIN table_b USING (shared_column_name);
Note
The word INNER is optional. Plain JOIN is always an inner join. Many developers omit it for brevity; either style is correct as long as you are consistent within your codebase.
The Matching Rows Concept

Think of INNER JOIN as a filter: "only give me rows that have a partner in the other table."

If customers has 500 rows but only 350 have placed orders, an INNER JOIN against orders returns rows for those 350 customers only. The 150 customers who never ordered are excluded. Moreover, customers with multiple orders appear multiple times — once per order.

SQL
-- Sample data setup for demonstration
INSERT INTO customers (customer_id, first_name, email) VALUES
  (1, 'Alice', 'alice@example.com'),
  (2, 'Bob',   'bob@example.com'),
  (3, 'Carol', 'carol@example.com');   -- Carol has no orders

INSERT INTO orders (order_id, customer_id, total, status) VALUES
  (101, 1, 75.00,  'delivered'),
  (102, 1, 120.00, 'delivered'),       -- Alice has two orders
  (103, 2, 45.00,  'pending');

-- INNER JOIN: Carol is excluded, Alice appears twice
SELECT c.first_name, o.order_id, o.total
FROM customers AS c
INNER JOIN orders AS o ON c.customer_id = o.customer_id;

first_name

order_id

total

Alice

101

75.00

Alice

102

120.00

Bob

103

45.00

Carol is missing because she has no matching row in orders. That is the defining behavior of INNER JOIN.

Multiple INNER JOINs in One Query

You can chain as many INNER JOINs as needed. Each join narrows the result to rows that have a match in every joined table. If any table in the chain has no matching row, that row is dropped from the final result.

SQL
-- Three-table join: customers → orders → order_items → products
SELECT
  c.first_name,
  c.last_name,
  o.order_id,
  p.name           AS product_name,
  oi.quantity,
  oi.unit_price,
  ROUND(oi.quantity * oi.unit_price, 2) AS line_total
FROM customers   AS c
JOIN orders      AS o  ON c.customer_id  = o.customer_id
JOIN order_items AS oi ON o.order_id     = oi.order_id
JOIN products    AS p  ON oi.product_id  = p.product_id
ORDER BY o.order_id, p.name;

-- Four-table join: add category
SELECT
  c.email,
  cat.name  AS category,
  p.name    AS product,
  SUM(oi.quantity * oi.unit_price) AS total_spent
FROM customers   AS c
JOIN orders      AS o   ON c.customer_id  = o.customer_id
JOIN order_items AS oi  ON o.order_id     = oi.order_id
JOIN products    AS p   ON oi.product_id  = p.product_id
JOIN categories  AS cat ON p.category_id  = cat.category_id
WHERE o.status = 'delivered'
GROUP BY c.customer_id, cat.category_id, c.email, cat.name
ORDER BY total_spent DESC;
INNER JOIN with WHERE

WHERE filters rows after the join is computed. You can filter on any column from any of the joined tables. The order of operations: JOIN first (eliminates non-matching rows), then WHERE (filters the joined result), then GROUP BY / ORDER BY.

SQL
-- Only delivered orders from Canadian customers
SELECT
  c.first_name,
  c.country,
  o.order_id,
  o.total,
  o.created_at
FROM customers AS c
JOIN orders    AS o ON c.customer_id = o.customer_id
WHERE o.status  = 'delivered'
  AND c.country = 'Canada'
  AND o.total   > 50.00
ORDER BY o.created_at DESC;

-- High-value orders in specific product categories
SELECT
  o.order_id,
  c.email,
  cat.name AS category,
  ROUND(SUM(oi.quantity * oi.unit_price), 2) AS order_value
FROM customers   AS c
JOIN orders      AS o   ON c.customer_id  = o.customer_id
JOIN order_items AS oi  ON o.order_id     = oi.order_id
JOIN products    AS p   ON oi.product_id  = p.product_id
JOIN categories  AS cat ON p.category_id  = cat.category_id
WHERE cat.name IN ('Electronics', 'Software')
  AND o.status   = 'delivered'
GROUP BY o.order_id, c.email, cat.name
HAVING SUM(oi.quantity * oi.unit_price) > 200
ORDER BY order_value DESC;
JOIN on Multiple Conditions

The ON clause can include multiple conditions with AND. Use this when the relationship involves a composite key or when you want to narrow the join itself, not just the final result.

SQL
-- Composite key join (company_id + invoice_id)
SELECT i.invoice_date, il.description, il.amount
FROM invoices      AS i
JOIN invoice_lines AS il
  ON  i.company_id  = il.company_id
  AND i.invoice_id  = il.invoice_id;

-- Join with an extra narrowing condition in ON
-- Only join order_items where quantity > 1 (bulk line items)
SELECT
  o.order_id,
  oi.product_id,
  oi.quantity,
  oi.unit_price
FROM orders      AS o
JOIN order_items AS oi
  ON  o.order_id   = oi.order_id
  AND oi.quantity  > 1
WHERE o.status = 'delivered';
Tip
For INNER JOIN, putting a filter in the ON clause vs. WHERE produces the same result. For OUTER JOINs, it matters: a filter in ON preserves non-matching rows (keeps the outer join behavior), while the same filter in WHERE silently converts the outer join to an inner join.
INNER JOIN with Table Aliases

SQL
-- Clean multi-join query with consistent short aliases
SELECT
  c.customer_id,
  CONCAT(c.first_name, ' ', c.last_name)  AS customer_name,
  o.order_id,
  o.created_at                            AS order_date,
  COUNT(oi.item_id)                       AS line_items,
  ROUND(SUM(oi.quantity * oi.unit_price),2) AS order_value
FROM customers   AS c
JOIN orders      AS o  ON c.customer_id = o.customer_id
JOIN order_items AS oi ON o.order_id    = oi.order_id
GROUP BY c.customer_id, customer_name, o.order_id, o.created_at
HAVING order_value > 100
ORDER BY order_value DESC
LIMIT 25;
Implicit JOIN Syntax (Comma Notation)

Before explicit JOIN syntax, SQL code used a comma in the FROM clause with the join condition in WHERE. You will encounter this in legacy code — it still works but is not recommended for new queries.

SQL
-- Old implicit syntax (legacy — avoid in new code)
SELECT c.first_name, o.order_id, o.total
FROM customers AS c, orders AS o
WHERE c.customer_id = o.customer_id
  AND o.status = 'delivered';

-- Modern explicit equivalent
SELECT c.first_name, o.order_id, o.total
FROM customers AS c
JOIN orders    AS o ON c.customer_id = o.customer_id
WHERE o.status = 'delivered';
Warning
With implicit comma syntax, forgetting the WHERE join condition silently produces a cartesian product — every customer row paired with every order row. Explicit JOIN with ON prevents this class of bug entirely.
Performance Tips for INNER JOIN

SQL
-- Step 1: verify indexes exist on all join columns
SHOW INDEX FROM customers;    -- should have idx on customer_id (PK)
SHOW INDEX FROM orders;       -- must have idx on customer_id (FK)
SHOW INDEX FROM order_items;  -- must have idx on order_id and product_id

-- Step 2: add missing indexes
ALTER TABLE orders      ADD INDEX idx_customer_id (customer_id);
ALTER TABLE order_items ADD INDEX idx_order_id    (order_id);
ALTER TABLE order_items ADD INDEX idx_product_id  (product_id);

-- Step 3: use EXPLAIN to verify the optimizer uses your indexes
EXPLAIN
SELECT c.email, o.total
FROM customers AS c
JOIN orders    AS o ON c.customer_id = o.customer_id
WHERE c.country = 'Canada';
-- Good output: type=ref, key=idx_customer_id for the orders row
Practical E-commerce Report

SQL
-- Top 10 customers by lifetime value (delivered orders only)
SELECT
  c.customer_id,
  CONCAT(c.first_name, ' ', c.last_name)     AS customer_name,
  c.email,
  COUNT(DISTINCT o.order_id)                 AS total_orders,
  SUM(oi.quantity)                           AS total_items_purchased,
  ROUND(SUM(oi.quantity * oi.unit_price), 2) AS lifetime_value
FROM customers   AS c
JOIN orders      AS o  ON c.customer_id  = o.customer_id
JOIN order_items AS oi ON o.order_id     = oi.order_id
WHERE o.status = 'delivered'
GROUP BY c.customer_id, customer_name, c.email
ORDER BY lifetime_value DESC
LIMIT 10;

-- Product performance: revenue and unique buyers per product (last 90 days)
SELECT
  p.product_id,
  p.name                                     AS product,
  cat.name                                   AS category,
  SUM(oi.quantity)                           AS units_sold,
  ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue,
  COUNT(DISTINCT o.customer_id)              AS unique_buyers,
  ROUND(AVG(oi.unit_price), 2)               AS avg_selling_price
FROM products    AS p
JOIN categories  AS cat ON p.category_id  = cat.category_id
JOIN order_items AS oi  ON p.product_id   = oi.product_id
JOIN orders      AS o   ON oi.order_id    = o.order_id
WHERE o.status = 'delivered'
  AND o.created_at >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY p.product_id, product, cat.category_id, category
ORDER BY revenue DESC
LIMIT 25;
Debugging INNER JOIN — Why Fewer Rows Than Expected?

The most common INNER JOIN debugging question: "My query returned fewer rows than I expected. Where did the missing rows go?"

The answer is always the same: a row is missing because it has no match in one of the joined tables. To find the culprit, switch each JOIN to a LEFT JOIN one at a time and look for NULLs in the columns from that table.

SQL
-- Debugging: which orders have no matching customer? (data integrity issue)
SELECT o.order_id, o.customer_id, c.first_name
FROM orders      AS o
LEFT JOIN customers AS c ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL;   -- orders with no matching customer row

-- Debugging: which order_items have no matching order?
SELECT oi.item_id, oi.order_id, o.order_id AS matched_order
FROM order_items AS oi
LEFT JOIN orders AS o ON oi.order_id = o.order_id
WHERE o.order_id IS NULL;
INNER JOIN vs Other Join Types

Scenario

Use this JOIN

I want only rows with matches in both tables

INNER JOIN

I want all left-table rows, NULLs for missing right-table data

LEFT JOIN

I want to find left rows with NO match on the right

LEFT JOIN + WHERE right.pk IS NULL

I want all possible combinations of two tables

CROSS JOIN

I need to compare rows within the same table

SELF JOIN

  1. Use INNER JOIN (or just JOIN) when related data is guaranteed to exist

  2. Index every foreign key column — this is the single biggest JOIN performance lever

  3. Use EXPLAIN to confirm the optimizer is using your indexes

  4. Switch to LEFT JOIN when debugging to find rows with missing partners

  5. Avoid implicit comma-separated FROM syntax — it hides accidental cartesian products