SQLab Hub
Lesson 18 · CROSS JOIN
Lesson 18 · Querying

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.

✕ Every combination ▶ Runnable Playground examples 🧮 Rows multiply 📊 Report grids · % of total 🧪 7 practice exercises 📝 10-question quiz

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:

SQL
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):

SQLite · runs in the Playground
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

Every combination of three sizes and two colours
sizecolour
LBlue
LRed
MBlue
MRed
SBlue
SRed
✓ 6 rows · real output from the Playground database

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

SQL · syntax
SELECT columns
FROM table_a
CROSS JOIN table_b;

There is no ON clause. Three spellings produce the same result:

FormExampleNotes
CROSS JOINFROM a CROSS JOIN bStandard SQL and the clearest: it says the combination is intended.
CommaFROM a, bThe original syntax. Identical result, but easy to write by accident.
Always-true conditionFROM a JOIN b ON 1 = 1Seen in generated SQL. Avoid writing it by hand.
About the examples on this page. The SQLabHub Playground is a shared database, so it rejects the 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

DatabaseCROSS JOINGood to know
MySQL, MariaDBSupportedJOIN, INNER JOIN and CROSS JOIN are interchangeable in MySQL; a JOIN with no ON is a cross join.
PostgreSQLSupportedJOIN without ON is an error, which protects against accidents.
SQL ServerSupportedAlso has CROSS APPLY, a different operator that runs a subquery per row.
OracleSupported
SQLiteSupportedCROSS 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:

SQLite · runs in the Playground
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 and the rows their cross join returns
departmentsquartersrows_returned
10440
✓ 1 row · real output from the Playground database
Table ATable BRows returned
326
10440
1,0001,0001,000,000
1,000,0001,0001,000,000,000
Any number00
Any number1The 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:

SQLite · runs in the Playground
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

Size of a cross join between orders and customers
orderscustomersrows_if_cross_joined
20015030000
✓ 1 row · real output from the Playground database

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:

SQLite · runs in the Playground
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

One row per department per quarter
departmentquarter
Customer SuccessQ1
Customer SuccessQ2
Customer SuccessQ3
Customer SuccessQ4
DesignQ1
DesignQ2
DesignQ3
DesignQ4
✓ 8 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Orders per status and month for the last quarter of 2024, zeros included
statusmonthorders
cancelled2024-100
cancelled2024-110
cancelled2024-121
delivered2024-102
delivered2024-112
delivered2024-123
pending2024-100
pending2024-111
pending2024-121
✓ 18 rows · first 9 shown · real output from the Playground database

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.

StepWhat it does
1. Cross join the dimensionsEvery status with every month
2. LEFT JOIN the factsAttach 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:

SQLite · runs in the Playground
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

Orders per status as a share of all orders
statusorderstotal_orderspct_of_total
delivered8220041
processing2820014
cancelled2720013.5
pending2320011.5
shipped2120010.5
refunded192009.5
✓ 6 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Average price per category compared with the overall average
categoryavg_priceoverall_avgdifference
Food & Beverage1249.461048.2201.26
Clothing1202.851048.2154.65
Automotive1161.091048.2112.89
Home & Garden1129.711048.281.51
Office Supplies1069.571048.221.37
✓ 10 rows · first 5 shown · real output from the Playground database

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:

SQLite · runs in the Playground
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

Pairs of departments, each pair once
department_adepartment_b
EngineeringMarketing
EngineeringSales
EngineeringHR
EngineeringFinance
EngineeringOperations
EngineeringCustomer Success
✓ 6 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

A cross join filtered in WHERE and the equivalent INNER JOIN
syntaxrows_returned
comma join with WHERE200
INNER JOIN … ON200
✓ 2 rows · real output from the Playground database
CROSS JOININNER JOINFULL OUTER JOIN
Join conditionNoneRequired (ON)Required (ON)
ReturnsEvery pair of rowsOnly pairs that matchMatching pairs plus unmatched rows of both tables
Row countrows(A) × rows(B)Number of matchesBetween the larger table and the sum of both, for unique keys
Use it forCombinations, grids, attaching a totalRelated rowsReconciling 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

TaskWhat is cross joined
Product variantssizes × colours × materials
A row for every date and every store, so days with no sales show 0calendar × stores
Budget or target templatesdepartments × months
Percent of total, difference from the averagedetail 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 pairsa table × itself, with a.id < b.id
Generating test data or a number seriessmall digit lists × each other
Shift rotasdays × shifts

A quick example of the last one, with three days and two shifts:

SQLite · runs in the Playground
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

