Cloud Explainers

Every SQL Join Type in 10 Minutes

Databases & SQL9 min videoPublished Nov 8, 2026

Every SQL join type, run for real in PostgreSQL on a tiny tea shop: four customers and five orders. Inner join keeps matches (Ben vanishes, Mia shows twice), left and right keep one side's unmatched rows with NULLs, full keeps both, cross pairs everything, a self join reads one table twice, and an anti join finds customers who never ordered. Plus two traps: a WHERE that undoes a left join, and NOT IN returning nothing because of one NULL.

Every SQL Join Type in 10 Minutes, the full picture: diagram of Ben, Mia, Leo, Sara, Oolong, Sencha, Assam, Earl Grey, customers: the left table (named first), orders: the right table (named second)
The whole picture, from the video's recap.

Where did Ben go?

You joined two tables, and a customer just vanished from your report. Ana runs a small tea shop with four customers, and she wants each one next to the tea they ordered. Her query comes back with four rows. But Ben isn't there, and Mia shows up twice.

psql: Ana's tea shop
shop=# SELECT name, tea FROM customers c JOIN orders o ON o.customer_id = c.id; name |    tea
------+-----------
 Mia  | Sencha
 Mia  | Assam
 Leo  | Earl Grey
 Sara | Sencha
(4 rows)

Nothing is broken. The join did exactly what that kind of join does. There are seven kinds of join you'll meet in SQL, and each one decides differently which rows survive. By the end you'll know all seven, and why one popular way to find customers who never ordered returns nothing at all.

A question on Stack Overflow asks exactly this: what's the difference between an inner join and an outer join? Asked in 2008, it's been viewed more than two point seven million times. Here's the whole answer, run for real.

One question on Stack Overflow: figure of 2,732,939 views, source: Stack Overflow, asked 2008

Inner join

First, the two tables. Customers: Mia, Leo, Sara and Ben, each with an id. Orders: five of them. Each order keeps the id of the customer who placed it. Order 105 has none: NULL, meaning no value, a guest who checked out without an account.

psql: Ana's tea shop
shop=# SELECT * FROM customers; id | name | referred_by
----+------+-------------
  1 | Mia  |        NULL
  2 | Leo  |           1
  3 | Sara |           1
  4 | Ben  |           2
(4 rows)
psql: Ana's tea shop
shop=# SELECT * FROM orders; id  | customer_id |    tea
-----+-------------+-----------
 101 |           1 | Sencha
 102 |           1 | Assam
 103 |           2 | Earl Grey
 104 |           3 | Sencha
 105 |        NULL | Oolong
(5 rows)

An inner join pairs rows that match. For each order, it finds the customer whose id equals the order's customer id. Mia placed two orders, so she appears twice. Leo once, Sara once. Ben has no orders, and the guest order has no customer. With nothing to pair with, both drop out. That is also how a join multiplies rows: one customer with two orders becomes two rows.

The join itself is one line. Join, on its own, means inner join. Then the second table, orders, nicknamed o. And after on, the rule that decides a match: the order's customer id equals the customer's id.

The line that makes it a join: diagram of JOIN orders o ON o.customer_id = c.id, JOIN = inner join, orders o = the right table, o.customer_id = c.id = the match rule, c is customers, named first: FROM customers c

Real systems are built on this same shape. GitLab runs on PostgreSQL, and its own developer guide joins the projects table to the namespaces table, the users and groups that own each project, with exactly this kind of match rule.

GitLab's guide: the same shape, at scale: diagram of projects, namespaces, GitLab's PostgreSQL

Left join

But Ana wanted every customer, whether they ordered or not. That's a left join. One word changed. 5 rows now. Ben is back, and where his tea would be, PostgreSQL fills in NULL.

psql: Ana's tea shop
shop=# SELECT name, tea FROM customers c LEFT JOIN orders o ON o.customer_id = c.id; name |    tea
------+-----------
 Mia  | Sencha
 Mia  | Assam
 Leo  | Earl Grey
 Sara | Sencha
 Ben  | NULL
(5 rows)

A left join keeps every row of the left table, the one named first, and adds matches from the right when there are any. No match? The row stays anyway, with NULLs in the right side's columns. The guest order sits on the right, so it still drops out.

Those NULLs matter for everything that follows. NULL isn't zero, and it isn't empty text. It means no value at all: unknown. SQL can't say whether an unknown value equals anything, not even another NULL, so any comparison with NULL is never true.

What NULL means: cards of Not zero, Not empty text, No value: unknown, Never equal

