Common SQL Join Mistakes That Quietly Duplicate Your Rows
The Problem: Your Count Just Doubled (And You Don’t Know Why)
Ever written a SQL query that felt right, only to look at the results and realize something is off?
You expected 1,000 rows—maybe one per customer—but the database handed you 2,000. Or 5,000. Or 10,000.
This is one of the most common frustrations in data analysis. It’s not a syntax error. The database doesn’t throw a warning. It just quietly multiplies your rows, turning a simple report into a mess. If your query calculates total revenue and your rows have doubled, your revenue comes out 2x higher than reality. That’s the difference between a good quarter and a reporting disaster.
Let’s run a straightforward query on some retail data and see what happens.
import pandas as pd
import sqlite3
# Let's set up a quick in-memory database
conn = sqlite3.connect(':memory:')
# Create an orders table
orders = pd.DataFrame({
'order_id': [101, 102],
'customer_name': ['Alice', 'Bob'],
'total_amount': [50.00, 75.00]
})
# Create an order_items table (Alice bought 2 things, Bob bought 2 things)
items = pd.DataFrame({
'item_id': [1, 2, 3, 4],
'order_id': [101, 101, 102, 102],
'product': ['Apple', 'Banana', 'Cherry', 'Date']
})
orders.to_sql('orders', conn, index=False)
items.to_sql('items', conn, index=False)
# The naive query: We just want to see orders with their items
query = """
SELECT
o.order_id,
o.total_amount,
i.product
FROM orders o
JOIN items i ON o.order_id = i.order_id
"""
result = pd.read_sql(query, conn)
print(f"Original Orders Count: {len(orders)}")
print(f"Resulting Rows: {len(result)}")
print(result)
What happened? We started with 2 orders but ended up with 4 rows. If we tried to SUM(total_amount) on this result, we’d get $250 instead of the actual $125. The join didn’t just combine data. It multiplied it.
How Joins Actually Work: Cartesian Products Under the Hood
To fix this, we have to change our mental model. We often think of a JOIN as a way to “lookup” information, like a VLOOKUP in Excel. But in SQL, a join is fundamentally a multiplication operation.
When you join Table A to Table B, SQL looks at every row in Table A. For each row, it finds every matching row in Table B. If one row in Table A matches three rows in Table B, SQL creates three rows in your output.
This is a Cartesian Product of the matching subsets.
What this actually means: Joins don’t filter your data unless nothing matches. Their default behavior is to expand. A one-to-many relationship will multiply your row count, and the join type — INNER or LEFT — doesn’t change that.
Mistake #1: Joining on a One-to-Many Without Realizing It
This is the most frequent culprit. You join Customers to Orders and think, “I just want the customer’s email next to their order.”
A customer can have many orders, though. Join from the Customer table to the Order table and you get one row for every order that customer ever placed. Join that result to Order_Items and you get one row for every item in every order.
# Let's add a third layer: order_items to categories
categories = pd.DataFrame({
'product': ['Apple', 'Apple', 'Banana', 'Cherry', 'Date'], # Oops! Apple has a duplicate category entry
'category': ['Fruit', 'Snack', 'Fruit', 'Fruit', 'Fruit']
})
categories.to_sql('categories', conn, index=False)
multi_join_query = """
SELECT o.order_id, i.product, c.category
FROM orders o
JOIN items i ON o.order_id = i.order_id
JOIN categories c ON i.product = c.product
"""
explosion = pd.read_sql(multi_join_query, conn)
print(f"Row count after joining categories: {len(explosion)}")
The row count keeps growing at every step — 2 orders become 4 rows after the items join, and 5 rows after the categories join, because categories now covers all four products and still has that duplicate Apple entry. This is why GROUP BY or DISTINCT end up used as panic buttons — but they just paper over a deeper misunderstanding of the data relationship.
Mistake #2: Joining to a Table With Duplicates You Didn’t Know About
Sometimes the logic is fine but the data is dirty. Say your Product_Catalog table should have one row per product ID, but an ETL error inserted “Product 501” twice.
Join that to your Sales table and every sale of Product 501 doubles.
How to spot it: Check your reference table before joining:
SELECT join_key, COUNT(*)
FROM reference_table
GROUP BY join_key
HAVING COUNT(*) > 1;
If this returns anything, your join will duplicate rows.
Mistake #3: Joining on Non-Unique Keys (The Silent Killer)
This is the hardest part of causal debugging in SQL. You join on last_name, thinking it’s unique enough for your small test set. Then you run it against the full database, and “Smith” matches 5,000 people.
Even columns that look like IDs can be non-unique. Joining on zip_code or store_id might seem safe, until you realize those IDs are shared across regions or time periods.
Mistake #4: Multiple Joins Creating Compounding Multiplication
Multiplication compounds. If the first join doubles your rows (2x) and the second triples each of those (3x), you’re at 6x your starting data.
In a complex query with 5 or 6 joins, a single one-to-many relationship early in the chain can turn 100 rows into 100,000 by the end. Now your data is wrong, and your query is slow.
Spotting the Mistake: How to Tell If Your Join Is Duplicating Rows
Before you trust your results, run this 30-second diagnostic. Compare the count of your primary key to the count of distinct primary keys.
diagnostic_query = """
SELECT
COUNT(*) as total_rows,
COUNT(DISTINCT order_id) as unique_orders
FROM (
SELECT o.order_id
FROM orders o
JOIN items i ON o.order_id = i.order_id
)
"""
check = pd.read_sql(diagnostic_query, conn)
print(check)
If total_rows is 4 and unique_orders is 2, you’ve got a 2x duplication.
Fix #1: Use DISTINCT to Remove Duplicates (The Quick Fix)
SELECT DISTINCT is the last resort. It tells SQL: after all the joining and multiplying, look at the final rows and collapse any that are identical.
It works, but it’s expensive. The database has to sort the full result set to find duplicates. Use it only when you truly have identical rows you can’t avoid through better logic.
Fix #2: Use GROUP BY to Aggregate Before Joining
This is the standard fix for most duplication. If you need data from a “many” table (like Order_Items) to live in a “one” table (like Orders), aggregate it first.
Join to a summary of items, not the raw rows.
fix_query = """
SELECT
o.order_id,
o.total_amount,
item_summary.item_count
FROM orders o
JOIN (
SELECT order_id, COUNT(*) as item_count
FROM items
GROUP BY order_id
) item_summary ON o.order_id = item_summary.order_id
"""
print(pd.read_sql(fix_query, conn))
Now we have 2 rows for 2 orders. The count is correct because we turned the “many” into a “one” before the join.
Fix #3: Use Subqueries or CTEs to Control Join Order
Common Table Expressions (CTEs) keep your logic readable. You define your “clean” tables up front in the query, so you’re not wrestling duplicates inside a 50-line block of code.
cte_query = """
WITH clean_items AS (
SELECT order_id, COUNT(*) as total_items
FROM items
GROUP BY order_id
)
SELECT o.order_id, o.total_amount, ci.total_items
FROM orders o
JOIN clean_items ci ON o.order_id = ci.order_id
"""
print(pd.read_sql(cte_query, conn))
Fix #4: Use Window Functions to Rank and Filter Rows
What if you want the most recent item for each order, without aggregating?
ROW_NUMBER() ranks rows within a group so you can keep just the top one.
window_query = """
WITH ranked_items AS (
SELECT
order_id,
product,
ROW_NUMBER() OVER(PARTITION BY order_id ORDER BY item_id DESC) as rn
FROM items
)
SELECT order_id, product
FROM ranked_items
WHERE rn = 1
"""
print(pd.read_sql(window_query, conn))
One row per order. The specific item we asked for, no duplication.
Putting It Together: A Real-World Example
Say you’re asked for “Total Revenue per Category.”
- Naive approach: Join
Orders -> Items -> Categories. RunSUM(total_amount)over the result and you get $300 — not because of one bug, but two compounding ones:Categoriesnow has 5 rows for 4 products (Apple shows up twice, mapped to both “Fruit” and “Snack”), andItemsalready puts one row on the board per item, so every order’s revenue gets repeated once per item it contains. - Diagnostic: The naive query’s total reads $300. Your
orderstable — the actual source of truth — sums to $125. RunningGROUP BY product HAVING COUNT(*) > 1oncategoriesreturnsApple, confirming one of the two causes. - The Fix: Deduplicating
categorieswith a CTE fixes the category-duplication half — the query drops to 4 rows and $250 — but that’s still 2x too high, becauseItemsis still putting one row on the board per item andtotal_amountis order-level, not item-level. The rest of the fix is Fix #2’s pattern: aggregateitemsdown to one row per order before joining toorders. Do that and the query lands on exactly $125 — the original two orders, dollar for dollar.
How to Prevent Join Mistakes in the Future
- Check counts early: Run
SELECT COUNT(*)after each new join you add. - Know your keys: Don’t assume a column is unique. Check it with
GROUP BY...HAVING COUNT(*) > 1. - Visualize the relationship: Is it 1:1, 1:N, or N:N? With 1:N, if you want one row back, you must aggregate or filter.
Recap: What You Now Know
- Joins are multiplication, not just lookups.
- One-to-many relationships are the #1 cause of “ghost” rows.
DISTINCTis correct when the extra rows are genuinely identical; the moment they differ in any column (likecategory),GROUP BYor a CTE is the only fix that actually works.- Always validate your row counts against your source tables.
Next time, we’ll look at making these joins fast — not just correct.
Check Your Understanding
Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.
Remember What is the “30-second diagnostic” the article recommends for checking whether a join duplicated rows?
Understand In your own words, explain why the article says a JOIN is fundamentally a “multiplication operation” rather than a “lookup,” using the orders/items example (2 orders becoming 4 rows).
Apply
Using the article’s example, if orders has 2 rows and each order has 3 matching rows in items, and you then join that result to a categories table where each product matches 2 category rows, how many total rows would you expect in the final result?
Analyze
The article distinguishes Mistake #1 (joining a genuine one-to-many relationship) from Mistake #2 (joining to a reference table with accidental duplicate rows). Walk through why the diagnostic query in Mistake #2 (GROUP BY join_key HAVING COUNT(*) > 1 on the reference table) wouldn’t catch Mistake #1 — what’s fundamentally different about the two situations?
Evaluate
The article calls SELECT DISTINCT “the last resort” and recommends GROUP BY/aggregation as the sturdier fix. Critique this as a blanket rule: is there a scenario where reaching for DISTINCT is actually the more correct choice, not just the lazier one?
Create
Design a diagnostic and fix for a new scenario: a report joining Employees to Department_History (which has one row per department change, so an employee has multiple rows if they changed departments) is asked to show “each employee’s current department.” Using the article’s Fix #4 pattern (window functions), sketch the query logic you’d use to get exactly one row per employee.
Apply What You Learned
Deliverable: An incident report (150-250 words) containing: (1) which table is causing the duplication and why, (2) the exact diagnostic SQL that proves it — both the GROUP BY … HAVING COUNT(*) > 1 check on the culprit table and the COUNT(*) vs COUNT(DISTINCT order_id) row-count comparison, (3) the corrected query using a CTE or subquery to fix the problem before the join, and (4) why a blanket SELECT DISTINCT would fail here.
Rubric (checklist):
- Names
categoriesas the duplication source — theproductcolumn has duplicate entries mapping ‘Apple’ to both ‘Fruit’ and ‘Snack’ (non-identical rows, not a copy-paste dupe) - Runs
GROUP BY product HAVING COUNT(*) > 1oncategoriesand reports it returns rows - Runs the 30-second diagnostic (
COUNT(*)vsCOUNT(DISTINCT order_id)) and reports the 2× inflation ratio (e.g., 4 total rows vs 2 unique orders) - Writes a fix using a CTE or subquery that deduplicates or aggregates before the join — not
SELECT DISTINCTapplied after - Explains that
DISTINCTon the final result fails because the duplicated rows carry differentcategoryvalues (‘Fruit’ vs ‘Snack’), so they aren’t identical rows - Confirms the fixed query’s row count matches the original
orderscount (2 rows for 2 orders, not 4)
Related articles
- SQL & Data Engineering Under review
BigQuery ML: Training Models Without Leaving SQL
You've been there. You have a massive table in BigQuery — millions of rows, terabytes of data — and you want to train a simple logistic regression.
- SQL & Data Engineering Under review
SQL Window Functions You Actually Need for Data Science
Master SQL window functions for data science: running totals, RANK, LAG, NTILE, and the common pitfalls that trip up candidates in technical interviews.
- LLMs & GenAI Under review
Why Your Prompts Fail (And What That Tells Us)
Most prompt failures are communication problems, not the model's fault. Learn five patterns that fix them and how to measure what actually works.
- Explainability Under review
LIME vs. SHAP: Choosing the Right 'Translator' for Your Black-Box Models
Learn how LIME's fast local perturbations and SHAP's game-theoretic Shapley values explain black-box model predictions, and when to use each for your projects.
Looking for something else?
Search every article by title, summary or topic.