SQL CROSS JOIN: Syntax, Examples & Practice
SQL CROSS JOIN pairs every row of one table with every row of another. It has no ON condition, so nothing is matched or filtered: 3 rows joined to 2 rows give 6. The result is called the Cartesian product.
What Is a CROSS JOIN in SQL?
A shop sells a T-shirt in three sizes and two colours. To list every variant it has to stock, pair each size with each colour. That is a cross join:
SELECT s.size, c.colour
FROM sizes s
CROSS JOIN colours c;
Here is the same query on two small lists you can run. It is written with a comma between the tables, which means exactly the same thing as CROSS JOIN (see Syntax for why this page runs the comma form):
SELECT s.size, c.colour
FROM (SELECT 'S' AS size UNION ALL SELECT 'M' UNION ALL SELECT 'L') AS s,
(SELECT 'Red' AS colour UNION ALL SELECT 'Blue') AS c
ORDER BY s.size, c.colour;
▶ Run this query in the SQL Playground
| size | colour |
|---|---|
| L | Blue |
| L | Red |
| M | Blue |
| M | Red |
| S | Blue |
| S | Red |
Three sizes times two colours is six rows. No size was "matched" to a colour; every size simply met every colour once. Other joins start from this same set of pairs and then keep only the ones that satisfy ON. A cross join keeps them all.
CROSS JOIN Syntax
SELECT columns
FROM table_a
CROSS JOIN table_b;
There is no ON clause. Three spellings produce the same result:
| Form | Example | Notes |
|---|---|---|
CROSS JOIN | FROM a CROSS JOIN b | Standard SQL and the clearest: it says the combination is intended. |
| Comma | FROM a, b | The original syntax. Identical result, but easy to write by accident. |
| Always-true condition | FROM a JOIN b ON 1 = 1 | Seen in generated SQL. Avoid writing it by hand. |
CROSS JOIN keyword to stop anyone multiplying large tables together. The runnable examples therefore use the comma form on small lists. Everywhere else, write CROSS JOIN: replace the comma with the keyword and the query is unchanged.Database support
| Database | CROSS JOIN | Good to know |
|---|---|---|
| MySQL, MariaDB | Supported | JOIN, INNER JOIN and CROSS JOIN are interchangeable in MySQL; a JOIN with no ON is a cross join. |
| PostgreSQL | Supported | JOIN without ON is an error, which protects against accidents. |
| SQL Server | Supported | Also has CROSS APPLY, a different operator that runs a subquery per row. |
| Oracle | Supported | |
| SQLite | Supported | CROSS JOIN also tells the planner not to reorder the two tables. |
How Many Rows Does a CROSS JOIN Return?
Always the number of rows in the first table multiplied by the number in the second. The Playground has 10 departments; pairing them with the four quarters of a year gives 40 rows:
SELECT (SELECT COUNT(*) FROM departments) AS departments,
4 AS quarters,
COUNT(*) AS rows_returned
FROM departments d,
(SELECT 'Q1' AS quarter UNION ALL SELECT 'Q2'
UNION ALL SELECT 'Q3' UNION ALL SELECT 'Q4') AS q;
▶ Run this query in the SQL Playground
| departments | quarters | rows_returned |
|---|---|---|
| 10 | 4 | 40 |
| Table A | Table B | Rows returned |
|---|---|---|
| 3 | 2 | 6 |
| 10 | 4 | 40 |
| 1,000 | 1,000 | 1,000,000 |
| 1,000,000 | 1,000 | 1,000,000,000 |
| Any number | 0 | 0 |
| Any number | 1 | The same number |
The last two rows matter in practice. Cross joining with an empty table returns nothing at all, and cross joining with a one-row table adds its columns to every row without changing the row count, which is one of the most useful tricks on this page.
The growth is why cross joins have a reputation. Joining the Playground's orders to its customers with no condition would already be a five-figure result:
SELECT (SELECT COUNT(*) FROM orders) AS orders,
(SELECT COUNT(*) FROM users) AS customers,
(SELECT COUNT(*) FROM orders) * (SELECT COUNT(*) FROM users) AS rows_if_cross_joined;
▶ Run this query in the SQL Playground
| orders | customers | rows_if_cross_joined |
|---|---|---|
| 200 | 150 | 30000 |
Building a Complete Grid for a Report
The most common legitimate use: make sure every combination appears, even with no data.
Cross join two lists to get every combination that should exist. Every department for every quarter, ready for targets or budgets to be filled in:
SELECT d.name AS department, q.quarter
FROM departments d,
(SELECT 'Q1' AS quarter UNION ALL SELECT 'Q2'
UNION ALL SELECT 'Q3' UNION ALL SELECT 'Q4') AS q
ORDER BY d.name, q.quarter
LIMIT 8;
▶ Run this query in the SQL Playground
| department | quarter |
|---|---|
| Customer Success | Q1 |
| Customer Success | Q2 |
| Customer Success | Q3 |
| Customer Success | Q4 |
| Design | Q1 |
| Design | Q2 |
| Design | Q3 |
| Design | Q4 |
The grid becomes valuable when real data is attached to it with a LEFT JOIN. Counting orders by status and month directly leaves out any combination with no orders. Building the grid first keeps them, with a zero:
SELECT s.status, m.month, COUNT(o.id) AS orders
FROM (SELECT DISTINCT status FROM orders) AS s,
(SELECT '2024-10' AS month UNION ALL SELECT '2024-11'
UNION ALL SELECT '2024-12') AS m
LEFT JOIN orders o
ON o.status = s.status
AND strftime('%Y-%m', o.order_date) = m.month
GROUP BY s.status, m.month
ORDER BY s.status, m.month;
▶ Run this query in the SQL Playground
| status | month | orders |
|---|---|---|
| cancelled | 2024-10 | 0 |
| cancelled | 2024-11 | 0 |
| cancelled | 2024-12 | 1 |
| delivered | 2024-10 | 2 |
| delivered | 2024-11 | 2 |
| delivered | 2024-12 | 3 |
| pending | 2024-10 | 0 |
| pending | 2024-11 | 1 |
| pending | 2024-12 | 1 |
Six statuses times three months is 18 rows, and every one is present. A plain GROUP BY on orders returns fewer, because combinations such as cancelled orders in October never occur. Charts and pivot tables need the full grid. strftime is SQLite's date function.
| Step | What it does |
|---|---|
| 1. Cross join the dimensions | Every status with every month |
| 2. LEFT JOIN the facts | Attach the orders that belong to each combination |
3. COUNT(o.id) | Counts matches only, so empty combinations show 0 |
Cross Join With a One-Row Table: Percent of Total
Cross joining to a subquery that returns a single row puts that value on every row. It is the simplest way to compare each row with a grand total:
SELECT s.status, s.orders, t.total_orders,
ROUND(100.0 * s.orders / t.total_orders, 1) AS pct_of_total
FROM (SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status) AS s,
(SELECT COUNT(*) AS total_orders FROM orders) AS t
ORDER BY s.orders DESC;
▶ Run this query in the SQL Playground
| status | orders | total_orders | pct_of_total |
|---|---|---|---|
| delivered | 82 | 200 | 41 |
| processing | 28 | 200 | 14 |
| cancelled | 27 | 200 | 13.5 |
| pending | 23 | 200 | 11.5 |
| shipped | 21 | 200 | 10.5 |
| refunded | 19 | 200 | 9.5 |
The second subquery has one row, so the row count stays at six and total_orders is simply available everywhere. The same pattern compares each group with an overall average:
SELECT p.category,
ROUND(AVG(p.price), 2) AS avg_price,
a.overall_avg,
ROUND(AVG(p.price) - a.overall_avg, 2) AS difference
FROM products p,
(SELECT ROUND(AVG(price), 2) AS overall_avg FROM products) AS a
GROUP BY p.category, a.overall_avg
ORDER BY difference DESC;
▶ Run this query in the SQL Playground
| category | avg_price | overall_avg | difference |
|---|---|---|---|
| Food & Beverage | 1249.46 | 1048.2 | 201.26 |
| Clothing | 1202.85 | 1048.2 | 154.65 |
| Automotive | 1161.09 | 1048.2 | 112.89 |
| Home & Garden | 1129.71 | 1048.2 | 81.51 |
| Office Supplies | 1069.57 | 1048.2 | 21.37 |
A window function such as SUM(…) OVER () does the same job in one pass; the cross join version works in every database and is easy to read.
Cross Joining a Table With Itself
Cross join a table to itself to get every pairing of its rows, then add a condition to drop the pairs you do not want. Every match-up between two different departments, each listed once:
SELECT a.name AS department_a, b.name AS department_b
FROM departments a, departments b
WHERE a.id < b.id
ORDER BY a.id, b.id
LIMIT 6;
▶ Run this query in the SQL Playground
| department_a | department_b |
|---|---|
| Engineering | Marketing |
| Engineering | Sales |
| Engineering | HR |
| Engineering | Finance |
| Engineering | Operations |
| Engineering | Customer Success |
Without the WHERE there would be 10 × 10 = 100 rows, including each department paired with itself and every pair twice. a.id < b.id leaves the 45 distinct pairs. This is a self join that starts from a cross join.
CROSS JOIN vs INNER JOIN
An inner join is a cross join plus a condition. Add a WHERE that links the two tables and the cross join returns exactly what the inner join does:
SELECT 'comma join with WHERE' AS syntax, COUNT(*) AS rows_returned
FROM orders o, users u
WHERE u.id = o.user_id
UNION ALL
SELECT 'INNER JOIN … ON', COUNT(*)
FROM orders o
INNER JOIN users u ON u.id = o.user_id;
▶ Run this query in the SQL Playground
| syntax | rows_returned |
|---|---|
| comma join with WHERE | 200 |
| INNER JOIN … ON | 200 |
| CROSS JOIN | INNER JOIN | FULL OUTER JOIN | |
|---|---|---|---|
| Join condition | None | Required (ON) | Required (ON) |
| Returns | Every pair of rows | Only pairs that match | Matching pairs plus unmatched rows of both tables |
| Row count | rows(A) × rows(B) | Number of matches | Between the larger table and the sum of both, for unique keys |
| Use it for | Combinations, grids, attaching a total | Related rows | Reconciling two lists |
If the two tables are related, write an INNER JOIN with ON, not a cross join with WHERE: the result is the same, but the intent is clear and the condition cannot be forgotten. The SQL JOINs overview compares all the join types.
Where CROSS JOIN Is Used
| Task | What is cross joined |
|---|---|
| Product variants | sizes × colours × materials |
| A row for every date and every store, so days with no sales show 0 | calendar × stores |
| Budget or target templates | departments × months |
| Percent of total, difference from the average | detail rows × a one-row summary |
| Applying one set of parameters to every row (tax rate, exchange rate, report date) | data × a one-row settings table |
| Tournament fixtures or comparison pairs | a table × itself, with a.id < b.id |
| Generating test data or a number series | small digit lists × each other |
| Shift rotas | days × shifts |
A quick example of the last one, with three days and two shifts:
SELECT d.day, s.shift
FROM (SELECT 1 AS n, 'Mon' AS day UNION ALL SELECT 2, 'Tue'
UNION ALL SELECT 3, 'Wed') AS d,
(SELECT 'Morning' AS shift UNION ALL SELECT 'Evening') AS s
ORDER BY d.n, s.shift DESC;
▶ Run this query in the SQL Playground
| day | shift |
|---|---|
| Mon | Morning |
| Mon | Evening |
| Tue | Morning |
| Tue | Evening |
| Wed | Morning |
| Wed | Evening |
Accidental Cross Joins
Far more cross joins are written by mistake than on purpose.
| How it happens | Symptom |
|---|---|
A comma join with the WHERE condition forgotten | Row count is the product of the two tables |
| Three tables, two of them linked, the third not | Totals multiplied by the size of the unlinked table |
| A join condition that is always true or compares the wrong columns | Far more rows than expected |
JOIN with no ON in MySQL | Runs without error as a cross join |
DISTINCT added to "fix" duplicates | The right-looking answer, computed over millions of hidden rows |
Check for it by counting. If a query returns more rows than its largest table and you did not expect a one-to-many join, compare the row count with the product of the table sizes. To be safe by habit:
| Habit | Why |
|---|---|
Write JOIN … ON, never commas | The condition sits next to the join and is hard to leave out. |
Write CROSS JOIN when you mean it | A reader, and you in six months, can see it is intentional. |
| n tables need at least n − 1 conditions | Count them before running the query. |
Do not hide duplicates with DISTINCT | Find out why they are there first. |
Common CROSS JOIN Mistakes
1. Forgetting the join condition
SELECT o.id, u.first_name
FROM orders o, users u;
SELECT o.id, u.first_name
FROM orders o
JOIN users u
ON u.id = o.user_id;
Every order is paired with every customer. The query runs, and the first screen of results can look plausible.
2. Cross joining to add a total, then summing
SELECT SUM(t.total_orders)
FROM orders o,
(SELECT COUNT(*) AS total_orders
FROM orders) AS t;
SELECT o.status, COUNT(*) AS orders,
t.total_orders
FROM orders o,
(SELECT COUNT(*) AS total_orders
FROM orders) AS t
GROUP BY o.status, t.total_orders;
The one-row value is copied to all 200 rows. Summing it gives 200 × 200. Group by it or wrap it in MAX() instead.
3. An unlinked third table
SELECT COUNT(*)
FROM order_items oi
JOIN orders o ON o.id = oi.order_id,
products p;
SELECT COUNT(*)
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id;
Mixing commas and JOINs makes it easy to leave one table without a condition, which multiplies the result by that table's size (here by 120).
4. Cross joining large tables
SELECT *
FROM customers
CROSS JOIN products;
SELECT *
FROM (SELECT DISTINCT region FROM customers) AS r
CROSS JOIN (SELECT DISTINCT category FROM products) AS c;
Cross join the short lists of distinct values you actually need (regions, categories, months), not the detail tables they come from.
5. Hiding the problem with DISTINCT
SELECT DISTINCT u.first_name
FROM users u, orders o;
SELECT DISTINCT u.first_name
FROM users u
JOIN orders o
ON o.user_id = u.id;
DISTINCT collapses the repeated rows after the database has already built them all. The answer is also different: the first query lists every customer, not only those with orders.
CROSS JOIN Performance
| Tip | Why it helps |
|---|---|
| Multiply the row counts before you run it | The result size is known in advance. If it is in the millions, reconsider. |
| Keep both sides small | Use distinct lists and filtered subqueries. Indexes cannot help a join with no condition. |
| Aggregate detail tables before attaching them to a grid | One row per combination is cheaper to LEFT JOIN than raw transactions. |
| Use a window function for totals on big tables | SUM(x) OVER () reads the table once. |
Add LIMIT while exploring | It stops a mistake from returning a huge result to your screen. |
CROSS JOIN Cheat Sheet
| Goal | Pattern |
|---|---|
| Every combination of two lists | FROM a CROSS JOIN b |
| Row count | rows(a) × rows(b) |
| Grid with zeros | FROM dims1 CROSS JOIN dims2 LEFT JOIN facts ON …, then COUNT(facts.id) |
| Percent of total | CROSS JOIN (SELECT SUM(x) AS total FROM t) AS t |
| Unique pairs within a table | FROM t a CROSS JOIN t b WHERE a.id < b.id |
| Same as INNER JOIN | CROSS JOIN … WHERE a.key = b.key (prefer INNER JOIN … ON) |
| Older syntax | FROM a, b |
Practice CROSS JOIN Queries
7 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy A café sells three drinks (Coffee, Tea, Juice) in two sizes (Small, Large). List every drink and size combination. Table:
(lists you write yourself)Show solution
SELECT d.drink, s.size FROM (SELECT 'Coffee' AS drink UNION ALL SELECT 'Tea' UNION ALL SELECT 'Juice') AS d, (SELECT 'Small' AS size UNION ALL SELECT 'Large') AS s ORDER BY d.drink, s.size;▶ Run this query in the SQL Playground
Three drinks times two sizes gives 6 rows. With real tables this would be
FROM drinks CROSS JOIN sizes. -
2Easy Create a planning grid with one row for every department and each of the years 2025 and 2026. Show the first ten rows. Table:
departmentsShow solution
SELECT d.name AS department, y.year FROM departments d, (SELECT 2025 AS year UNION ALL SELECT 2026) AS y ORDER BY d.name, y.year LIMIT 10;▶ Run this query in the SQL Playground
Ten departments times two years is 20 rows in total; LIMIT shows the first ten.
-
3Medium For each department, show the number of employees and what percentage of all employees that is. Table:
employeesShow solution
SELECT e.department, COUNT(*) AS employees, ROUND(100.0 * COUNT(*) / t.total, 1) AS pct_of_company FROM employees e, (SELECT COUNT(*) AS total FROM employees) AS t GROUP BY e.department, t.total ORDER BY employees DESC;▶ Run this query in the SQL Playground
The one-row subquery puts the company total on every row. It is in the GROUP BY because it is not aggregated.
-
4Medium List the five highest paid employees with the company average salary beside each and the difference. Table:
employeesShow solution
SELECT e.first_name, e.last_name, e.salary, a.avg_salary, e.salary - a.avg_salary AS above_average FROM employees e, (SELECT ROUND(AVG(salary)) AS avg_salary FROM employees) AS a ORDER BY e.salary DESC LIMIT 5;▶ Run this query in the SQL Playground
A cross join to a single row does not change the number of rows; it only adds the average as a column.
-
5Medium How many distinct pairs of departments are there? Count them with a cross join of the table to itself. Table:
departmentsShow solution
SELECT COUNT(*) AS department_pairs FROM departments a, departments b WHERE a.id < b.id;▶ Run this query in the SQL Playground
Ten departments give 10 × 9 / 2 = 45 pairs.
a.id < b.idremoves self-pairs and mirrored pairs. -
6Challenging For each product category and each star rating from 1 to 5, count the reviews. Every category must show all five ratings, with 0 where there are none. Show the first ten rows. Table:
products, reviewsShow solution
SELECT c.category, r.rating, COUNT(rv.id) AS reviews FROM (SELECT DISTINCT category FROM products) AS c, (SELECT 1 AS rating UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5) AS r LEFT JOIN ( SELECT p.category, v.rating, v.id FROM reviews v JOIN products p ON p.id = v.product_id ) AS rv ON rv.category = c.category AND rv.rating = r.rating GROUP BY c.category, r.rating ORDER BY c.category, r.rating LIMIT 10;▶ Run this query in the SQL Playground
The cross join builds the 10 × 5 grid; the LEFT JOIN attaches the reviews;
COUNT(rv.id)gives 0 for empty cells. -
7Challenging Show the number of orders per month for October to December 2024 for each shipping city, including months with no orders. Show the first nine rows. Table:
ordersShow solution
SELECT c.shipping_city, m.month, COUNT(o.id) AS orders FROM (SELECT DISTINCT shipping_city FROM orders) AS c, (SELECT '2024-10' AS month UNION ALL SELECT '2024-11' UNION ALL SELECT '2024-12') AS m LEFT JOIN orders o ON o.shipping_city = c.shipping_city AND strftime('%Y-%m', o.order_date) = m.month GROUP BY c.shipping_city, m.month ORDER BY c.shipping_city, m.month LIMIT 9;▶ Run this query in the SQL Playground
Both conditions belong in ON so that city and month combinations with no orders are kept, with a count of 0.
📝 Quiz — CROSS JOIN
Test your understanding · 0/10 answered
What does a CROSS JOIN return?
A cross join returns the Cartesian product: every possible pair of rows, with no matching condition.
Table A has 6 rows and table B has 5. How many rows does A CROSS JOIN B return?
The row counts multiply: 6 × 5 = 30.
Does CROSS JOIN take an ON clause?
There is nothing to match on. Standard SQL does not allow ON with CROSS JOIN.
What is another name for the result of a cross join?
The set of all pairs of rows from two tables is the Cartesian product.
FROM a, b with no WHERE clause is the same as:
A comma between tables with no condition produces every combination, exactly like CROSS JOIN.
A table of 500 rows is cross joined with a one-row subquery. How many rows result?
500 × 1 = 500. The single row's columns are added to every row.
A table is cross joined with an empty table. How many rows result?
Anything multiplied by zero is zero, so the result is empty.
Which is a good use of CROSS JOIN?
Cross joins generate complete sets of combinations, which can then be LEFT JOINed to real data.
What is the usual cause of an accidental cross join?
Leaving out the condition, especially with comma-separated tables, pairs every row with every row.
CROSS JOIN with WHERE a.id = b.a_id returns the same rows as:
Filtering the Cartesian product on the key leaves only the matching pairs, which is an inner join.
CROSS JOIN Interview Questions
Q1. What is a CROSS JOIN?
A join that returns the Cartesian product of two tables: every row of the first combined with every row of the second. It has no join condition.
Q2. How many rows does a CROSS JOIN return?
The product of the two tables' row counts. 5 rows cross joined with 4 rows return 20. If either table is empty the result is empty.
Q3. What is the difference between CROSS JOIN and INNER JOIN?
CROSS JOIN returns all pairs of rows. INNER JOIN returns only the pairs that satisfy the ON condition. An INNER JOIN is a cross join followed by a filter.
Q4. What is a Cartesian product?
The set of all ordered pairs taken from two sets. In SQL it is what a cross join produces, and what you get by accident when a join condition is missing.
Q5. When would you use a CROSS JOIN deliberately?
To generate every combination of two lists (sizes and colours, dates and stores), to build a complete grid for a report before LEFT JOINing the facts, to attach a one-row total or parameter to every row, and to create pairs within a table.
Q6. What is the difference between CROSS JOIN and FULL OUTER JOIN?
They are unrelated. FULL OUTER JOIN matches rows on a condition and keeps the unmatched rows of both tables. CROSS JOIN matches nothing and returns every combination.
Q7. What is the difference between FROM a, b and a CROSS JOIN b?
None in the result. CROSS JOIN is the explicit ANSI form and makes the intent visible. The comma form is the older syntax and is the usual source of accidental Cartesian products.
Q8. How do you get each row's percentage of a total?
Cross join the grouped rows with a one-row subquery holding the total, then divide: 100.0 * amount / t.total. A window function, SUM(amount) OVER (), is the alternative.
Q9. How do you make a report show zero for combinations with no data?
Cross join the dimension lists to produce every combination, LEFT JOIN the fact table to that grid, and count a column of the fact table so that empty cells give 0.
Q10. How can you tell a query contains an accidental cross join?
The row count is far larger than any input table, often exactly the product of two of them, and totals are multiplied. Check that every table in FROM is linked by a join condition.
Q11. Is CROSS JOIN slow?
The join itself is cheap per row, but the number of rows grows multiplicatively and no index can reduce it. It is fine on small lists and dangerous on large tables.
Q12. What is CROSS APPLY in SQL Server?
A different operator. It runs a table expression once per row of the left table, like a correlated join, and keeps only rows where it returns something. It is not a Cartesian product, despite the name.
Frequently Asked Questions
What is a CROSS JOIN in SQL?
A CROSS JOIN returns every possible combination of rows from two tables. It has no ON condition, and the result is called the Cartesian product.
How many rows does a CROSS JOIN return?
The number of rows in the first table multiplied by the number of rows in the second. A table of 10 rows cross joined with a table of 4 rows returns 40 rows.
What is the difference between CROSS JOIN and INNER JOIN?
INNER JOIN returns only the pairs of rows that satisfy its ON condition. CROSS JOIN has no condition and returns all pairs.
Does CROSS JOIN need an ON clause?
No. A cross join does not match rows, so there is no ON clause. To restrict the combinations afterwards, add a WHERE clause.
What is a Cartesian product in SQL?
It is the result of combining every row of one table with every row of another, which is exactly what CROSS JOIN produces. It also appears by accident when a join condition is left out.
When should I use a CROSS JOIN?
When you need all combinations: product variants, a row for every date and store, a complete report grid, or a single total attached to every row. If the tables are related by a key, use INNER JOIN or LEFT JOIN instead.
Is FROM table1, table2 a cross join?
Yes. Listing tables with a comma and no WHERE condition between them produces the same result as CROSS JOIN.
How do I avoid an accidental cross join?
Write joins with JOIN … ON instead of commas, make sure every table has a condition linking it to another, and check row counts when a result is larger than expected.