SQL errors and wrong results are inevitable — knowing how to debug them systematically is what separates confident practitioners from people who guess and rerun. This lesson gives you a structured methodology for reading error messages, tracing silent data errors, and diagnosing the join bugs that corrupt your results without warning.

You've written the query. You run it. And then one of two things happens: either your database throws an error message written in what appears to be ancient Sumerian, or worse, it runs perfectly and hands you results that are subtly, silently wrong. Both scenarios will happen to you regularly as a SQL practitioner — and how well you recover determines how much your colleagues trust your numbers.
Debugging SQL is a skill that most courses skip entirely. They teach you how to write queries when everything goes right. But production data is messy, schemas have surprises, and business logic is complex. The gap between knowing SQL syntax and being confident debugging real queries is where a lot of practitioners get stuck. This lesson closes that gap. We're going to work through how to read error messages intelligently, how to hunt down wrong results systematically, and how to diagnose and fix broken joins — which are responsible for the majority of silent data errors in analytical SQL.
By the end of this lesson, you'll be able to approach any broken or suspicious query with a structured methodology rather than random trial-and-error. You'll know what the most common error classes actually mean, how to use isolation techniques to find where results go wrong, and how to catch the join bugs that inflate or collapse your row counts without warning.
What you'll learn:
This lesson assumes you're comfortable with the core building blocks: SELECT, FROM, and WHERE clauses, and that you've worked with JOINs in practice including INNER, LEFT, and multi-table joins. You should also have some experience with GROUP BY and aggregate functions. We won't be teaching those concepts from scratch — we'll be debugging them at the practitioner level.
The first instinct when you see a SQL error is panic or frustration. The productive instinct is curiosity. Error messages are not obstacles — they're evidence. Your database engine is trying to tell you exactly what went wrong. Learning to read them well is like learning to read a stack trace.
Most database engines return errors with at minimum three pieces of information: an error code, a message, and usually a position indicator or object name. Let's walk through the most common classes.
Syntax errors are the most frequent and often the least informative at first glance:
-- This query has a missing comma
SELECT
customer_id
first_name, -- missing comma after customer_id
last_name,
email
FROM customers;
PostgreSQL will give you something like:
ERROR: syntax error at or near "first_name"
LINE 3: first_name,
^
The caret ^ points to where the parser got confused — but note that it's pointing at first_name, not at the missing comma before it. This is the single most important rule of SQL syntax errors: the reported location is where the parser gave up, not necessarily where you made your mistake. The actual problem is always at or just before that position. Look one token back.
-- Fixed
SELECT
customer_id,
first_name,
last_name,
email
FROM customers;
Object not found errors are usually clearer:
SELECT * FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
WHERE p.categry_id = 5; -- typo in column name
ERROR: column p.categry_id does not exist
LINE 3: WHERE p.categry_id = 5;
When you see "column does not exist" or "table does not exist," your first move is to verify the actual schema. Don't trust your memory — check it:
-- PostgreSQL / standard SQL
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'products'
ORDER BY ordinal_position;
-- MySQL
DESCRIBE products;
-- SQL Server
EXEC sp_help 'products';
This habit alone saves hours. You'll discover the column is named category_id not categry_id, or that the table is in a different schema than you assumed.
Type mismatch errors are trickier because they often appear in unexpected places:
SELECT order_id, customer_id
FROM orders
WHERE order_date > '2024-01-01';
ERROR: operator does not exist: date > integer
Wait — you passed a string, not an integer. The issue is that order_date was stored as an integer (Unix timestamp, perhaps) rather than a proper date type, or your literal needs explicit casting. This is a signal to inspect the data types in your schema before assuming.
-- Explicit cast where needed
WHERE order_date > EXTRACT(EPOCH FROM '2024-01-01'::timestamp)::integer
Tip
When you encounter a type error, immediately check the data type of the column using information_schema.columns or DESCRIBE. Don't assume what a column's type is based on its name. A column named user_id might be VARCHAR in one system and BIGINT in another.
Ambiguous column errors happen in multi-table queries and are completely avoidable:
SELECT order_id, customer_id, customer_name
FROM orders
JOIN customers USING (customer_id);
-- ERROR: column reference "customer_id" is ambiguous
Both tables have customer_id. You need to qualify it:
SELECT o.order_id, o.customer_id, c.customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;
The fix is straightforward, but the lesson is deeper: always alias your tables and always qualify your column references in multi-table queries. This isn't just style — it prevents ambiguity errors and makes your code readable when you come back to it in three months.
Different databases phrase the same underlying problem differently. Here's a quick reference for the errors you'll hit most often:
| Problem | PostgreSQL | MySQL | SQL Server |
|---|---|---|---|
| Syntax error | syntax error at or near "X" |
You have an error in your SQL syntax near 'X' |
Incorrect syntax near 'X' |
| Missing object | relation "X" does not exist |
Table 'db.X' doesn't exist |
Invalid object name 'X' |
| Type mismatch | operator does not exist: X op Y |
Illegal mix of collations or implicit conversion |
Operand type clash |
| Division by zero | division by zero |
Division by 0 |
Divide by zero error encountered |
| NULL constraint violation | null value in column "X" violates not-null constraint |
Column 'X' cannot be null |
Cannot insert the value NULL into column 'X' |
Note
MySQL is especially permissive — it will silently truncate values, coerce types, and swallow errors that PostgreSQL would reject loudly. If you're debugging queries that were written for MySQL, assume more silent failures are possible.
Silent wrong results are more dangerous than errors. A query that returns 2.3 million in monthly revenue when the real number is 1.8 million won't crash your application — it'll just mislead every decision made from it. Tracing these bugs requires a systematic approach, not guessing.
The core technique is progressive isolation: strip the query back to its simplest possible form, verify that each layer is correct, then build it back up until you find where the results diverge from what you expect.
Before you can debug wrong results, you need to know what "right" looks like. This sounds obvious, but it's often skipped. Pick a small, verifiable subset of your data. For example, if you're debugging a sales report:
-- Find a customer you can manually verify
SELECT *
FROM orders
WHERE customer_id = 1042
ORDER BY order_date;
Look at the raw data directly. Count the rows. Sum the values with a calculator if needed. Now you have a ground truth to validate your query against.
Before joining anything, make sure the data in each individual table looks right for your filter conditions:
-- How many orders do we have for 2024?
SELECT COUNT(*) AS order_count, SUM(total_amount) AS total_revenue
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01';
order_count | total_revenue
------------+---------------
14,832 | 4,127,450.00
Write this number down. It's your baseline. Now when your final query returns a different number, you know exactly which join or filter caused the divergence.
This is where most practitioners skip ahead and add all their joins at once. Don't do it. Add one join, check the row count, then add the next:
-- Start: just orders
SELECT COUNT(*) FROM orders WHERE order_date >= '2024-01-01';
-- Result: 14,832
-- Add customer join
SELECT COUNT(*)
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date >= '2024-01-01';
-- Result: 14,832 ✓ same - good, no rows lost or gained
-- Add product join
SELECT COUNT(*)
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.order_date >= '2024-01-01';
-- Result: 47,219 ✓ expected - multiple items per order, this is the line-item count
When a row count changes unexpectedly, you've found the join that's causing the problem. You don't need to read the whole query — just that one join.
Key insight
A row count that stays the same after a join usually means you have a clean one-to-one or many-to-one relationship. A row count that increases means one-to-many — which may be correct or may signal fan-out duplication. A row count that decreases means rows are being lost — which usually means your join condition is wrong or you have NULL keys.
Once you've identified a suspicious join, pull individual records through it:
-- Is this specific order joining correctly?
SELECT
o.order_id,
o.customer_id,
o.total_amount,
c.customer_name,
c.email
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_id = 98234;
If customer_name and email come back NULL, your LEFT JOIN is revealing that this order's customer_id doesn't exist in the customers table. Now you know why the revenue totals were off — some orders have orphaned customer IDs. That's a data quality issue, not a query logic issue, and diagnosing it this way lets you handle it appropriately (maybe COALESCE(c.customer_name, 'Unknown') or filtering those orders out deliberately).
Joins are where the most consequential bugs live. Let's go through each major failure mode with realistic examples.
A cross join produces the Cartesian product of two tables — every row from one matched with every row from the other. In a deliberately written CROSS JOIN, that's intentional. When it happens by accident, it's catastrophic.
How it happens:
-- Looks innocent, produces disaster
SELECT
o.order_id,
o.total_amount,
p.product_name
FROM orders o, products p -- implicit cross join!
WHERE o.order_date >= '2024-01-01';
The old comma-separated FROM syntax without a WHERE join condition is a cross join. If orders has 14,832 rows and products has 3,000 rows, this query returns 44,496,000 rows. Your database will run it — it won't warn you.
The modern version of this mistake looks like:
SELECT
o.order_id,
s.salesperson_name
FROM orders o
JOIN salespeople s ON 1 = 1; -- explicit cross join condition!
How to spot it: Your row count is a multiple of what you expected, often suspiciously round. If your result has 10x more rows than your order table, check every join condition. You can also add a LIMIT and inspect:
SELECT o.order_id, COUNT(*) OVER () AS total_rows
FROM orders o
JOIN salespeople s ON 1 = 1
LIMIT 5;
How to fix it: Every JOIN needs a meaningful ON condition that links the tables through actual keys:
-- Correct: each order links to the salesperson who owns it
SELECT
o.order_id,
s.salesperson_name
FROM orders o
JOIN salespeople s ON o.salesperson_id = s.salesperson_id;
This is the sneakiest join bug because the results look plausible. It happens when you join to a table that has multiple rows per key you're joining on.
Scenario: You want total revenue by customer, joining in customer details:
SELECT
c.customer_id,
c.customer_name,
SUM(o.total_amount) AS total_revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN customer_addresses ca ON c.customer_id = ca.customer_id
GROUP BY c.customer_id, c.customer_name;
This looks reasonable. But if customer_addresses has multiple addresses per customer (shipping, billing, etc.), then each order row gets multiplied by the number of addresses. A customer with 2 addresses and 5 orders will contribute 10 rows to your aggregate instead of 5. Your SUM(o.total_amount) will be doubled.
How to detect fan-out: Before aggregating, check whether your join key is unique in the table you're joining:
-- Is customer_id unique in customer_addresses?
SELECT customer_id, COUNT(*) AS address_count
FROM customer_addresses
GROUP BY customer_id
HAVING COUNT(*) > 1
LIMIT 10;
If this returns rows, joining directly on that table will cause fan-out.
How to fix it: You have two good options.
Option 1 — Pre-aggregate before joining:
-- Get the primary address per customer first
WITH primary_addresses AS (
SELECT DISTINCT ON (customer_id)
customer_id,
street_address,
city,
state
FROM customer_addresses
WHERE address_type = 'billing'
ORDER BY customer_id, created_at DESC
)
SELECT
c.customer_id,
c.customer_name,
pa.city,
SUM(o.total_amount) AS total_revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
LEFT JOIN primary_addresses pa ON c.customer_id = pa.customer_id
GROUP BY c.customer_id, c.customer_name, pa.city;
Option 2 — Aggregate the join target inline:
SELECT
c.customer_id,
c.customer_name,
SUM(o.total_amount) AS total_revenue,
MAX(ca.city) AS primary_city -- MAX picks one without fan-out
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
LEFT JOIN customer_addresses ca ON c.customer_id = ca.customer_id
AND ca.address_type = 'billing'
GROUP BY c.customer_id, c.customer_name;
Warning
The MAX() trick for picking a single value from a fan-out join is a workaround, not a proper solution. It works when you genuinely don't care which value is selected. If business logic dictates which record should be used (most recent, highest priority, etc.), pre-aggregate with proper ordering as shown in Option 1.
When working with multi-table reports that combine JOINs and GROUP BY, always verify that your aggregate table and your detail table are at compatible granularities before joining.
Choosing the wrong join type — particularly using INNER JOIN when you need LEFT JOIN — silently drops rows from your results. This is extremely common when your data has gaps you didn't expect.
Scenario: You're building a report of all customers and their 2024 order totals, including customers who didn't order anything:
-- Wrong: drops customers with no 2024 orders
SELECT
c.customer_id,
c.customer_name,
SUM(o.total_amount) AS total_revenue_2024
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id -- INNER JOIN drops non-buyers
AND o.order_date >= '2024-01-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY total_revenue_2024 DESC;
The result: Your customer table has 12,400 customers, but your report only shows 8,100. The missing 4,300 either never ordered or didn't order in 2024. Were you supposed to include them? Probably, if this is for a retention or reactivation analysis.
-- Correct: keeps all customers, NULLs where no 2024 orders
SELECT
c.customer_id,
c.customer_name,
COALESCE(SUM(o.total_amount), 0) AS total_revenue_2024
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
AND o.order_date >= '2024-01-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY total_revenue_2024 DESC;
Notice the crucial detail: the date filter AND o.order_date >= '2024-01-01' is in the JOIN condition, not the WHERE clause. This matters enormously. If you put it in the WHERE clause:
-- This silently converts LEFT JOIN to INNER JOIN behavior!
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_date >= '2024-01-01' -- Filters out NULL rows from LEFT JOIN
Customers with no 2024 orders will have o.order_date as NULL. NULL >= '2024-01-01' evaluates to NULL (not TRUE), so those rows are eliminated by the WHERE clause. Your LEFT JOIN becomes functionally an INNER JOIN. This is one of the most common and most consequential SQL mistakes in practice.
For an in-depth treatment of NULL behavior in join and filter conditions, especially how NULLs propagate through comparisons, it's worth understanding the three-valued logic underpinning SQL's treatment of unknown values.
Tip
A reliable way to verify whether your LEFT JOIN is working as intended: after writing it, temporarily add WHERE right_table.primary_key IS NULL to your query. If you get zero rows, every row in the left table has a match — no missing data possible. If you get rows back, those are your unmatched records, and you need to decide whether they should appear in your results and what values they should carry.
Once joins are working correctly, the next most common source of wrong results is in GROUP BY and aggregation logic.
Every GROUP BY query has an implicit question: "what is one row in my result?" When your grouping columns don't match your intent, you get results at the wrong level.
-- Intended: monthly revenue by product category
-- Actual: daily revenue by product category (order_date is a timestamp!)
SELECT
p.category_name,
o.order_date, -- should be DATE_TRUNC('month', o.order_date)
SUM(oi.unit_price * oi.quantity) AS revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.category_name, o.order_date;
The fix requires both correcting the SELECT expression and the GROUP BY:
SELECT
p.category_name,
DATE_TRUNC('month', o.order_date) AS revenue_month,
SUM(oi.unit_price * oi.quantity) AS revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.category_name, DATE_TRUNC('month', o.order_date)
ORDER BY revenue_month, p.category_name;
HAVING filters after aggregation; WHERE filters before. Misplacing a filter is a logic error, not a syntax error — the query runs, but the results are wrong.
-- Wrong: filters rows before aggregation, losing partial-month data
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS revenue
FROM orders
WHERE SUM(total_amount) > 50000 -- Can't use aggregate in WHERE!
GROUP BY DATE_TRUNC('month', order_date);
-- This will actually error: aggregate functions not allowed in WHERE
-- Correct: filter the aggregated result
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
HAVING SUM(total_amount) > 50000
ORDER BY month;
A subtler version of this mistake is filtering on a column that affects the aggregate when it should filter on the aggregate itself:
-- Finds customers where at least one order > $1000
-- (customers with a mix of small and large orders will appear)
SELECT customer_id, SUM(total_amount) AS lifetime_value
FROM orders
WHERE total_amount > 1000
GROUP BY customer_id;
-- Finds customers whose TOTAL lifetime value > $1000
SELECT customer_id, SUM(total_amount) AS lifetime_value
FROM orders
GROUP BY customer_id
HAVING SUM(total_amount) > 1000;
These are different business questions. Only one of them is what you intended. When debugging aggregation results, always ask: "Should this filter apply before or after I calculate the aggregate?"
Complex queries with multiple joins, subqueries, and aggregations are hard to debug not because the debugging is hard, but because you can't easily see the intermediate states. Common Table Expressions (CTEs) solve this elegantly by letting you name and inspect each step.
Compare these two versions of the same logic:
Hard to debug (everything nested):
SELECT
s.salesperson_name,
revenue_data.total_revenue,
quota_data.annual_quota,
ROUND(revenue_data.total_revenue / quota_data.annual_quota * 100, 1) AS pct_of_quota
FROM salespeople s
JOIN (
SELECT salesperson_id, SUM(total_amount) AS total_revenue
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'
GROUP BY salesperson_id
) revenue_data ON s.salesperson_id = revenue_data.salesperson_id
JOIN (
SELECT salesperson_id, SUM(quota_amount) AS annual_quota
FROM sales_quotas
WHERE quota_year = 2024
GROUP BY salesperson_id
) quota_data ON s.salesperson_id = quota_data.salesperson_id
WHERE quota_data.annual_quota > 0;
Easy to debug (each step visible):
WITH revenue_2024 AS (
SELECT
salesperson_id,
SUM(total_amount) AS total_revenue
FROM orders
WHERE order_date >= '2024-01-01'
AND order_date < '2025-01-01'
GROUP BY salesperson_id
),
quotas_2024 AS (
SELECT
salesperson_id,
SUM(quota_amount) AS annual_quota
FROM sales_quotas
WHERE quota_year = 2024
GROUP BY salesperson_id
)
SELECT
s.salesperson_name,
r.total_revenue,
q.annual_quota,
ROUND(r.total_revenue / q.annual_quota * 100, 1) AS pct_of_quota
FROM salespeople s
JOIN revenue_2024 r ON s.salesperson_id = r.salesperson_id
JOIN quotas_2024 q ON s.salesperson_id = q.salesperson_id
WHERE q.annual_quota > 0;
Now when your pct_of_quota looks wrong, you can run just SELECT * FROM revenue_2024 (by copying that CTE block with a standalone SELECT) and verify the revenue totals before worrying about the final join. You've turned a complex multi-step debugging problem into three simpler ones.
Tip
During debugging, temporarily convert your CTEs to standalone queries with LIMIT 100 to inspect each stage. Once you've confirmed each intermediate result is correct, reassemble the full CTE structure. This is the SQL equivalent of unit testing your logic.
For more sophisticated query architecture using CTEs and subqueries, the lesson on writing multi-step analytical queries walks through how to chain these patterns for complex business questions.
When a query returns unexpected results in a production context, work through this checklist in order. Don't skip steps — each one has caught real bugs.
1. Check row counts at each stage
-- Add to any query to see how many rows each table contributes
SELECT
COUNT(*) AS final_row_count,
COUNT(DISTINCT o.order_id) AS distinct_orders,
COUNT(DISTINCT c.customer_id) AS distinct_customers
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;
If final_row_count > distinct_orders, you have fan-out from the customer join (shouldn't happen with a proper PK on customers, but worth verifying).
2. Check for NULLs in join keys
SELECT
COUNT(*) AS total_orders,
COUNT(customer_id) AS orders_with_customer_id, -- COUNT ignores NULLs
COUNT(*) - COUNT(customer_id) AS orders_without_customer_id
FROM orders;
3. Verify filter conditions with opposite logic
-- How many rows would my WHERE clause exclude?
SELECT COUNT(*) FROM orders WHERE NOT (order_date >= '2024-01-01');
-- If this returns 0, your date filter might be wrong
4. Spot-check a known record end-to-end
-- Trace one record through the entire query pipeline
SELECT *
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id
LEFT JOIN order_items oi ON o.order_id = oi.order_id
LEFT JOIN products p ON oi.product_id = p.product_id
WHERE o.order_id = 12345;
5. Check for implicit type coercion in join conditions
-- If these types don't match, the join will still "work" but be slow or wrong
SELECT
pg_typeof(o.customer_id) AS order_customer_id_type,
pg_typeof(c.customer_id) AS customer_id_type
FROM orders o, customers c
LIMIT 1;
-- If one is INTEGER and one is VARCHAR, implicit casting may cause missed matches
6. Verify aggregate math on a small, known subset
-- Manually verify total for one customer you know
SELECT
order_id,
total_amount
FROM orders
WHERE customer_id = 1042
AND order_date >= '2024-01-01';
-- Sum these manually or in Excel, then compare to your aggregated query result
You've been handed a broken sales analysis query from a colleague. It's supposed to show each product category's total 2024 revenue and the number of unique customers who purchased it. The query runs but the revenue numbers look 3-4x too high and the customer counts seem too low. Find and fix all the bugs.
Schema:
-- customers: customer_id, customer_name, segment, region
-- orders: order_id, customer_id, order_date, status
-- order_items: item_id, order_id, product_id, quantity, unit_price
-- products: product_id, product_name, category_id
-- categories: category_id, category_name
Broken query:
SELECT
cat.category_name,
SUM(oi.unit_price * oi.quantity) AS total_revenue,
COUNT(c.customer_id) AS unique_customers
FROM categories cat
JOIN products p ON cat.category_id = p.category_id
JOIN order_items oi ON p.product_id = oi.product_id
JOIN orders o ON oi.order_id = o.order_id
JOIN customers c ON o.customer_id = c.customer_id
JOIN customer_segments cs ON c.customer_id = cs.customer_id
WHERE o.order_date >= '2024-01-01'
AND o.order_date < '2025-01-01'
AND o.status = 'completed'
GROUP BY cat.category_name;
Step 1: Run a baseline. Check the count in orders for 2024 completed orders alone. Note the number.
Step 2: Add the joins one at a time. After adding customer_segments, compare your row count to after adding customers. If customer_segments has multiple segments per customer (e.g., a historical log), this is your fan-out source.
Step 3: Investigate customer_segments:
SELECT customer_id, COUNT(*) AS segment_count
FROM customer_segments
GROUP BY customer_id
HAVING COUNT(*) > 1
LIMIT 10;
You'll find customers appear multiple times (different segment assignments over time).
Step 4: Fix the fan-out by pre-filtering to one segment record per customer:
WITH current_segments AS (
SELECT DISTINCT ON (customer_id)
customer_id,
segment_name
FROM customer_segments
ORDER BY customer_id, assigned_date DESC
)
SELECT
cat.category_name,
SUM(oi.unit_price * oi.quantity) AS total_revenue,
COUNT(DISTINCT o.customer_id) AS unique_customers
FROM categories cat
JOIN products p ON cat.category_id = p.category_id
JOIN order_items oi ON p.product_id = oi.product_id
JOIN orders o ON oi.order_id = o.order_id
JOIN customers c ON o.customer_id = c.customer_id
LEFT JOIN current_segments cs ON c.customer_id = cs.customer_id
WHERE o.order_date >= '2024-01-01'
AND o.order_date < '2025-01-01'
AND o.status = 'completed'
GROUP BY cat.category_name
ORDER BY total_revenue DESC;
Step 5: Also fix COUNT(c.customer_id) → COUNT(DISTINCT o.customer_id). The original was counting one row per item-customer combination, not unique customers. The distinction between COUNT() and COUNT(DISTINCT) is crucial in any aggregation over a joined dataset.
"My query ran for 20 minutes and then returned wrong results." Long-running queries that return wrong results often have implicit cross joins or unindexed join conditions that produce a massive intermediate result. Check for missing join conditions first, then look at whether your join columns have indexes.
"I added a filter and now I have MORE rows than before." This usually means your filter is on a joined table and you've switched from an INNER to an OUTER join somewhere, or you have a filter inside an OR condition that's widening the result set unexpectedly. Check the parenthesization of your WHERE clause.
"The same customer appears multiple times in my result."
You're missing a GROUP BY column, or more commonly, a fan-out join is creating duplicate rows before aggregation. Use SELECT DISTINCT as a diagnostic (not a permanent fix) to confirm duplicates exist, then trace which join introduced them.
"My LEFT JOIN isn't keeping all the rows from the left table." Check whether a WHERE clause is filtering on the right table — any condition on the right table in WHERE eliminates the NULL rows from unmatched left records. Move those conditions into the JOIN's ON clause.
"My aggregate totals change depending on join order." In standard SQL, join order shouldn't affect results — but if you're seeing this, you likely have an implicit filter effect from changing INNER to LEFT joins, or you're joining on a non-unique key somewhere. Add DISTINCT counts to isolate where the instability is occurring.
For advanced filtering patterns that help you write more precise WHERE conditions without these side effects, the lesson on WHERE and ORDER BY covers predicate logic in depth.
Debugging SQL is fundamentally about two skills: reading the evidence (error messages, row counts, sample records) and applying systematic reduction (isolate each component, verify it independently, then reassemble). The specific bugs change, but the methodology doesn't.
Here's what to carry forward from this lesson:
COUNT(DISTINCT key) when counting entities across a joined dataset.As your queries grow more complex — incorporating window functions, set operations like UNION and INTERSECT, or deeper nested subquery patterns — these debugging fundamentals scale with you. The queries get harder, but the approach stays the same: isolate, verify, rebuild.
The best SQL practitioners aren't the ones who write perfect queries on the first try. They're the ones who debug efficiently and develop an instinct for where bugs are likely to hide. That instinct comes from practice — and from having a checklist to fall back on when instinct alone isn't enough.