Tutorial

SQL JOINs Explained with Practical Examples

A practical guide to SQL JOINs (INNER, LEFT, RIGHT, FULL OUTER) using simple examples, covering common pitfalls like fan-out traps and anti-joins.

Anuj SainiMar 18, 2026Updated Nov 3, 20266 min read

Per the official PostgreSQL Table Expressions Documentation, SQL JOIN clauses are the bedrock of relational database analysis, allowing you to create a single, coherent view from multiple, normalized tables. Mastering how each join type includes or excludes rows is essential for accurate reporting, from calculating customer order totals to identifying orphaned records in a financial audit.

This guide provides a practical, no-fluff walkthrough of the four primary SQL JOINs, using a simple, reproducible dataset to demonstrate exactly how each one works.

Hands-on Practice Sandbox (Pre-Loaded SQLite Database)

Run every query in this guide without installing a database:

How to run these queries immediately (Zero Installation):

  1. Open the free in-browser SQL runner: sqliteonline.com
  2. Drag and drop sql-joins-practice.db into the browser window (or click File -> Open DB).
  3. Paste any query from this guide and click Run to inspect results. (Alternatively, open the .db file locally in DB Browser for SQLite, DBeaver, or VS Code with the SQLite extension.)

Practice Dataset Preview

Our illustrative dataset contains two tables: customers with 4 unique customers and orders with 5 total orders. Notice that customer 'David' has no orders, and order 'O105' is a guest order with no matching customer.

customers (4 Rows)

customer_idcustomer_name
C1Alice
C2Bob
C3Charlie
C4David

orders (5 Rows)

order_idcustomer_idorder_total (Illustrative)
O101C1150
O102C2200
O103C250
O104C3300
O105C5100

INNER JOIN: Matching Rows Only

An INNER JOIN returns only the rows where the join key exists in both tables. It effectively filters out any records that do not have a match.

sql
SELECT
  c.customer_name,
  o.order_id,
  o.order_total
FROM customers c
INNER JOIN orders o
  ON c.customer_id = o.customer_id;

Result (4 Rows): An INNER JOIN on these tables returns 4 rows, showing only customers who have placed orders. It excludes customer 'David' (no orders) and guest order 'O105' (no matching customer).

customer_nameorder_idorder_total (Illustrative)
AliceO101150
BobO102200
BobO10350
CharlieO104300

LEFT JOIN: All Rows from the Left Table

A LEFT JOIN (or LEFT OUTER JOIN) returns all rows from the left table (customers) and any matching rows from the right table (orders). If a left-table row has no match, the right-side columns are filled with NULL.

sql
SELECT
  c.customer_name,
  o.order_id,
  o.order_total
FROM customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id;

Result (5 Rows): A LEFT JOIN returns 5 rows, including the customer 'David' who has no matching orders. The guest order 'O105' is still excluded because its key (C5) is not in the left customers table.

customer_nameorder_idorder_total (Illustrative)
AliceO101150
BobO102200
BobO10350
CharlieO104300
DavidNULLNULL

RIGHT JOIN: All Rows from the Right Table

A RIGHT JOIN (or RIGHT OUTER JOIN) is the mirror image of a LEFT JOIN. It returns all rows from the right table (orders) and any matching rows from the left table (customers).

sql
SELECT
  c.customer_name,
  o.order_id,
  o.order_total
FROM customers c
RIGHT JOIN orders o
  ON c.customer_id = o.customer_id;

Result (5 Rows): A RIGHT JOIN also returns 5 rows, but includes the guest order 'O105' which has no matching customer. Customer 'David' is now excluded because he has no record in the right orders table.

customer_nameorder_idorder_total (Illustrative)
AliceO101150
BobO102200
BobO10350
CharlieO104300
NULLO105100

FULL OUTER JOIN: All Rows from Both Tables

A FULL OUTER JOIN combines the results of both LEFT and RIGHT joins. It returns all rows from both tables, filling in NULLs on either side where matches do not exist. This is most useful for data reconciliation and auditing.

sql
SELECT
  c.customer_name,
  o.order_id,
  o.order_total
FROM customers c
FULL OUTER JOIN orders o
  ON c.customer_id = o.customer_id;

Result (6 Rows): A FULL OUTER JOIN returns all 6 unique rows from both tables, showing all customers and all orders. Both 'David' and the guest order 'O105' are included, with NULL values in their non-matching columns.

customer_nameorder_idorder_total (Illustrative)
AliceO101150
BobO102200
BobO10350
CharlieO104300
DavidNULLNULL
NULLO105100