Here's the trap that catches almost everyone. Ana wants every customer, plus any Sencha they bought, so she adds where tea equals Sencha. Two rows. Leo and Ben are gone again. Now she moves that condition into the on part instead. All four customers are back, with Sencha where they bought it.

psql: Ana's tea shop
shop=# SELECT name, tea FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE tea = 'Sencha'; name |  tea
------+--------
 Mia  | Sencha
 Sara | Sencha
(2 rows)
psql: Ana's tea shop
shop=# SELECT name, tea FROM customers c LEFT JOIN orders o ON o.customer_id = c.id AND tea = 'Sencha'; name |  tea
------+--------
 Mia  | Sencha
 Leo  | NULL
 Sara | Sencha
 Ben  | NULL
(4 rows)

Why? PostgreSQL's documentation says it plainly: a condition in on is applied before the join, where is applied after it. After the left join, Ben's tea is NULL, and NULL never equals Sencha, so where throws his row away. The left join quietly turned back into an inner join.

The order PostgreSQL works in: diagram of JOIN... ON, WHERE, SELECT

Right join

What about the guest order? A right join is the mirror image: it keeps every row of the right table, the one named second. Now the Oolong order is in, with NULL for the name, and Ben is out. Swap the two tables, use a left join, and you get the same five rows.

psql: Ana's tea shop
shop=# SELECT name, tea FROM customers c RIGHT JOIN orders o ON o.customer_id = c.id; name |    tea
------+-----------
 Mia  | Sencha
 Mia  | Assam
 Leo  | Earl Grey
 Sara | Sencha
 NULL | Oolong
(5 rows)
psql: Ana's tea shop
shop=# SELECT name, tea FROM orders o LEFT JOIN customers c ON o.customer_id = c.id; name |    tea
------+-----------
 Mia  | Sencha
 Mia  | Assam
 Leo  | Earl Grey
 Sara | Sencha
 NULL | Oolong
(5 rows)

So a right join keeps every order, matched or not. Many people rarely write one. They put the table they want to keep first, and use a left join, because a query reads more easily when the table you keep comes first. Both give the same rows; pick one style and stick to it.

Full outer join

Now keep everyone from both sides. That's a full outer join. Customers has four rows, orders has five. Will it add them up? Guess first: how many rows will it return? Six. The four matched pairs, Ben with no order, and the guest order with no customer.

Here it is in PostgreSQL. Every row from both tables, at least once, with NULL wherever the other side had nothing. A full join is how you line up two lists, like orders against payments: the NULLs show what's missing on each side.

psql: Ana's tea shop
shop=# SELECT name, tea FROM customers c FULL JOIN orders o ON o.customer_id = c.id; name |    tea
------+-----------
 Mia  | Sencha
 Mia  | Assam
 Leo  | Earl Grey
 Sara | Sencha
 NULL | Oolong
 Ben  | NULL
(6 rows)

Cross join

A cross join has no match rule at all. Every row on one side meets every row on the other. Ana uses one on purpose, to build her menu: four teas, three cup sizes. Every tea comes in every size. Guess first: how many menu lines? Twelve: four times three. PostgreSQL's documentation puts it the same way: N rows times M rows.

Cross join: every tea meets every size: diagram of Sencha, Assam, Earl Grey, Oolong, small, medium, large, teas, sizes

In SQL it's cross join, and out come 12 rows, every tea in every size. The same thing happens by accident when a query lists two tables with a comma and forgets the match rule. Four customers times five orders: 20 rows, and no error. With a thousand customers and ten thousand orders, that's ten million.

psql: Ana's tea shop
shop=# SELECT tea, size FROM teas CROSS JOIN sizes;    tea    |  size
-----------+--------
 Sencha    | small
 Sencha    | medium
 Sencha    | large
 Assam     | small
 Assam     | medium
 Assam     | large
 Earl Grey | small
 Earl Grey | medium
 Earl Grey | large
 Oolong    | small
 Oolong    | medium
 Oolong    | large
(12 rows)
psql: Ana's tea shop
shop=# SELECT count(*) FROM customers, orders; count
-------
    20
(1 row)

Self join

A self join isn't a new keyword: it's a table joined to itself. Ana's customers table has a referred by column: the id of the customer who told them about the shop. Leo came through Mia, and so did Sara. Ben came through Leo. To turn that id into a name, the query reads the same table twice, under two nicknames. You'll meet this shape anywhere a row points at another row of the same table: an employee and their manager, a reply and the comment it answers.

One table, read twice: diagram of Mia, Leo, Sara, Ben, customers

