Introduction to Joins
A well-normalized database rarely keeps everything in one table. Customer details live in a customers table, their orders live in an orders table, the items on each order live in yet another table, and so on. Splitting data this way avoids repeating the same customer name or address on every single order row — which is exactly what normalization is designed to do. But once data is spread across tables, you need a way to bring it back together for reporting and everyday queries. That is what a join does.
A join combines rows from two (or more) tables based on a related column between them — typically a primary key in one table and a matching foreign key in the other. Instead of manually looking up a customer, then separately looking up their orders, and stitching the results together in application code, a join lets the database engine do that matching in a single query.
The general syntax pattern
Every join follows roughly the same shape: pick the columns you want, name the two tables, and describe how rows in one relate to rows in the other.
General join syntax
SELECT columns FROM tableA JOIN tableB ON tableA.matching_column = tableB.matching_column;
The ON clause is the heart of the join — it tells the database exactly which rows in tableA correspond to which rows in tableB. Get the ON clause wrong (or forget it entirely) and you either get a query error, a result set full of nonsense matches, or — as you will see in the CROSS JOIN page — every possible combination of rows from both tables.
The example schema used throughout this section
To keep every join example concrete and easy to compare, the rest of the pages in this section reuse the same two small tables: customers and orders. A customer can place zero, one, or many orders, and every order belongs to exactly one customer — a classic one-to-many relationship linked by customer_id.
Reference schema: customers and orders
CREATE TABLE customers ( customer_id INT PRIMARY KEY, name VARCHAR(50), city VARCHAR(50) ); CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT, amount DECIMAL(10, 2), order_date DATE, FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ); INSERT INTO customers (customer_id, name, city) VALUES (1, 'Alice', 'New York'), (2, 'Bob', 'Boston'), (3, 'Carol', 'Chicago'), (4, 'Dave', 'Denver'); INSERT INTO orders (order_id, customer_id, amount, order_date) VALUES (101, 1, 250.00, '2024-01-05'), (102, 1, 75.50, '2024-02-10'), (103, 2, 300.00, '2024-01-20'), (104, 3, 120.00, '2024-02-15'), (105, 99, 45.00, '2024-03-01');
Notice two intentional wrinkles that show up in the join pages ahead: Dave (customer_id 4) has placed no orders at all, and order 105 references customer_id 99, which does not exist in the customers table. Real-world data is messy in exactly these ways, and different join types handle that messiness differently.
customers | orders |
|---|---|
1 — Alice — New York | 101 — cust 1 — 250.00 |
2 — Bob — Boston | 102 — cust 1 — 75.50 |
3 — Carol — Chicago | 103 — cust 2 — 300.00 |
4 — Dave — Denver (no orders) | 104 — cust 3 — 120.00 |
105 — cust 99 — 45.00 (orphan order) |
The join types covered in this section
Join type | What it returns |
|---|---|
INNER JOIN | Only rows that have a match in both tables. |
LEFT (OUTER) JOIN | All rows from the left table, plus matches from the right (NULL when there is none). |
RIGHT (OUTER) JOIN | All rows from the right table, plus matches from the left (NULL when there is none). |
FULL OUTER JOIN | All rows from both tables, with NULLs wherever a side has no match. |
CROSS JOIN | Every row from table A paired with every row from table B (Cartesian product). |
SELF JOIN | A table joined to itself, used to compare rows within the same table. |
Joins let you query across normalized tables as if the data lived in one place.
The ON clause defines how rows from each table are related.
This section reuses one customers/orders schema so you can compare join types directly.
Some rows are deliberately unmatched (Dave has no orders, order 105 has no customer) to illustrate how each join type behaves differently.