JOIN Types Compared

JOIN TypeMatching RowsUnmatched Left RowsUnmatched Right RowsPrimary Use Case
INNER JOINIncludedExcludedExcludedFinding exact matches only.
LEFT JOINIncludedIncludedExcludedEnriching a primary table with optional data.
RIGHT JOINIncludedExcludedIncludedEnriching a secondary table (less common).
FULL OUTER JOINIncludedIncludedIncludedAuditing and reconciling two datasets.

Common Pitfall: The Deadly JOIN Fan-Out Trap

A frequent and dangerous error occurs when you join tables with a one-to-many relationship. This can cause rows from the "one" side to be duplicated, leading to massively inflated aggregate calculations.

Let's introduce a third table, customer_spend_limits, which has a one-to-one relationship with customers.

customer_spend_limits

customer_idcredit_limit (Illustrative)
C11000
C2500

Now, consider a query that joins all three tables to sum the total credit limit.

sql
-- TRAP QUERY: Incorrectly sums the duplicated credit_limit
SELECT
  SUM(sl.credit_limit) AS inflated_total_credit
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
LEFT JOIN customer_spend_limits sl ON c.customer_id = sl.customer_id;

Because customer 'Bob' (C2) has two orders, his credit_limit of 500 is duplicated in the joined result set. The query calculates 1000 (Alice) + 500 (Bob) + 500 (Bob again) = 2000, which is wrong.

The Fix: Aggregate Before Joining To prevent this, aggregate your measures to the correct grain before you perform the join.

sql
-- FIX QUERY: Aggregates metrics before the join
WITH customer_orders AS (
  SELECT customer_id FROM orders
)
SELECT
  SUM(sl.credit_limit) AS correct_total_credit
FROM customer_spend_limits sl
JOIN (SELECT DISTINCT customer_id FROM customer_orders) co
  ON sl.customer_id = co.customer_id;

This approach ensures each customer's credit limit is counted only once. The incorrect query inflates the total credit limit to an illustrative $2,000, while the correct total is only $1,500.

The Anti-Join Pattern: Finding Unmatched Rows

A common analytical task is to find records in one table that have no corresponding records in another. For example, which customers have never placed an order? This is called an "anti-join".

The most reliable way to perform an anti-join is with a LEFT JOIN and a WHERE ... IS NULL clause.

sql
SELECT
  c.customer_name
FROM customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;

Result (1 Row): This query correctly identifies 'David' as the only customer who does not appear in the orders table.

customer_name
David

Interactive Practice Challenge: Test Your Query

Open sql-joins-practice.db in sqliteonline.com and test your understanding with this real-world prompt:

Business Challenge: Write a query that returns each customer's name, total order count, and total spend across all orders. Customers with zero orders must be included with an order count of 0 and a total spend of 0.

Expected Output

customer_nameorder_counttotal_spend
Charlie1300
Bob2250
Alice1150
David00

Solution Query

sql
SELECT 
  c.customer_name,
  COUNT(o.order_id) AS order_count,
  COALESCE(SUM(o.order_total), 0) AS total_spend
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_name
ORDER BY total_spend DESC;

Why this works: A LEFT JOIN retains 'David'. Using COUNT(o.order_id) correctly evaluates to 0 because o.order_id is NULL, while COALESCE(SUM(...), 0) replaces the NULL sum with 0.

Advance Your SQL Mastery

Move from basic joins to complex multi-table analytics, execution plan tuning, and edge-case handling in our interactive course.

Explore SQL Advanced Analytics

Frequently Asked Questions

What are the 4 main types of SQL JOINs?

The four main types are INNER JOIN (returns only matching rows), LEFT JOIN (returns all rows from the left table and matching rows from the right), RIGHT JOIN (returns all rows from the right table and matching rows from the left), and FULL OUTER JOIN (returns all rows from both tables).

What is the most common type of SQL JOIN?

The LEFT JOIN is the most commonly used join in data analytics and business reporting. It allows analysts to start with a primary table (like customers) and enrich it with optional data from a secondary table (like orders) without losing any records from the primary table.

How do you find records that don't have a match in another table?

Use an Anti-Join pattern. This is typically done with a `LEFT JOIN` followed by a `WHERE` clause that filters for `NULL` on the right table's key (e.g., `LEFT JOIN orders ON customers.id = orders.customer_id WHERE orders.id IS NULL`).

Anuj Saini

Written by

Anuj SainiFounder & Lead Instructor

Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.