All posts
PostgreSQLSeptember 22, 20267 min read

PostgreSQL JOIN Explained for Beginners (Examples)

PostgreSQL JOIN explained for beginners with simple, copy-pasteable examples. Learn INNER JOIN, LEFT JOIN, and more using two tiny example tables.

F
Fepiq Team
Fepiq

If you searched for "postgresql join explained for beginners," here is the short answer: a JOIN lets you combine rows from two or more tables into one result, by matching a column in one table to a related column in another. This guide walks through it slowly, with two tiny example tables you can copy into your own database, so you can see exactly what each JOIN type does and why.

What is a JOIN in PostgreSQL?

In a real database, you rarely store everything in one giant table. Instead, you split related information into separate tables. For example, a shop might keep a customers table and a separate orders table, because one customer can place many orders. A JOIN is the SQL command that puts those two tables back together for a query, matching rows where a shared value (like a customer ID) lines up.

This guide assumes you already know the basics of SELECT and WHERE. If you don't yet, read our PostgreSQL SELECT guide first, then come back here.

The Sample Tables We'll Use

We'll build two small tables: customers and orders. Every order belongs to one customer, so the orders table stores a customer_id that points back to a row in customers. Run this in psql or any PostgreSQL client to follow along.

sql
CREATE TABLE customers (
  customer_id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL
);

Line by line: CREATE TABLE customers starts a new table named customers. customer_id SERIAL PRIMARY KEY makes an auto-incrementing whole number that uniquely identifies each customer (1, 2, 3, and so on). name TEXT NOT NULL and email TEXT NOT NULL store text and require a value — NOT NULL means "this field can't be left empty."

sql
CREATE TABLE orders (
  order_id SERIAL PRIMARY KEY,
  customer_id INTEGER REFERENCES customers(customer_id),
  product TEXT NOT NULL,
  amount NUMERIC(10,2)
);

The orders table has its own order_id primary key. The important line is customer_id INTEGER REFERENCES customers(customer_id) — this is a foreign key. It says "this column stores a number that must match a customer_id that already exists in the customers table." That link is what makes a JOIN possible. amount NUMERIC(10,2) stores a price with up to 10 digits total and 2 digits after the decimal point, which is the right type for money.

sql
INSERT INTO customers (name, email) VALUES
  ('Maria Lopez', 'maria@example.com'),
  ('Sam Chen', 'sam@example.com'),
  ('Priya Rao', 'priya@example.com');

INSERT INTO orders (customer_id, product, amount) VALUES
  (1, 'Keyboard', 45.00),
  (1, 'Mouse', 20.00),
  (2, 'Monitor', 199.99);

This adds three customers (their customer_id values will be 1, 2, and 3, assigned automatically) and three orders. Notice customer_id 1 (Maria) has two orders, customer_id 2 (Sam) has one order, and customer_id 3 (Priya) has none yet. Keep that in mind — it matters when we compare JOIN types below.

INNER JOIN: Matching Rows Only

INNER JOIN — usually just written JOIN — returns only the rows where the match actually exists in both tables. Any customer with zero orders, like Priya, will not appear at all.

sql
SELECT customers.name, orders.product, orders.amount
FROM customers
INNER JOIN orders
  ON customers.customer_id = orders.customer_id;

SELECT customers.name, orders.product, orders.amount picks the columns we want to see, one from each table. FROM customers starts with the customers table. INNER JOIN orders adds the orders table. ON customers.customer_id = orders.customer_id is the matching rule: for every row, PostgreSQL checks whether a customer's customer_id equals an order's customer_id, and only keeps the pairs that match.

nameproductamount
Maria LopezKeyboard45.00
Maria LopezMouse20.00
Sam ChenMonitor199.99

Maria appears twice because she has two orders — a JOIN returns one row per match, not one row per customer. Priya doesn't appear at all, because INNER JOIN drops anything without a match.

LEFT JOIN: Keeping Every Row From the First Table

Sometimes you want every customer to show up, even ones with no orders yet — useful for finding customers who haven't bought anything. LEFT JOIN keeps every row from the "left" table (the one listed first, customers) whether or not it has a match.

sql
SELECT customers.name, orders.product
FROM customers
LEFT JOIN orders
  ON customers.customer_id = orders.customer_id;

This looks almost identical to the INNER JOIN query, just with LEFT JOIN instead of INNER JOIN. The result now includes Priya Rao, with product showing as NULL — PostgreSQL's way of saying "no matching value was found." Maria and Sam's rows look the same as before.

RIGHT JOIN and FULL JOIN, Briefly

