This episode covers the concept of joins in relational databases: INNER JOIN, LEFT RIGHT and FULL OUTER JOIN, CROSS JOIN, as well as SELF JOIN for hierarchical data, including techniques for combining three or more tables in a single query.

Welcome to episode 6 of the Learn SQL with PostgreSQL series! So far we've learned to manage a single table: creating it, filling it, and processing it. But now it's time for relational databases to show their true power: combining data from many tables. This is what distinguishes a relational database from a mere collection of spreadsheet files.
Imagine you have an orders table and a customers table. An order only stores customer_id, not the customer's full name. To display "Order #123 belonging to Budi Santoso", you have to combine both tables based on the foreign key relationship we built in episode 2. That's the job of JOIN.
In this episode, we'll cover the concept of joins thoroughly: INNER JOIN to take matching data, LEFT and RIGHT OUTER JOIN to preserve one side, FULL OUTER JOIN for all data, CROSS JOIN for Cartesian combinations, and SELF JOIN for hierarchical data. We'll also learn to combine three or more tables in a single query.
JOIN is an operation that combines rows from two tables based on a specific condition — usually a foreign key match. The result is new rows combining columns from both tables.
The easiest visualization is the circle diagram:
SELECT *
FROM orders
JOIN customers ON orders.customer_id = customers.id;The ON clause determines the joining condition — here it matches customer_id on orders with id on customers. The JOIN keyword itself is identical to INNER JOIN.
Tip
Because joined columns often share the same name (e.g. both have an id column), get in the habit of using table aliases: FROM orders o JOIN customers c ON o.customer_id = c.id. The query becomes shorter and column ambiguity disappears.
INNER JOIN is the most common type of join. It only returns rows that have a match in both tables. Orders without a matching customer won't appear.
SELECT
o.id AS order_id,
c.full_name AS customer,
o.total
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id
ORDER BY o.total DESC;LEFT JOIN takes ALL rows from the left table, plus the matching data from the right table. If there's no match, the right columns are filled with NULL. This is the most common pattern for finding "data without a partner":
SELECT c.full_name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.full_name;Notice: customers who have never ordered will still appear with a order_id of NULL. This answers the query "show all customers along with their orders — including those who have never ordered".
SELECT c.full_name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;The LEFT JOIN + WHERE right side IS NULL pattern is a classic idiom for finding left rows that have no partner.
RIGHT JOIN is the mirror image of LEFT JOIN: all right rows are preserved, left is filled with NULL if there's no match. FULL JOIN combines both: all rows from both sides, with NULL on the side that doesn't match.
SELECT c.full_name, o.id AS order_id
FROM orders o
RIGHT JOIN customers c ON o.customer_id = c.id;
SELECT c.full_name, o.id AS order_id
FROM customers c
FULL JOIN orders o ON o.customer_id = c.id;Note
In practice, LEFT JOIN covers 95% of production join needs — you just place the "main" table on the left side. RIGHT JOIN can be replaced by swapping the table positions, and FULL JOIN is rarely used except for data auditing (finding data that exists on only one side).
CROSS JOIN produces the combination of every row in the left table with every row in the right table — without an ON condition. If the left table has 10 rows and the right has 5, the result is 50 rows.
SELECT p.name, s.size_label
FROM products p
CROSS JOIN product_sizes s;The example above is suitable for generating a product-per-size list. But be careful: CROSS JOIN can explode easily — a 1000×1000 table produces 1 million rows.
Warning
CROSS JOIN is one of the most common causes of "hanging" queries in production — forgetting to write ON on a regular join will silently turn it into a CROSS JOIN in some databases, or produce wrong results. Always check that every JOIN has a correct ON condition.
SELF JOIN joins a table with itself. This odd-sounding name is very useful for hierarchical data — e.g. an employees table with a manager_id column pointing to another employee, or categories that have a parent category.
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
manager_id INTEGER REFERENCES employees(id)
);To display each employee's name along with their manager's name, we join the table with itself using different aliases:
SELECT
e.name AS karyawan,
m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id
ORDER BY m.name NULLS FIRST;Notice two things: the same table is joined twice with the aliases e (employee) and m (manager), and we use LEFT JOIN so that employees without a manager (the CEO) still appear. This SELF JOIN pattern is also used for multi-level category structures and social networks ("friends of friends" relationships).
The real world rarely involves only two tables. An order consists of an order header, order items, and products. To display the product name on each order, we combine three tables at once:
SELECT
o.id AS order_id,
c.full_name AS customer,
p.name AS product,
oi.quantity
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.id = 123;Each JOIN is added sequentially, and each has an ON condition connecting its new table to the previous result. There's no hard limit on the number of joins, but the more joins, the higher the execution cost — in episode 15 we'll learn to analyze their performance.
Tip
To avoid inflated results (accidental duplicates) in multi-joins, think about the direction of the relationship first. If one order has 3 items, joining orders → order_items will multiply the order rows into 3. That's when aggregations like SUM(oi.quantity) often get summed multiple times over. Make sure you understand the data structure before summing.
| # | Mistake | Symptom | Solution |
|---|---|---|---|
| 1 | Join without ON | Exploding results (Cartesian) | Always write an ON condition |
| 2 | Using INNER JOIN when "empty" data is needed | Rows without a partner disappear | Switch to LEFT JOIN |
| 3 | Forgetting table aliases on ambiguous columns | Error column reference is ambiguous | Alias the tables and write table.column |
| 4 | Summing after a multi-join | Inflated aggregate numbers | Aggregate before joining, or understand the data structure |
In this episode 6 we've mastered joins in all their forms: INNER JOIN for matching data, LEFT/RIGHT/FULL JOIN to preserve one or both sides, CROSS JOIN for Cartesian combinations, SELF JOIN for hierarchical data, and the multi-table join technique for combining many tables in a single query.
Key takeaways:
INNER JOIN takes the intersection; LEFT JOIN preserves the left table.LEFT JOIN ... WHERE right side IS NULL pattern finds data without a partner.SELF JOIN is the key to hierarchical data (employee-manager, multi-level categories).ON condition — without it the query becomes a dangerous CROSS JOIN.In episode 7, we'll expand our query capabilities: Subqueries, Set Operations & Expressions — from subqueries in WHERE, FROM, and SELECT, the CASE WHEN branching logic, NULL-handling functions like COALESCE and NULLIF, to the set operations UNION, INTERSECT, and EXCEPT. You'll see just how expressive SQL really is.