Skip to main content

INNER JOIN and Join Keys

Level 2: Level 2: Aggregation & Joins (Combining Source Data)medium30 minINNER JOINONjoin keystable aliasesqualifying columns

Combine two source tables on a matching key.

Real data lives in many tables

Real source data is never in one table. A raw e-commerce feed splits orders (who bought, when, total) from customers (name, region, email) from products (name, category, price). To answer "revenue by customer region" you must first stitch these back together on their shared keys. That stitching is a join, and the everyday workhorse is the INNER JOIN: it returns rows where a key in one table matches a key in the other, and drops everything with no match on either side.

Worked example: attach each order to its customer

SELECT
  o.order_id,
  o.total_cents,
  c.customer_name
FROM orders AS o
INNER JOIN customers AS c
  ON o.customer_id = c.customer_id;

Read it as: for each orders row, find the customers row whose customer_id equals this order's customer_id, and glue their columns side by side. An order with no matching customer, or a customer with no orders, does not appear. That's the "inner" part.

Press play to watch each order find its customer, and see exactly why order 103 and Alan drop out:

orders · INNER JOIN · customers ON customer_id = customer_id
Step 1 / 4
orders
order_idcustomer_idtotal_cents
10012500
10125000
10219900
10391500
customers
customer_idcustomer_name
matched: 1Ada Lovelace
2Grace Hopper
3Alan Turing

customer_id=1 matches 1 customers row.

Result (1 row)
orders.order_idorders.customer_idorders.total_centscustomers.customer_idcustomers.customer_name
100125001Ada Lovelace
Each order matches its customer on customer_id. Order 103 (customer 9) has no match, so INNER JOIN drops it; Alan (customer 3) has no orders, so he never appears.

Anatomy of a join

FROM   orders     AS o          ← left table, aliased 'o'
INNER JOIN customers AS c       ← right table, aliased 'c'
  ON   o.customer_id = c.customer_id
       └─── join key: the column(s) that relate the two tables ───┘
SELECT o.order_id, c.customer_name
       └── qualify columns with the alias so 'customer_id' isn't ambiguous ──┘

Three habits that make joins readable and correct:

  1. Alias every table (orders AS o). Short aliases keep the ON and SELECT legible.
  2. Qualify every column (o.order_id, not order_id). The instant two tables share a column name, an unqualified reference is ambiguous and errors.
  3. Name the join key deliberately. The ON clause is the contract: "these two rows describe the same thing."

Cardinality: the concept that separates a DE from a query monkey

Before you join, know the relationship between the tables:

  • N:1 (many-to-one): each order points at exactly one customer, but a customer has many orders, so orders to customers is the canonical many-to-one join. (A true 1:1 is rarer: each row on one side matches at most one on the other, like users to user_profiles.)
  • 1:N (one-to-many): one order has many order_items. Joining orders to order_items multiplies each order row by its number of line items.
  • M:N (many-to-many): needs a bridge table (you'll model these in Level 3).

Why this matters: a 1:N join fans out rows, and a fan-out inflates a SUM. If you join orders to order_items and then SUM(orders.total_cents), you sum each order's total once per line item. A 3-item order counts its total three times. The revenue triples and looks plausible, which is how bad numbers ship. The fix is to know your grain: after a fan-out join, aggregate the line-level measure (SUM(quantity * unit_price_cents)), never the pre-aggregated header total.

Keep it readable / common pitfall

Forgetting the ON clause (or writing ,-separated tables with the join condition in WHERE) can produce a cross join: every row paired with every row, N×M rows. If your result set is suspiciously huge, you dropped or weakened a join key. Always join on the full key; a partial key silently fans out.

Recap: INNER JOIN … ON key returns only matching rows from both tables; alias and qualify everything, and always know the cardinality: a 1:N join fans out rows and will double-count any header-level SUM.

Sample data for this example
CREATE TABLE customers (
  customer_id   INTEGER PRIMARY KEY,
  customer_name TEXT
);
CREATE TABLE orders (
  order_id    INTEGER PRIMARY KEY,
  customer_id INTEGER,
  total_cents INTEGER
);
INSERT INTO customers VALUES
  (1, 'Ada Lovelace'),
  (2, 'Grace Hopper'),
  (3, 'Alan Turing');
INSERT INTO orders VALUES
  (100, 1, 2500),
  (101, 2, 5000),
  (102, 1, 9900),
  (103, 9, 1500);   -- customer_id 9 does not exist, dropped by INNER JOIN
Worked example (SQL)
SELECT
  o.order_id,
  o.total_cents,
  c.customer_name
FROM orders AS o
INNER JOIN customers AS c
  ON o.customer_id = c.customer_id;

Apply

Your turn

The task this lesson builds to.

Write a query that returns order_id, total_cents, and customer_name, one row per order that has a matching customer, sorted by order_id. Inner-join orders to customers on customer_id.

4 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 3 bonus drills.

The analytics team needs a line-item fact preview that ties each sale back to its order and its product. Write a query that returns one row per order item with columns order_item_id, order_id, product_name, category, order_status, and line_revenue (that item's quantity * unit_price_cents), sorted by order_item_id. Build it by inner-joining order_items to orders on order_id and then to products on product_id, so an item with no matching order or no matching product drops out.

Because this is a 1:N chain, prove to yourself the grain stays at the line-item level: the output row count must equal the number of order_items rows that have a matching order and a matching product (inner joins on both). Do not sum anything; this is a preview at line grain.

4 hints and 1 automated check are waiting in the workspace.