RIGHT JOIN is the mirror image of LEFT JOIN: it keeps every row from the second ("right") table, filling in NULL where the first table has no match. FULL JOIN keeps every row from both tables, matched where possible and NULL where not. In practice, most beginners use INNER JOIN and LEFT JOIN far more often than RIGHT JOIN or FULL JOIN — you can usually rewrite a RIGHT JOIN as a LEFT JOIN by swapping the table order.

Join typeWhat it returns
INNER JOINOnly rows that match in both tables
LEFT JOINAll rows from the left (first) table, plus matches from the right; NULL when there's no match
RIGHT JOINAll rows from the right (second) table, plus matches from the left; NULL when there's no match
FULL JOINAll rows from both tables, matched where possible, NULL where not

A Shorter Syntax: JOIN ... USING

When the column you're matching on has the exact same name in both tables — here, customer_id in both customers and orders — you can use USING instead of ON, which is a little shorter.

sql
SELECT customers.name, orders.product
FROM customers
JOIN orders USING (customer_id);

JOIN orders USING (customer_id) tells PostgreSQL to match rows where both tables' customer_id column has the same value, without repeating customers.customer_id = orders.customer_id. It only works because we named the column customer_id in both tables — if the names were different (like id and customer_id), you'd need ON instead.

Not sure how primary keys and foreign keys work? That relationship is what makes every JOIN possible — read our beginner guide before going further.

Read the primary key & foreign key guide

Common Mistakes Beginners Make with JOINs

  • Forgetting the ON (or USING) condition — this silently creates a cross join, pairing every row with every other row and producing far too many results.
  • Mixing up LEFT and RIGHT. If you're unsure, use LEFT JOIN and put the table you care most about keeping first, after FROM.
  • Writing a plain column name, like name, when both tables have a column with that name — PostgreSQL will raise an "ambiguous column" error. Prefix it with the table name, like customers.name.
  • Assuming a JOIN removes duplicate rows automatically. It doesn't — a customer with three orders will appear three times unless you group or filter the results.

Practice Exercise

Using the same customers and orders tables from earlier, try writing a query that lists every customer's name along with the total amount they've spent, including customers who've spent nothing. That needs a LEFT JOIN plus SUM() and GROUP BY — both covered in our SQL aggregate functions guide.

sql
SELECT customers.name, COALESCE(SUM(orders.amount), 0) AS total_spent
FROM customers
LEFT JOIN orders USING (customer_id)
GROUP BY customers.name;

COALESCE(SUM(orders.amount), 0) adds up each customer's order amounts, and swaps in 0 if SUM() would otherwise return NULL (which happens for customers with no orders). GROUP BY customers.name collapses each customer's rows into a single summary row.

Frequently asked questions

What is the difference between JOIN and INNER JOIN in PostgreSQL?+

There isn't one — they're the same operation. Writing JOIN by itself defaults to INNER JOIN. Some people write INNER JOIN explicitly so readers immediately know which type of join it is, but PostgreSQL treats the two exactly alike.

Can I join more than two tables in PostgreSQL?+

Yes. Add another JOIN clause with its own ON condition for each extra table, one after another. PostgreSQL processes them in order, so by the third or fourth JOIN your query can combine data from many related tables in a single SELECT.

What happens if I forget the ON clause in a JOIN?+

Without ON (or USING), PostgreSQL has no matching rule, so it pairs every row in the first table with every row in the second table. This is called a cross join, and on real tables it can produce an enormous, mostly meaningless result very quickly.

Do the columns I'm joining on need to have the same name?+

No, not with a regular JOIN ... ON — you can compare customers.customer_id to orders.buyer_id if that's how your columns are named. Matching names are only required for the shorthand JOIN ... USING or NATURAL JOIN syntax.

Is a LEFT JOIN slower than an INNER JOIN in PostgreSQL?+

Not meaningfully, in most cases. Query speed depends far more on whether the columns you're joining on have an index than on which JOIN type you choose. For small learning projects like the one in this guide, you won't notice any difference at all.

Want the general, database-agnostic version of this topic? Our SQL JOIN guide covers the same ideas with a broader lens.

Read the SQL JOIN guide

Building something real with PostgreSQL and need a hand? Tell us what you're working on.

Get in touch

Get new posts in your inbox

Occasional, no-fluff notes on shipping modern software — startups, automation, Laravel, Shopify and more. No spam, unsubscribe anytime.

Keep reading

Related posts

All posts

Let's build something

Ready to ship your next product with Fepiq?

Book a free discovery call. We'll listen, ask sharp questions, and send you a proposal within 3 business days.