SQL FULL OUTER JOIN and CROSS JOIN
You have now mastered INNER JOIN and LEFT JOIN, which together handle the vast majority of real-world SQL queries. But SQL offers two more join types that complete the picture: FULL OUTER JOIN and CROSS JOIN. These are specialized tools that you will use less frequently, but when you need them, nothing else will do.
FULL OUTER JOIN returns all rows from both tables, matching where it can and filling in NULLs where it cannot. It reveals unmatched data on both sides simultaneously. CROSS JOIN takes a completely different approach: it pairs every row from one table with every row from another, producing a cartesian product. No matching condition, no filtering, just every possible combination.
This guide explains both join types, shows you exactly when they are useful (and when they are dangerous), walks through practical examples, and clarifies why most developers rarely need them compared to INNER JOIN and LEFT JOIN. Every example uses the ShopSmart sample database with full outputs (we defined it in a previous guide here, and then we extended it here).
FULL OUTER JOIN Explained
A FULL OUTER JOIN (also written as FULL JOIN) combines the behavior of LEFT JOIN and RIGHT JOIN. It returns:
- All rows from the left table, with NULLs for unmatched right columns
- All rows from the right table, with NULLs for unmatched left columns
- All matched rows from both tables combined
SELECT columns
FROM table_a
FULL OUTER JOIN table_b ON table_a.column = table_b.column;
In other words, no row is ever excluded. Every row from both tables appears in the results, regardless of whether it has a match.
Visualizing FULL OUTER JOIN
┌───────────────┐ ┌───────────────┐
│ │ │ │
│ table_a │ │ table_b │
│ ┌──┼─────┼──┐ │
│ unmatched │ │ │ │ unmatched │
│ left rows │ matched│ │ right rows │
│ │ rows │ │ │
│ INCLUDED │ INCLUDED │ INCLUDED │
│ (NULLs on │ │ │ (NULLs on │
│ right) └──┼─────┼──┘ left) │
│ │ │ │
└───────────────┘ └───────────────┘
◄────── FULL OUTER JOIN returns everything ──────►
Compare this with the other join types:
| Join Type | Left unmatched | Matched rows | Right unmatched |
|---|---|---|---|
INNER JOIN | Excluded | Included | Excluded |
LEFT JOIN | Included (NULLs on right) | Included | Excluded |
RIGHT JOIN | Excluded | Included | Included (NULLs on left) |
FULL OUTER JOIN | Included (NULLs on right) | Included | Included (NULLs on left) |
Your First FULL OUTER JOIN
To demonstrate FULL OUTER JOIN properly, we need data with unmatched rows on both sides. Let us create a scenario using temporary tables:
-- Create a table of product promotions (some products don't have promotions)
CREATE TABLE promotions (
id INTEGER PRIMARY KEY,
product_id INTEGER,
discount_pct DECIMAL(5, 2),
promo_name VARCHAR(100),
FOREIGN KEY (product_id) REFERENCES products(id)
);
INSERT INTO promotions VALUES (1, 1, 15.00, 'Mouse Madness');
INSERT INTO promotions VALUES (2, 2, 10.00, 'Keyboard Special');
INSERT INTO promotions VALUES (3, 6, 20.00, 'Kitchen Sale');
INSERT INTO promotions VALUES (4, 11, 25.00, 'Mystery Product Deal');
-- product_id 11 doesn't exist in our products table (for demonstration)
We intentionally inserted a promotion referencing product_id = 11, which does not exist in the products table. In a real database with foreign key constraints, this insert would fail. We are simulating a scenario where the constraint is missing or the data comes from an external source.
Now let us see what FULL OUTER JOIN reveals:
SELECT p.id AS product_id,
p.name AS product_name,
pr.id AS promo_id,
pr.promo_name,
pr.discount_pct
FROM products p
FULL OUTER JOIN promotions pr ON p.id = pr.product_id
ORDER BY p.id;
Output:
| product_id | product_name | promo_id | promo_name | discount_pct |
|---|---|---|---|---|
| 1 | Wireless Mouse | 1 | Mouse Madness | 15.00 |
| 2 | Mechanical Keyboard | 2 | Keyboard Special | 10.00 |
| 3 | USB-C Hub | NULL | NULL | NULL |
| 4 | SQL for Beginners | NULL | NULL | NULL |
| 5 | Data Science Handbook | NULL | NULL | NULL |
| 6 | Coffee Maker Pro | 3 | Kitchen Sale | 20.00 |
| 7 | Yoga Mat Premium | NULL | NULL | NULL |
| 8 | Running Shoes X1 | NULL | NULL | NULL |
| 9 | Bluetooth Speaker | NULL | NULL | NULL |
| 10 | Stainless Water Bottle | NULL | NULL | NULL |
| NULL | NULL | 4 | Mystery Product Deal | 25.00 |
Three distinct groups are visible in the results:
- Matched rows (products 1, 2, 6): Both product and promotion data are present
- Left-only rows (products 3, 4, 5, 7, 8, 9, 10): Products without promotions, promotion columns are NULL
- Right-only row (promo 4): A promotion referencing a non-existent product, product columns are NULL
This is the unique power of FULL OUTER JOIN: it shows you everything, including mismatches on both sides.
Understanding Each Result Group
-- See only the matched rows (same as INNER JOIN)
SELECT p.name, pr.promo_name
FROM products p
FULL OUTER JOIN promotions pr ON p.id = pr.product_id
WHERE p.id IS NOT NULL AND pr.id IS NOT NULL;
Output:
| name | promo_name |
|---|---|
| Wireless Mouse | Mouse Madness |
| Mechanical Keyboard | Keyboard Special |
| Coffee Maker Pro | Kitchen Sale |
-- See only products WITHOUT promotions (left-only)
SELECT p.name AS product_without_promotion
FROM products p
FULL OUTER JOIN promotions pr ON p.id = pr.product_id
WHERE pr.id IS NULL;
Output:
| product_without_promotion |
|---|
| USB-C Hub |
| SQL for Beginners |
| Data Science Handbook |
| Yoga Mat Premium |
| Running Shoes X1 |
| Bluetooth Speaker |
| Stainless Water Bottle |
-- See only promotions WITHOUT valid products (right-only, orphaned data)
SELECT pr.promo_name AS orphaned_promotion,
pr.product_id AS invalid_product_id
FROM products p
FULL OUTER JOIN promotions pr ON p.id = pr.product_id
WHERE p.id IS NULL;
Output:
| orphaned_promotion | invalid_product_id |
|---|---|
| Mystery Product Deal | 11 |
When to Use FULL OUTER JOIN
FULL OUTER JOIN is a specialized tool. Here are the scenarios where it genuinely shines.
Data Reconciliation
When comparing two datasets that should match but might have discrepancies on either side:
-- Compare expected inventory (from a spreadsheet import) with actual products
-- Find items in one list but not the other
CREATE TABLE expected_products (
sku VARCHAR(20) PRIMARY KEY,
name VARCHAR(100),
expected_stock INTEGER
);
INSERT INTO expected_products VALUES ('WM-001', 'Wireless Mouse', 150);
INSERT INTO expected_products VALUES ('MK-001', 'Mechanical Keyboard', 75);
INSERT INTO expected_products VALUES ('HP-001', 'Headphones Pro', 40);
CREATE TABLE actual_inventory (
sku VARCHAR(20) PRIMARY KEY,
name VARCHAR(100),
actual_stock INTEGER
);
INSERT INTO actual_inventory VALUES ('WM-001', 'Wireless Mouse', 148);
INSERT INTO actual_inventory VALUES ('MK-001', 'Mechanical Keyboard', 75);
INSERT INTO actual_inventory VALUES ('UC-001', 'USB-C Hub', 200);
SELECT COALESCE(e.sku, a.sku) AS sku,
COALESCE(e.name, a.name) AS product,
e.expected_stock,
a.actual_stock,
CASE
WHEN e.sku IS NULL THEN 'In inventory but not expected'
WHEN a.sku IS NULL THEN 'Expected but not in inventory'
WHEN e.expected_stock = a.actual_stock THEN 'Match'
ELSE 'Stock mismatch'
END AS status
FROM expected_products e
FULL OUTER JOIN actual_inventory a ON e.sku = a.sku
ORDER BY sku;
Output:
| sku | product | expected_stock | actual_stock | status |
|---|---|---|---|---|
| HP-001 | Headphones Pro | 40 | NULL | Expected but not in inventory |
| MK-001 | Mechanical Keyboard | 75 | 75 | Match |
| UC-001 | USB-C Hub | NULL | 200 | In inventory but not expected |
| WM-001 | Wireless Mouse | 150 | 148 | Stock mismatch |
This immediately shows four types of situations: matches, mismatches, items missing from inventory, and unexpected items in inventory. No other join type can reveal all four in a single query.
Finding Orphaned Records
When data integrity might be compromised (perhaps foreign keys were not enforced or data was imported from external sources):
-- Find products without categories AND categories without products
SELECT COALESCE(cat.name, '[No Category]') AS category,
COALESCE(p.name, '[No Product]') AS product,
CASE
WHEN cat.id IS NULL THEN 'Product missing category'
WHEN p.id IS NULL THEN 'Empty category'
ELSE 'OK'
END AS status
FROM categories cat
FULL OUTER JOIN products p ON cat.id = p.category_id
ORDER BY category, product;
Output:
| category | product | status |
|---|---|---|
| Books | Data Science Handbook | OK |
| Books | SQL for Beginners | OK |
| Electronics | Bluetooth Speaker | OK |
| Electronics | Mechanical Keyboard | OK |
| Electronics | USB-C Hub | OK |
| Electronics | Wireless Mouse | OK |
| Home & Kitchen | Coffee Maker Pro | OK |
| Sports | Running Shoes X1 | OK |
| Sports | Stainless Water Bottle | OK |
| Sports | Yoga Mat Premium | OK |
| Toys | [No Product] | Empty category |
The Toys category has no products. In a well-maintained database, this might be intentional. In a data migration scenario, it might indicate missing data.
Comparing Two Versions of Data
When you need to detect what changed between two snapshots:
CREATE TABLE prices_last_month (
product_id INTEGER,
price DECIMAL(10, 2)
);
CREATE TABLE prices_this_month (
product_id INTEGER,
price DECIMAL(10, 2)
);
INSERT INTO prices_last_month VALUES (1, 29.99), (2, 84.99), (3, 45.00), (5, 42.50);
INSERT INTO prices_this_month VALUES (1, 29.99), (2, 89.99), (3, 45.00), (4, 34.99);
SELECT COALESCE(lm.product_id, tm.product_id) AS product_id,
lm.price AS old_price,
tm.price AS new_price,
CASE
WHEN lm.product_id IS NULL THEN 'New product'
WHEN tm.product_id IS NULL THEN 'Removed product'
WHEN lm.price = tm.price THEN 'No change'
WHEN tm.price > lm.price THEN 'Price increased'
ELSE 'Price decreased'
END AS change_type
FROM prices_last_month lm
FULL OUTER JOIN prices_this_month tm ON lm.product_id = tm.product_id
ORDER BY product_id;
Output:
| product_id | old_price | new_price | change_type |
|---|---|---|---|
| 1 | 29.99 | 29.99 | No change |
| 2 | 84.99 | 89.99 | Price increased |
| 3 | 45.00 | 45.00 | No change |
| 4 | NULL | 34.99 | New product |
| 5 | 42.50 | NULL | Removed product |
This is a classic change-detection query that only FULL OUTER JOIN can handle cleanly.
FULL OUTER JOIN in MySQL: The Workaround
MySQL does not support FULL OUTER JOIN syntax. If you need this functionality in MySQL, you can simulate it by combining a LEFT JOIN and a RIGHT JOIN with UNION:
-- MySQL workaround for FULL OUTER JOIN
SELECT p.id AS product_id, p.name, pr.promo_name
FROM products p
LEFT JOIN promotions pr ON p.id = pr.product_id
UNION
SELECT p.id AS product_id, p.name, pr.promo_name
FROM products p
RIGHT JOIN promotions pr ON p.id = pr.product_id;
UNION combines both result sets and removes duplicates. The LEFT JOIN captures all left-table rows (including unmatched). The RIGHT JOIN captures all right-table rows (including unmatched). Together, they cover everything a FULL OUTER JOIN would return.
Use UNION (not UNION ALL) for this workaround. UNION removes duplicate rows that appear in both the LEFT JOIN and RIGHT JOIN results (the matched rows). UNION ALL would include those duplicates, giving you incorrect results.
CROSS JOIN Explained
CROSS JOIN is fundamentally different from every other join type. It does not match rows based on a condition. Instead, it produces the cartesian product: every row from the first table paired with every row from the second table.
SELECT columns
FROM table_a
CROSS JOIN table_b;
If table A has 5 rows and table B has 4 rows, the result has 5 x 4 = 20 rows, one for every possible combination.
There is no ON clause because there is no matching condition. Every row pairs with every other row unconditionally.
A Simple CROSS JOIN Example
SELECT c.name AS category, s.label AS status
FROM categories c
CROSS JOIN (
SELECT 'In Stock' AS label
UNION ALL SELECT 'Low Stock'
UNION ALL SELECT 'Out of Stock'
) s
ORDER BY c.name, s.label;
Output:
| category | status |
|---|---|
| Books | In Stock |
| Books | Low Stock |
| Books | Out of Stock |
| Electronics | In Stock |
| Electronics | Low Stock |
| Electronics | Out of Stock |
| Home & Kitchen | In Stock |
| Home & Kitchen | Low Stock |
| Home & Kitchen | Out of Stock |
| Sports | In Stock |
| Sports | Low Stock |
| Sports | Out of Stock |
| Toys | In Stock |
| Toys | Low Stock |
| Toys | Out of Stock |
5 categories x 3 statuses = 15 rows. Every category is paired with every status.
Visualizing CROSS JOIN
Category Table Status Values CROSS JOIN Result
┌────────────────┐ ┌───── ─────────┐ ┌────────────────┬──────────────┐
│ Books │ │ In Stock │ │ Books │ In Stock │
│ Electronics │ × │ Low Stock │ = │ Books │ Low Stock │
│ Home & Kitchen │ │ Out of Stock │ │ Books │ Out of Stock │
│ Sports │ └──────────────┘ │ Electronics │ In Stock │
│ Toys │ │ Electronics │ Low Stock │
└────────────────┘ │ Electronics │ Out of Stock │
│ ... (9 more) │ │
5 rows × 3 rows = │ │ │
└────────────────┴──────────────┘
15 rows
The Cartesian Product Danger
CROSS JOIN can produce enormous result sets with even modest tables:
| Table A Rows | Table B Rows | Result Rows |
|---|---|---|
| 10 | 10 | 100 |
| 100 | 100 | 10,000 |
| 1,000 | 1,000 | 1,000,000 |
| 10,000 | 10,000 | 100,000,000 |
Never run a CROSS JOIN between two large tables without understanding the result size. A CROSS JOIN between two tables with 10,000 rows each produces 100 million rows. This can consume all available memory, fill up disk space, and bring your database server to a halt.
-- DANGEROUS: If products has 10,000 rows and orders has 50,000 rows
-- This produces 500,000,000 rows
SELECT *
FROM products
CROSS JOIN orders;
-- DON'T DO THIS
Always calculate the expected result size (rows in A × rows in B) before running a CROSS JOIN.
Practical CROSS JOIN Use Cases
Despite its dangers, CROSS JOIN has legitimate and elegant use cases.
Generating a Report Framework
When you need a complete grid that includes every possible combination, even those with no data:
-- Create a grid of all categories × all months
-- Then LEFT JOIN actual data to find gaps
SELECT grid.category,
grid.month,
COALESCE(actual.order_count, 0) AS orders,
COALESCE(actual.revenue, 0) AS revenue
FROM (
-- Generate the complete grid
SELECT cat.name AS category, m.month
FROM categories cat
CROSS JOIN (
SELECT '2024-01' AS month
UNION ALL SELECT '2024-02'
UNION ALL SELECT '2024-03'
UNION ALL SELECT '2024-04'
) m
) grid
LEFT JOIN (
-- Calculate actual data
SELECT cat.name AS category,
TO_CHAR(o.order_date, 'YYYY-MM') AS month,
COUNT(DISTINCT o.id) AS order_count,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM categories cat
JOIN products p ON cat.id = p.category_id
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
GROUP BY cat.name, TO_CHAR(o.order_date, 'YYYY-MM')
) actual ON grid.category = actual.category AND grid.month = actual.month
ORDER BY grid.category, grid.month;
Output:
| category | month | orders | revenue |
|---|---|---|---|
| Books | 2024-01 | 0 | 0 |
| Books | 2024-02 | 1 | 34.99 |
| Books | 2024-03 | 1 | 34.99 |
| Books | 2024-04 | 0 | 0 |
| Electronics | 2024-01 | 2 | 194.97 |
| Electronics | 2024-02 | 0 | 0 |
| Electronics | 2024-03 | 1 | 45.00 |
| Electronics | 2024-04 | 4 | 314.96 |
| Home & Kitchen | 2024-01 | 0 | 0 |
| Home & Kitchen | 2024-02 | 0 | 0 |
| Home & Kitchen | 2024-03 | 1 | 129.99 |
| Home & Kitchen | 2024-04 | 0 | 0 |
| Sports | 2024-01 | 0 | 0 |
| Sports | 2024-02 | 1 | 24.99 |
| Sports | 2024-03 | 1 | 110.00 |
| Sports | 2024-04 | 4 | 345.98 |
| Toys | 2024-01 | 0 | 0 |
| Toys | 2024-02 | 0 | 0 |
| Toys | 2024-03 | 0 | 0 |
| Toys | 2024-04 | 0 | 0 |
Without the CROSS JOIN, months with zero activity would be missing from the results entirely. The CROSS JOIN generates every category-month combination (5 categories x 4 months = 20 rows), and the LEFT JOIN fills in actual data where it exists. This creates a complete, gap-free report perfect for charts and dashboards.
The pattern of CROSS JOIN to generate a framework + LEFT JOIN to fill in data is by far the most practical use of CROSS JOIN. It ensures your reports and charts have no missing rows, even when there is no data for certain combinations.
Size and Color Combinations for a Product
CREATE TABLE sizes (
id INTEGER PRIMARY KEY,
label VARCHAR(10)
);
CREATE TABLE colors (
id INTEGER PRIMARY KEY,
name VARCHAR(20)
);
INSERT INTO sizes VALUES (1, 'S'), (2, 'M'), (3, 'L'), (4, 'XL');
INSERT INTO colors VALUES (1, 'Red'), (2, 'Blue'), (3, 'Black');
-- Generate all possible size-color variants for a product
SELECT s.label AS size,
c.name AS color,
'Running Shoes X1 - ' || s.label || ' ' || c.name AS variant_name
FROM sizes s
CROSS JOIN colors c
ORDER BY s.id, c.id;
Output:
| size | color | variant_name |
|---|---|---|
| S | Red | Running Shoes X1 - S Red |
| S | Blue | Running Shoes X1 - S Blue |
| S | Black | Running Shoes X1 - S Black |
| M | Red | Running Shoes X1 - M Red |
| M | Blue | Running Shoes X1 - M Blue |
| M | Black | Running Shoes X1 - M Black |
| L | Red | Running Shoes X1 - L Red |
| L | Blue | Running Shoes X1 - L Blue |
| L | Black | Running Shoes X1 - L Black |
| XL | Red | Running Shoes X1 - XL Red |
| XL | Blue | Running Shoes X1 - XL Blue |
| XL | Black | Running Shoes X1 - XL Black |
4 sizes x 3 colors = 12 variants. This is a clean, legitimate use of CROSS JOIN for generating combinatorial data.
Date Series Generation
Generating a series of dates for reporting frameworks (PostgreSQL-specific for generate_series, but the concept applies everywhere):
-- PostgreSQL: Generate a date for every day in January 2024
SELECT d::date AS report_date,
COALESCE(order_data.order_count, 0) AS orders,
COALESCE(order_data.revenue, 0) AS revenue
FROM generate_series('2024-01-01'::date, '2024-01-31'::date, '1 day') d
LEFT JOIN (
SELECT order_date,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
GROUP BY order_date
) order_data ON d::date = order_data.order_date
WHERE COALESCE(order_data.order_count, 0) > 0
OR EXTRACT(DOW FROM d) BETWEEN 1 AND 5 -- Weekdays only
ORDER BY d
LIMIT 15;
While this uses generate_series instead of CROSS JOIN, the concept is identical: create a complete framework of dates, then LEFT JOIN actual data to fill in the values.
Comparing Every Product Pair
For recommendation engines or similarity analysis:
-- Find all pairs of products in the same category
SELECT p1.name AS product_a,
p2.name AS product_b,
p1.price AS price_a,
p2.price AS price_b,
ABS(p1.price - p2.price) AS price_difference
FROM products p1
CROSS JOIN products p2
WHERE p1.category_id = p2.category_id
AND p1.id < p2.id -- Avoid duplicates and self-pairs
ORDER BY price_difference ASC
LIMIT 10;
Output:
| product_a | product_b | price_a | price_b | price_difference |
|---|---|---|---|---|
| SQL for Beginners | Data Science Handbook | 34.99 | 42.5 | 7.509999999999998 |
| Yoga Mat Premium | Stainless Water Bottle | 38 | 24.99 | 13.010000000000002 |
| Wireless Mouse | USB-C Hub | 29.99 | 45 | 15.010000000000002 |
| USB-C Hub | Bluetooth Speaker | 45 | 65 | 20 |
| Mechanical Keyboard | Bluetooth Speaker | 89.99 | 65 | 24.989999999999995 |
| Wireless Mouse | Bluetooth Speaker | 29.99 | 65 | 35.010000000000005 |
| Mechanical Keyboard | USB-C Hub | 89.99 | 45 | 44.989999999999995 |
| Wireless Mouse | Mechanical Keyboard | 29.99 | 89.99 | 60 |
| Yoga Mat Premium | Running Shoes X1 | 38 | 110 | 72 |
| Running Shoes X1 | Stainless Water Bottle | 110 | 24.99 | 85.01 |
The WHERE p1.id < p2.id condition is critical. Without it, you would get both "Mouse vs Keyboard" and "Keyboard vs Mouse" (duplicates), plus "Mouse vs Mouse" (self-pairs).
Accidental CROSS JOINs
One of the most important things to understand about CROSS JOIN is that you can create one accidentally by forgetting the ON clause in an INNER JOIN or by using the older comma-separated table syntax without a WHERE condition:
-- Accidental CROSS JOIN: Forgot the ON clause (some databases allow this)
SELECT c.first_name, p.name
FROM customers c, products p;
-- 7 customers × 10 products = 70 rows of nonsense!
-- Accidental CROSS JOIN: Implicit join syntax with missing WHERE
SELECT c.first_name, o.order_date
FROM customers c, orders o;
-- 7 customers × 12 orders = 84 rows of nonsense!
The old-style comma-separated FROM syntax (FROM table_a, table_b) is equivalent to a CROSS JOIN when no WHERE clause connects the tables. This is the most common accidental cross join.
-- Implicit CROSS JOIN (dangerous old syntax)
SELECT c.first_name, o.order_date
FROM customers c, orders o;
-- Returns 84 rows (7 × 12), most of which are meaningless
-- Explicit INNER JOIN (correct modern syntax)
SELECT c.first_name, o.order_date
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;
-- Returns 12 rows, each correctly matched
Always use explicit JOIN syntax with an ON clause. Never use comma-separated tables unless you intentionally want a CROSS JOIN, and even then, use the explicit CROSS JOIN keyword for clarity.
Alternative CROSS JOIN Syntax
There are multiple ways to write a CROSS JOIN, all producing identical results:
-- Explicit CROSS JOIN keyword (recommended)
SELECT s.label, c.name
FROM sizes s
CROSS JOIN colors c;
-- Comma-separated tables (implicit cross join, older syntax)
SELECT s.label, c.name
FROM sizes s, colors c;
-- INNER JOIN with always-true condition (unusual but works)
SELECT s.label, c.name
FROM sizes s
INNER JOIN colors c ON 1 = 1;
The first form is the clearest and most maintainable. It makes your intent explicit: you deliberately want every combination.
Complete Join Type Reference
Here is a comprehensive comparison of all join types you have learned:
-- Setup for comparison: customers (7 rows) and orders (12 rows)
-- Grace Taylor has no orders
-- INNER JOIN: Only matched rows
SELECT c.first_name, o.id AS order_id
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;
-- Result: 12 rows (Grace excluded)
-- LEFT JOIN: All left rows + matched right rows
SELECT c.first_name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id;
-- Result: 13 rows (Grace included with NULL order)
-- RIGHT JOIN: Matched left rows + all right rows
SELECT c.first_name, o.id AS order_id
FROM customers c
RIGHT JOIN orders o ON c.id = o.customer_id;
-- Result: 12 rows (same as INNER here because FK ensures every order has a customer)
-- FULL OUTER JOIN: All rows from both tables
SELECT c.first_name, o.id AS order_id
FROM customers c
FULL OUTER JOIN orders o ON c.id = o.customer_id;
-- Result: 13 rows (Grace included, no orphaned orders in this case)
-- CROSS JOIN: Every combination
SELECT c.first_name, o.id AS order_id
FROM customers c
CROSS JOIN orders o;
-- Result: 84 rows (7 × 12)
Choosing the Right Join Type
Do you need EVERY possible combination?
├── Yes → CROSS JOIN (use with extreme caution)
└── No
├── Do you need unmatched rows from BOTH tables?
│ ├── Yes → FULL OUTER JOIN
│ └── No
│ ├── Do you need ALL rows from the primary table?
│ │ ├── Yes → LEFT JOIN (put primary table on left)
│ │ └── No → INNER JOIN
│ └── (RIGHT JOIN: Just use LEFT JOIN with swapped tables)
Practical Exercises
Exercise 1
Using FULL OUTER JOIN, write a query that shows all products and all promotions, indicating which products have promotions and which promotions reference valid products.
SELECT COALESCE(p.name, '[Unknown Product]') AS product,
COALESCE(pr.promo_name, '[No Promotion]') AS promotion,
pr.discount_pct,
CASE
WHEN p.id IS NOT NULL AND pr.id IS NOT NULL THEN 'Active Promotion'
WHEN p.id IS NOT NULL AND pr.id IS NULL THEN 'No Promotion'
ELSE 'Orphaned Promotion'
END AS status
FROM products p
FULL OUTER JOIN promotions pr ON p.id = pr.product_id
ORDER BY status, product;
| product | promotion | discout_pct | status |
|---|---|---|---|
| Coffee Maker Pro | Kitchen Sale | 20 | Active Promotion |
| Mechanical Keyboard | Keyboard Special | 10 | Active Promotion |
| Wireless Mouse | Mouse Madness | 15 | Active Promotion |
| Bluetooth Speaker | [No Promotion] | null | No Promotion |
| Data Science Handbook | [No Promotion] | null | No Promotion |
| Running Shoes X1 | [No Promotion] | null | No Promotion |
| SQL for Beginners | [No Promotion] | null | No Promotion |
| Stainless Water Bottle | [No Promotion] | null | No Promotion |
| USB-C Hub | [No Promotion] | null | No Promotion |
| Yoga Mat Premium | [No Promotion] | null | No Promotion |
| [Unknown Product] | Mystery Product Deal | 25 | Orphaned Promotion |
Exercise 2
Use CROSS JOIN to generate a grid of all categories and all order statuses, then show how many orders exist for each combination.
SELECT grid.category,
grid.status,
COALESCE(actual.order_count, 0) AS order_count
FROM (
SELECT cat.name AS category, s.status
FROM categories cat
CROSS JOIN (
SELECT DISTINCT status FROM orders
) s
) grid
LEFT JOIN (
SELECT cat.name AS category,
o.status,
COUNT(*) AS order_count
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN categories cat ON p.category_id = cat.id
GROUP BY cat.name, o.status
) actual ON grid.category = actual.category AND grid.status = actual.status
ORDER BY grid.category, grid.status;
| category | status | order_count |
|---|---|---|
| Books | completed | 1 |
| Books | pending | 1 |
| Books | shipped | 0 |
| Electronics | completed | 4 |
| Electronics | pending | 2 |
| Electronics | shipped | 2 |
| Home & Kitchen | completed | 0 |
| Home & Kitchen | pending | 0 |
| Home & Kitchen | shipped | 1 |
| Sports | completed | 3 |
| Sports | pending | 3 |
| Sports | shipped | 2 |
| Toys | completed | 0 |
| Toys | pending | 0 |
| Toys | shipped | 0 |
Exercise 3
Find all customers who have no orders AND all orders that somehow have no valid customer (if any exist), using FULL OUTER JOIN.
SELECT c.first_name || ' ' || c.last_name AS customer,
o.id AS order_id,
CASE
WHEN c.id IS NULL THEN 'Orphaned order'
WHEN o.id IS NULL THEN 'Customer without orders'
ELSE 'Matched'
END AS status
FROM customers c
FULL OUTER JOIN orders o ON c.id = o.customer_id
WHERE c.id IS NULL OR o.id IS NULL;
Expected output:
| customer | order_id | status |
|---|---|---|
| Grace Taylor | NULL | Customer without orders |
Exercise 4
Use CROSS JOIN to create all possible product pairs within the same price tier (Under $50, $50-$100, Over $100), excluding self-pairs.
SELECT p1.name AS product_a,
p2.name AS product_b,
CASE
WHEN p1.price < 50 THEN 'Under $50'
WHEN p1.price < 100 THEN '$50-$100'
ELSE 'Over $100'
END AS price_tier
FROM products p1
CROSS JOIN products p2
WHERE p1.id < p2.id
AND (
CASE WHEN p1.price < 50 THEN 1 WHEN p1.price < 100 THEN 2 ELSE 3 END
=
CASE WHEN p2.price < 50 THEN 1 WHEN p2.price < 100 THEN 2 ELSE 3 END
)
ORDER BY price_tier, product_a;
| category | status | order_count |
|---|---|---|
| Books | completed | 1 |
| Books | pending | 1 |
| Books | shipped | 0 |
| Electronics | completed | 4 |
| Electronics | pending | 2 |
| Electronics | shipped | 2 |
| Home & Kitchen | completed | 0 |
| Home & Kitchen | pending | 0 |
| Home & Kitchen | shipped | 1 |
| Sports | completed | 3 |
| Sports | pending | 3 |
| Sports | shipped | 2 |
| Toys | completed | 0 |
| Toys | pending | 0 |
| Toys | shipped | 0 |
Exercise 5
Create a monthly product review summary that includes months with zero reviews, using CROSS JOIN to generate the framework.
SELECT grid.product,
grid.month,
COALESCE(actual.review_count, 0) AS reviews,
COALESCE(actual.avg_rating, 0) AS avg_rating
FROM (
SELECT p.name AS product, p.id AS product_id, m.month
FROM products p
CROSS JOIN (
SELECT '2024-01' AS month
UNION ALL SELECT '2024-02'
UNION ALL SELECT '2024-03'
UNION ALL SELECT '2024-04'
) m
WHERE p.id IN (1, 2, 8)
) grid
LEFT JOIN (
SELECT product_id,
TO_CHAR(review_date, 'YYYY-MM') AS month,
COUNT(*) AS review_count,
ROUND(AVG(rating), 1) AS avg_rating
FROM reviews
GROUP BY product_id, TO_CHAR(review_date, 'YYYY-MM')
) actual ON grid.product_id = actual.product_id AND grid.month = actual.month
ORDER BY grid.product, grid.month;
| product | month | reviews | avg_rating |
|---|---|---|---|
| Mechanical Keyboard | 2024-01 | 1 | 5 |
| Mechanical Keyboard | 2024-02 | 1 | 4 |
| Mechanical Keyboard | 2024-03 | 1 | 3 |
| Mechanical Keyboard | 2024-04 | 0 | 0 |
| Running Shoes X1 | 2024-01 | 0 | 0 |
| Running Shoes X1 | 2024-02 | 0 | 0 |
| Running Shoes X1 | 2024-03 | 2 | 4.5 |
| Running Shoes X1 | 2024-04 | 1 | 5 |
| Wireless Mouse | 2024-01 | 2 | 4.5 |
| Wireless Mouse | 2024-02 | 1 | 5 |
| Wireless Mouse | 2024-03 | 0 | 0 |
| Wireless Mouse | 2024-04 | 0 | 0 |
Cleanup
Remove the temporary tables we created for the examples:
DROP TABLE IF EXISTS promotions;
DROP TABLE IF EXISTS sizes;
DROP TABLE IF EXISTS colors;
DROP TABLE IF EXISTS expected_products;
DROP TABLE IF EXISTS actual_inventory;
DROP TABLE IF EXISTS prices_last_month;
DROP TABLE IF EXISTS prices_this_month;
Key Takeaways
FULL OUTER JOIN and CROSS JOIN are specialized tools that complete your understanding of SQL joins. Here is what you should remember:
FULL OUTER JOINreturns all rows from both tables, filling NULLs where no match exists. No row from either table is ever excluded.- Use
FULL OUTER JOINfor data reconciliation, orphan detection, and comparing two datasets that might have mismatches on either side - MySQL does not support
FULL OUTER JOIN. Simulate it with aLEFT JOINUNIONRIGHT JOIN CROSS JOINproduces a cartesian product: every row from table A paired with every row from table B. NoONclause is used.- Result size of
CROSS JOIN= rows in A × rows in B. Always calculate this before running. - The most practical
CROSS JOINpattern is generating a report framework (all combinations of dimensions), thenLEFT JOINing actual data onto it CROSS JOINis useful for generating size/color combinations, date grids, product pairs, and other combinatorial data- Accidental cross joins happen when you use comma-separated tables without a
WHEREclause. Always use explicitJOINsyntax. - In daily work,
INNER JOINandLEFT JOINhandle 95%+ of all join needs. UseFULL OUTER JOINandCROSS JOINonly when their specific behavior is required. - The complete join hierarchy:
INNER(only matches) →LEFT/RIGHT(all from one side) →FULL OUTER(all from both sides) →CROSS(all combinations)
Understanding all four join types gives you the complete picture of how SQL combines data from multiple tables. For most queries, INNER JOIN and LEFT JOIN are your workhorses. For data quality checks and reconciliation, FULL OUTER JOIN is invaluable. For generating frameworks and combinations, CROSS JOIN is the right tool. Knowing when to reach for each one is what separates a competent SQL developer from a proficient one.