Basic Joins in PostgreSQL
Learn to combine rows from two or more tables with basic SQL joins in PostgreSQL. This tutorial covers inner and left joins, step-by-step examples, and common pitfalls—plus what to study next.
Focus: query data with basic joins
You've got users in one table and orders in another, and you need a report that shows who bought what. If you've ever tried to pull that data by running two separate queries and stitching the results together in Python, you know the pain: it's slow, error-prone, and a nightmare to keep in sync. This lesson teaches you how to query data with basic joins in PostgreSQL, so you can combine rows from multiple tables in a single, efficient, and readable SQL statement.
The problem this lesson solves
Relational databases store data in separate, normalized tables to avoid duplication. A users table holds customer names and emails, an orders table holds purchase records with a user_id column that references the users table. To answer a question like "What did each customer order last month?", you need to bring those rows together.
Without joins, you'd write two queries:
SELECT * FROM users;
SELECT * FROM orders WHERE user_id = 42;
Then you'd loop through the results in your application code, match them manually, and handle missing pairs. That approach breaks down fast: it multiplies round trips, bloats application logic, and makes debugging a chore. Worse, it doesn't scale — a report over thousands of users becomes a waterfall of queries.
Joins solve this by letting the database do the matching inside a single query. You declare the relationship (ON users.id = orders.user_id) and PostgreSQL efficiently combines the rows for you. The result is a unified result set you can filter, sort, and aggregate in one shot.
Core concept / mental model
Think of a join as a virtual table factory. You give it two or more source tables and a rule for how rows relate, and it produces a new combined table on the fly — no data is physically copied or stored.
A useful analogy: imagine two printed lists — one of customers, one of orders. A join is like placing them side by side and drawing lines between matching entries based on a key (usually id). The type of join decides which lines you keep:
- INNER JOIN: keep only rows that have a match on both sides.
- LEFT JOIN: keep all rows from the left table, and fill in
NULLfor the right side when there's no match.
Here's the mental model in a nutshell:
- Inner join = intersection of the two tables.
- Left join = everything from the left table, plus whatever matches from the right.
Another way to visualize it: the key columns are the "glue". Without a proper ON condition, you get a cartesian product — every combination of rows, which is rarely what you want.
How it works step by step
Writing a basic join in PostgreSQL follows a predictable pattern:
- Identify the tables — decide which tables hold the data you need.
- Find the relationship — locate the column that links them (e.g.,
orders.user_id→users.id). - Choose the join type — inner if you only want matched rows, left if you need all rows from the primary table.
- Write the
JOINclause —FROM table_a JOIN table_b ON table_a.key = table_b.key. - Select your columns — qualify ambiguous column names with table aliases.
- Add filters, sorting, or aggregation — extend the query with
WHERE,ORDER BY,GROUP BY, etc.
For example, to list all orders with customer names:
SELECT orders.id, users.name, orders.amount
FROM orders
JOIN users ON orders.user_id = users.id;
Here's how it executes conceptually:
- PostgreSQL scans the
orderstable (the left side). - For each order row, it looks up the matching
usersrow using theONcondition. - It combines the columns from both rows into a single result row.
- If no match exists, an inner join drops the row; a left join keeps it with
NULLfor user columns.
The process is optimized with indexes — if you have an index on orders.user_id, lookups become fast even on large tables.
Hands-on walkthrough
Let's set up a small example database. Suppose you have a users table and an orders table.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
product TEXT NOT NULL,
amount DECIMAL(10,2)
);
INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Carol');
INSERT INTO orders (user_id, product, amount) VALUES
(1, 'Laptop', 1200.00),
(1, 'Mouse', 25.50),
(2, 'Monitor', 300.00),
(NULL, 'Keyboard', 45.00);
Inner join example
SELECT users.name, orders.product, orders.amount
FROM users
JOIN orders ON orders.user_id = users.id
ORDER BY users.name;
Expected output:
name | product | amount
-------+---------+---------
Alice | Laptop | 1200.00
Alice | Mouse | 25.50
Bob | Monitor | 300.00
Notice: Carol appears in users but has no orders, so she's missing. The NULL user order (Keyboard) is also excluded — inner join only returns matches.
Left join example
To include all users, even those without orders:
SELECT users.name, orders.product, orders.amount
FROM users
LEFT JOIN orders ON orders.user_id = users.id
ORDER BY users.name;
Expected output:
name | product | amount
-------+---------+---------
Alice | Laptop | 1200.00
Alice | Mouse | 25.50
Bob | Monitor | 300.00
Carol | NULL | NULL
Carol now appears with NULLs because she has no matching orders.
Joining with aliases and filters
Tables get long names in real life; aliases keep queries readable. Here's a query that finds orders over $100 and shows the customer name:
SELECT u.name, o.product, o.amount
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
WHERE o.amount > 100;
Expected output:
name | product | amount
-------+---------+---------
Alice | Laptop | 1200.00
Bob | Monitor | 300.00
Pro tip: Always use aliases when joining more than two tables; it saves typing and avoids ambiguity.
Compare options / when to choose what
Basic joins come in flavors. Here's how to pick:
| Join type | What it returns | Use when |
|---|---|---|
INNER JOIN |
Only rows with matches on both sides | You want only records that have complete relationships |
LEFT JOIN |
All rows from left table, NULL for missing right-side matches |
You need every row from the primary table, even if related data is absent |
RIGHT JOIN |
All rows from right table, NULL for missing left-side matches |
Rare; flip the table order and use LEFT JOIN instead |
FULL OUTER JOIN |
All rows from both tables, NULL for missing counterparts |
You need to see all data from both sides, including unmatched rows |
For most reporting, left join is more common than inner join because you often want to see entities that have no related records (e.g., customers with no orders). Inner join is typical for filtering out incomplete data.
Variations and alternatives:
USINGclause — if the join column has the same name in both tables, you can writeJOIN ... USING (user_id)for brevity.- Natural join —
NATURAL JOINautomatically joins on all columns with the same name. It's concise but risky: you don't control the join keys, and schema changes can silently break queries. - Subqueries instead of joins — sometimes a correlated subquery can express the same logic, but joins are usually faster and clearer for combining rows.
Troubleshooting & edge cases
Even basic joins trip people up. Here are the usual culprits:
-
Ambiguous column names: If both tables have a column named
idand you writeSELECT id, PostgreSQL throwscolumn reference "id" is ambiguous. Fix: qualify with the table name or alias, e.g.,users.id. -
Missing matches vanish: With an inner join, you silently lose rows that don't match. If you expected everyone to appear, double-check your join type — switch to
LEFT JOINif needed. -
Duplicate rows appear unexpectedly: If the join key isn't unique on one side, you get multiple matches. For example, if
orders.user_idisn't indexed and there are duplicate references, you'll see repeated rows. UseSELECT DISTINCTor aggregate to deduplicate, but first ask why the data model allows that. -
NULLjoin keys cause rows to disappear: Iforders.user_idisNULL, an inner join won't find a match, and the row vanishes. A left join would keep it. Decide whetherNULLmeans "unknown customer" or "guest order" and handle accordingly. -
Cartesian product explosions: Forgetting the
ONclause results in every row of one table joined to every row of the other. That's rarely what you want and can return millions of rows. Always include the join condition. -
Performance pitfalls: Joining on non-indexed columns turns into a sequential scan. Add indexes on foreign keys for large tables:
CREATE INDEX idx_orders_user_id ON orders(user_id);.
What you learned & what's next
You now grasp why joins exist, how to write inner and left joins, how to use aliases and filters, and how to troubleshoot common issues. You can explain the core idea behind combining tables with basic joins and complete a practical exercise to query data across related tables.
In the next lesson, you'll expand this foundation with aggregations and grouping — summing amounts, counting orders per customer, and filtering with HAVING. That's where joins really shine for reporting.
Keep practicing: create your own tables, insert sample data, and run different join types until you can predict the output without executing.
Practice recap
Create your own two tables, e.g., employees and departments. Insert a few rows, including some employees without a department. Write an inner join and a left join, and observe the difference in output. Then add an ORDER BY and a WHERE filter to reinforce the concepts.
Common mistakes
- Forgetting the
ONclause, which produces a cartesian product of every row from both tables. - Using
INNER JOINwhen you need to preserve all rows from the primary table — the missing rows just disappear. - Referencing a column without qualifying it when both tables share the same name, leading to an ambiguous-column error.
- Joining on a column without an index, which causes slow sequential scans on large tables.
- Expecting
LEFT JOINto fill missing values with defaults — you getNULL, not a placeholder.
Variations
- Use
USING (user_id)when the join column has the same name in both tables to shorten theONcondition. - Substitute a correlated subquery for a join when you need a single value from the related table, but joins are usually faster.
- Use a
NATURAL JOINfor brevity, but beware it joins on all same-named columns, which can change behavior unexpectedly.
Real-world use cases
- Generate a sales report that shows each customer's purchase history by joining a
customerstable with anorderstable. - Display a list of articles in a blog with their author names, even if some articles have no author assigned.
- Produce a list of all employees and their department names, highlighting those without a department using a left join.
Key takeaways
- Joins combine rows from multiple tables in a single SQL query, avoiding manual data stitching in application code.
INNER JOINreturns only matching rows;LEFT JOINkeeps all rows from the left table.- Always qualify column names with table aliases to prevent ambiguous references.
- A missing
ONclause causes a cartesian product — always specify the join condition. - Index foreign key columns to keep joins fast on large datasets.
- Choosing the right join type depends on whether you need unmatched rows preserved.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.