c is the customer, r is the referrer, and the rule pairs them when r's id equals c's referred by. Three rows. Mia found the shop on her own, so this inner self join leaves her out; a left self join would keep her, with NULL.

psql: Ana's tea shop
shop=# SELECT c.name, r.name AS referred_by FROM customers c JOIN customers r ON r.id = c.referred_by; name | referred_by
------+-------------
 Leo  | Mia
 Sara | Mia
 Ben  | Leo
(3 rows)

Anti and semi join

The last one turns Ana's question around: which customers have never ordered? That's an anti join: keep the rows on the left that have no match on the right. This is the list Ana would send a welcome discount to. SQL has no anti join keyword, so you write it one of a few ways.

One: a left join, keeping only the rows where the order side came back as NULL. That leaves Ben. Two: not exists, keep a customer when no order points at them. Again, it's Ben.

psql: Ana's tea shop
shop=# SELECT name FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL; name
------
 Ben
(1 row)
psql: Ana's tea shop
shop=# SELECT name FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id); name
------
 Ben
(1 row)

Flip it, exists instead of not exists, and you get its opposite, a semi join: the customers who did order, each one listed once. Mia isn't doubled, the way the inner join doubled her at the start.

And three, the popular one from the start: not in. Customers whose id is not in the list of customer ids on orders. Zero rows. Not even Ben.

psql: Ana's tea shop
shop=# SELECT name FROM customers WHERE id NOT IN (SELECT customer_id FROM orders); name
------
(0 rows)

The guest order put a NULL into that list. To keep Ben, SQL must be sure his id, four, differs from every value in it. One, two, three: different. But is four different from NULL, an unknown value? SQL can't say. PostgreSQL's documentation spells it out: with a NULL in the list, not in is never true. Use not exists instead.

Is Ben's id not in the list?: diagram of 4 NOT IN (1, 1, 2, 3, NULL), 4 = Ben's id, 1, 1, 2, 3 = different, NULL = unknown, With a NULL in the list, NOT IN is never true: use NOT EXISTS

Recap

Seven joins, one question: which rows do you keep? Inner keeps matches. Left, right and full keep unmatched rows from one side or both, with NULLs. Cross keeps every pair, self reads one table twice, and anti keeps what has no match.