Every shift on every day
dayshift
MonMorning
MonEvening
TueMorning
TueEvening
WedMorning
WedEvening
✓ 6 rows · real output from the Playground database

Accidental Cross Joins

Far more cross joins are written by mistake than on purpose.

How it happensSymptom
A comma join with the WHERE condition forgottenRow count is the product of the two tables
Three tables, two of them linked, the third notTotals multiplied by the size of the unlinked table
A join condition that is always true or compares the wrong columnsFar more rows than expected
JOIN with no ON in MySQLRuns without error as a cross join
DISTINCT added to "fix" duplicatesThe 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:

HabitWhy
Write JOIN … ON, never commasThe condition sits next to the join and is hard to leave out.
Write CROSS JOIN when you mean itA reader, and you in six months, can see it is intentional.
n tables need at least n − 1 conditionsCount them before running the query.
Do not hide duplicates with DISTINCTFind out why they are there first.

Common CROSS JOIN Mistakes

1. Forgetting the join condition

❌ 30,000 rows
SQL
SELECT o.id, u.first_name
FROM orders o, users u;
✅ 200 rows
SQL
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

❌ Total repeated on every row and summed
SQL
SELECT SUM(t.total_orders)
FROM orders o,
     (SELECT COUNT(*) AS total_orders
      FROM orders) AS t;
✅ Use the total, do not aggregate it
SQL
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

❌ products is joined to nothing
SQL
SELECT COUNT(*)
FROM order_items oi
JOIN orders o ON o.id = oi.order_id,
     products p;
✅ Every table linked
SQL
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

❌ Billions of rows
SQL
SELECT *
FROM customers
CROSS JOIN products;
✅ Reduce each side first
SQL
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

❌ Correct rows, enormous work
SQL
SELECT DISTINCT u.first_name
FROM users u, orders o;
✅ Fix the join
SQL
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

TipWhy it helps
Multiply the row counts before you run itThe result size is known in advance. If it is in the millions, reconsider.
Keep both sides smallUse distinct lists and filtered subqueries. Indexes cannot help a join with no condition.
Aggregate detail tables before attaching them to a gridOne row per combination is cheaper to LEFT JOIN than raw transactions.
Use a window function for totals on big tablesSUM(x) OVER () reads the table once.
Add LIMIT while exploringIt stops a mistake from returning a huge result to your screen.

CROSS JOIN Cheat Sheet

GoalPattern
Every combination of two listsFROM a CROSS JOIN b
Row countrows(a) × rows(b)
Grid with zerosFROM dims1 CROSS JOIN dims2 LEFT JOIN facts ON …, then COUNT(facts.id)
Percent of totalCROSS JOIN (SELECT SUM(x) AS total FROM t) AS t
Unique pairs within a tableFROM t a CROSS JOIN t b WHERE a.id < b.id
Same as INNER JOINCROSS JOIN … WHERE a.key = b.key (prefer INNER JOIN … ON)
Older syntaxFROM 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.

  1. 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
    SQLite · runs in the Playground
    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.

  2. 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: departments

    Show solution
    SQLite · runs in the Playground
    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.

  3. 3Medium For each department, show the number of employees and what percentage of all employees that is. Table: employees

    Show solution
    SQLite · runs in the Playground
    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.

  4. 4Medium List the five highest paid employees with the company average salary beside each and the difference. Table: employees

    Show solution
    SQLite · runs in the Playground
    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.

  5. 5Medium How many distinct pairs of departments are there? Count them with a cross join of the table to itself. Table: departments

    Show solution
    SQLite · runs in the Playground
    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.id removes self-pairs and mirrored pairs.

  6. 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, reviews

    Show solution
    SQLite · runs in the Playground
    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.

  7. 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: orders

    Show solution
    SQLite · runs in the Playground
    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

Question 1 of 10

What does a CROSS JOIN return?

Question 2 of 10

Table A has 6 rows and table B has 5. How many rows does A CROSS JOIN B return?

Question 3 of 10

Does CROSS JOIN take an ON clause?

Question 4 of 10

What is another name for the result of a cross join?

Question 5 of 10

FROM a, b with no WHERE clause is the same as:

Question 6 of 10

A table of 500 rows is cross joined with a one-row subquery. How many rows result?

Question 7 of 10

A table is cross joined with an empty table. How many rows result?

Question 8 of 10

Which is a good use of CROSS JOIN?

Question 9 of 10

What is the usual cause of an accidental cross join?

Question 10 of 10

CROSS JOIN with WHERE a.id = b.a_id returns the same rows as:

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.