Which rows each join keeps: comparison table of Join vs Keeps (Ana's tea shop)

Every query and the lab files are linked in the description, to run yourself. If seeing all seven run for real helped, a like helps YouTube find other beginners stuck on joins, and subscribing is free.

Every query here ran for real on PostgreSQL 18. Thanks for watching Cloud Explainers.

Sources

Demos: one PostgreSQL container on our own Mac; the tea shop and its customers are made up. Watch the full video on YouTube.

More videos

Linux9 min

Linux Makefiles Will Finally Make Sense After This

Auth (OAuth, JWT)9 min

SAML vs OAuth vs OpenID Connect: What's the Difference?

Python7 min

How to Fix Python cron Jobs That Never Run

Linux22 min

Linux Bash Scripting: Full Tutorial for Beginners

Networking10 min

How PXE Network Boot Works

Active Directory9 min

How Active Directory Works

Linux10 min

How to Fix a Linux systemd Service That Won't Start

Linux9 min

Linux Logs: syslog vs journald

Auth (OAuth, JWT)9 min

JWT vs Session Cookies: Which One Should You Use?

Docker10 min

Docker vs Docker Compose: Which One Should You Use?

Docker9 min

How to Fix Docker Compose Containers That Can't Talk to Each Other

Django (Python web framework)22 min

Django Full Tutorial for Beginners

Linux9 min

How Linux systemd Works

Networking8 min

How to Fix 'Windows Cannot Access' Network Share Errors (SMB)

APIs10 min

REST vs gRPC: Which One Should You Use?

Certificates & PKI (ADCS)9 min

How to Fix SSL Certificate Chain Errors (Missing Intermediate)

Python9 min

Every Python Concept for Beginners in 10 Minutes

OpenRouter (one API for many AI models)9 min

Why OpenRouter Free Models Hit Their Limit So Fast

Gemini API & Google AI Studio9 min

Why Your Gemini API Free Tier Runs Out So Fast

OpenRouter (one API for many AI models)10 min

OpenRouter Will Finally Make Sense After This

Caching & Redis10 min

How Redis Caching Works

Gemini API & Google AI Studio10 min

Gemini API vs Vertex AI: Which One Should You Use?

Gemini API & Google AI Studio8 min

How to Fix a Leaked Gemini API Key

Go (Golang)9 min

Why Golang Uses Goroutines Instead of Threads

Gemini API & Google AI Studio9 min

How to Fix Gemini API Error 429: Resource Exhausted

Claude & the Claude Developer Platform10 min

Claude Token Pricing Will Finally Make Sense After This

Python11 min

Why Python Web Scraping Returns Nothing (and the Fix)

Python10 min

Python uv vs pip: Which One Should You Use?

Python22 min

Python Automation: Full Tutorial for Beginners

Auth (OAuth, JWT)9 min

How OAuth Login Works

Gemini API & Google AI Studio10 min

Google AI Studio Will Finally Make Sense After This

Docker10 min

What Happens When You Run a Docker Container

Claude & the Claude Developer Platform8 min

How to Fix Claude Code Billing Your API Key Instead of Your Plan

Auth (OAuth, JWT)9 min

Why Passkeys Can't Be Phished Like Passwords

PowerShell10 min

PowerShell vs Bash: Which One Should You Learn?

Linux10 min

The Linux Roadmap for Beginners

Django (Python web framework)9 min

Django vs FastAPI: Which One Should You Use?

Databases & SQL10 min

Why SQLite Is the Most Used Database in the World

Databases & SQL11 min

How a NoSQL Database Like MongoDB Works

Python11 min

How to Fix Python pip error: externally-managed-environment

Databases & SQL9 min

How to Fix MySQL Too Many Connections Database Errors

Go (Golang)9 min

Golang Concurrency Will Finally Make Sense After This

AWS10 min

How Amazon S3 Works

Kubernetes10 min

How Kubernetes Works

Git10 min

How Git Works

Docker10 min

How to Fix Docker Port Already Allocated Errors

PostgreSQL10 min

How PostgreSQL Works

Git8 min

How to Fix Git Merge Conflicts

Linux9 min

How to Fix No Space Left on Device in Linux

Networking10 min

How the Internet Works

Databases & SQL9 min

SQL vs NoSQL: When Each One Wins

Auth (OAuth, JWT)8 min

How to Fix JWT Token Expired Errors (401 Unauthorized)

Claude & the Claude Developer Platform9 min

Claude API vs Claude Pro: Why Your Subscription Doesn't Include It

AI agent skills9 min

Why AI Agent Skills Beat Giant Prompts

Linux10 min

Linux Cron Jobs Will Finally Make Sense After This

Cybersecurity10 min

Security Logs Will Finally Make Sense After This

Cybersecurity8 min

How to Fix a Security Leak: An API Key Pushed to GitHub

DNS9 min

DNS Will Finally Make Sense After This

Cybersecurity9 min

Never Ignore a Security Update

Auth (OAuth, JWT)9 min

Two-Factor Authentication Will Finally Make Sense

Networking8 min

Never Expose a Database to the Internet

Cybersecurity10 min

SSH Keys vs Passwords: Which Is Safer for Security?

Linux10 min

The 5 Levels of Linux Boot: From Power On to Login

Networking12 min

The 5 Levels of Network Firewalls

Cybersecurity10 min

Password Security: Why Hackers Crack Weak Passwords

Linux9 min

Why Your Linux Server Runs Out of Memory

Networking10 min

HTTP vs HTTPS: What Actually Changes on Your Network

Linux12 min

Every Linux File Permission You Need to Know

Linux10 min

Linux Processes vs Threads: When Each One Wins

Networking11 min

The OSI Model Will Finally Make Sense After This

Git11 min

The 7 Levels of Git

Networking10 min

Every Network Troubleshooting Command You Need

Cybersecurity10 min

Hashing vs Encryption in Cybersecurity: When Each One Wins

Networking11 min

Why the Internet Ran Out of IP Addresses

Linux11 min

Linux Package Managers: apt vs Snap vs Flatpak

Python9 min

Why Python Is Slow (and When It Doesn't Matter)

Networking11 min

TCP vs UDP: When Each One Wins

Linux8 min

Stop Running Linux as Root (Do This Instead)

Linux11 min

Linux Users and Groups Will Finally Make Sense

Cybersecurity9 min

Why Phishing Beats Good Security (and How to Spot It)

Docker12 min

The 7 Levels of Docker

AI agents & MCP12 min

AI Agents Will Finally Make Sense After This

Git8 min

Stop Using git push --force (Use This Instead)

AI agent skills10 min

Agent Skills vs MCP: When Each One Wins

Networking12 min

Networking Will Finally Make Sense After This

Linux12 min

Every Linux Command You Actually Need

Cybersecurity12 min

The 7 Levels of Cybersecurity

System design12 min

Every System Design Concept You Need, in One Diagram

PostgreSQL10 min

Why PostgreSQL Ignores Your Index