Full Outer Join In Sql: 2026 Guide & Examples
Master the full outer join in sql with practical examples, billing reconciliation queries, syntax rules, and NULL handling for data analysts.
When auditing enterprise ledgers or synchronizing distributed microservices, relying on INNER JOIN or LEFT JOIN blinds you to one side of your data pipeline. Executing a full outer join in sql provides complete 360-degree visibility across both tables by retaining every row from both the left and right datasets regardless of whether a matching relational key exists. If you need to perform billing reconciliation between Stripe charges and your internal Postgres invoice database, or detect orphaned records across customer tracking platforms, the SQL full outer join is the definitive set-operation tool.
In relational database management systems like PostgreSQL, Snowflake, BigQuery, and SQL Server, an outer join preserves non-matching rows by padding missing columns with NULL. Understanding how to correctly query, filter, and coalesce these columns is one of the most critical competencies evaluated in technical SQL interviews and senior analytics engineering roles.
For deeper gotchas when joining one-to-many relationships, study our companion guides on SQL JOIN Fan-Out, our deep-dive on LEFT JOIN vs LEFT OUTER JOIN, and our troubleshooting manual for SQL NULL traps.
Practice on Live PostgreSQL Engines
Every query and reconciliation pattern in this guide can be tested directly on Topfolio Practice with real PostgreSQL databases. Sharpen your skills with dedicated JOIN modules designed around real enterprise schemas.
SQL JOIN Types at a Glance
Before examining deep reconciliation mechanics, compare how each standard ANSI join behaves when two tables share common keys alongside unmatched entries:
| Join Operation | Left Table Unmatched Rows | Matched Rows | Right Table Unmatched Rows | Primary Production Use Case |
|---|---|---|---|---|
| INNER JOIN | Dropped | Preserved | Dropped | Strict relational lookups where both keys must exist |
| LEFT JOIN | Preserved (Right = NULL) | Preserved | Dropped | Primary entity retention with optional supplemental attributes |
| RIGHT JOIN | Dropped | Preserved | Preserved (Left = NULL) | Secondary entity retention (rarely used; rewritten as LEFT) |
| FULL OUTER JOIN | Preserved (Right = NULL) | Preserved | Preserved (Left = NULL) | Two-way audits, financial ledger reconciliation, data diffs |
| CROSS JOIN | Cartesian Product ($N \times M$) | Cartesian Product | Cartesian Product | Calendar matrices, price grid expansion, test mocks |
The Setup: Our Example Tables
We will use two straightforward tables throughout the baseline examples to illustrate how unmatched keys behave:
customers
| customer_id | name | city |
|---|---|---|
| 1 | Alice | Mumbai |
| 2 | Bob | Delhi |
| 3 | Charlie | Bangalore |
| 4 | Diana | Chennai |
orders
| order_id | customer_id | amount | order_date |
|---|---|---|---|
| 101 | 1 | 500 | 2026-01-15 |
| 102 | 1 | 300 | 2026-02-20 |
| 103 | 2 | 700 | 2026-01-22 |
| 104 | 5 | 200 | 2026-03-01 |
Notice the intentional gaps in this dataset:
- Customer 3 (Charlie) and Customer 4 (Diana) have placed zero orders.
- Order 104 references
customer_id = 5, which does not exist in thecustomersdimension table (an orphaned transaction).
INNER JOIN — Only Matching Rows
An INNER JOIN evaluates the join predicate and returns exclusively the records where matching keys exist in both participating tables.
SELECT
c.name,
c.city,
o.order_id,
o.amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id;Result:
| name | city | order_id | amount |
|---|---|---|---|
| Alice | Mumbai | 101 | 500 |
| Alice | Mumbai | 102 | 300 |
| Bob | Delhi | 103 | 700 |
What is dropped: Charlie and Diana are eliminated because they have no records in orders. Order 104 is eliminated because its customer_id (5) does not exist in customers. Use INNER JOIN only when non-matching rows are irrelevant to your query grain.
LEFT JOIN — All Left Table + Matching Right
A LEFT JOIN (or LEFT OUTER JOIN) preserves every row from the left-hand table. If a row in the left table matches one or more rows in the right table, those attributes are appended. If no match exists, the right-hand attributes populate with NULL.
SELECT
c.name,
c.city,
o.order_id,
o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;Result:
| name | city | order_id | amount |
|---|---|---|---|
| Alice | Mumbai | 101 | 500 |
| Alice | Mumbai | 102 | 300 |
| Bob | Delhi | 103 | 700 |
| Charlie | Bangalore | NULL | NULL |
| Diana | Chennai | NULL | NULL |
What is dropped: Order 104 is still omitted because its key does not exist in the primary left table (customers).
Why LEFT JOIN Dominates Reporting Queries
In business intelligence pipelines, you rarely want to drop active customers simply because they haven't made a purchase this month. LEFT JOIN guarantees base population integrity while pulling in optional transactional activity.
RIGHT JOIN — All Right Table + Matching Left
A RIGHT JOIN is the exact structural mirror of LEFT JOIN. It guarantees that every record from the right-hand table appears in the final result, populating missing left-hand columns with NULL.
SELECT
c.name,
o.order_id,
o.amount,
o.customer_id
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id;Result:
| name | order_id | amount | customer_id |
|---|---|---|---|
| Alice | 101 | 500 | 1 |
| Alice | 102 | 300 | 1 |
| Bob | 103 | 700 | 2 |
| NULL | 104 | 200 | 5 |
In production SQL codebases, senior data engineers almost universally convert RIGHT JOIN operations into LEFT JOIN syntax by reversing the table sequence. Reading queries from left to right establishes a cleaner cognitive hierarchy.
How to Use FULL OUTER JOIN in SQL (Syntax & Mechanics)
The ANSI SQL standard defines FULL OUTER JOIN (syntactically equivalent to FULL JOIN) as a bidirectional union of outer-join operations. It evaluates the join predicate and produces:
- Inner Matches: Rows where
table_a.key = table_b.key, merging attributes horizontally. - Left-Unmatched Rows: Rows from Table A where no key matches Table B, populating Table B columns with
NULL. - Right-Unmatched Rows: Rows from Table B where no key matches Table A, populating Table A columns with
NULL.
Basic Baseline Syntax
SELECT
c.customer_id AS customer_table_id,
c.name,
c.city,
o.order_id,
o.customer_id AS orders_table_id,
o.amount
FROM customers c
FULL OUTER JOIN orders o ON c.customer_id = o.customer_id;Query Output:
| customer_table_id | name | city | order_id | orders_table_id | amount |
|---|---|---|---|---|---|
| 1 | Alice | Mumbai | 101 | 1 | 500 |
| 1 | Alice | Mumbai | 102 | 1 | 300 |
| 2 | Bob | Delhi | 103 | 2 | 700 |
| 3 | Charlie | Bangalore | NULL | NULL | NULL |
| 4 | Diana | Chennai | NULL | NULL | NULL |
| NULL | NULL | NULL | 104 | 5 | 200 |
How RDBMS Execution Engines Execute Full Outer Joins
When you execute a full outer join in SQL, the database query planner (such as PostgreSQL's cost-based optimizer) chooses between three core physical operators depending on table statistics, indexing, and available working memory (work_mem):
- Hash Full Join: The planner builds an in-memory hash table on the inner relation. As it probes the outer relation, it marks matched hash buckets. Once probe scanning concludes, the engine performs an additional pass across the hash table to emit all unmarked buckets padded with
NULLcolumns. - Merge Full Join: If both inputs are sorted on the join key (via an existing B-tree index or an explicit
Sortoperator), the engine steps through both streams in lockstep, emitting matches and advancing whichever stream lags behind while padding nulls. - Nested Loop with Materialization: Only selected for tiny tables or when non-equi join conditions prevent hash or merge strategies.
Full Outer Join in SQL Practical Guide: Reconciling Billing Records
The single most prevalent real-world application of FULL OUTER JOIN in enterprise analytics is bilateral financial reconciliation.
Consider a high-growth SaaS business reconciling third-party payment gateway charges (Stripe Gateway) against the internal application ledger (PostgreSQL Billing Service). Discrepancies emerge constantly due to webhook drops, delayed authorizations, API timeouts, manual customer support adjustments, or direct payment platform chargebacks.
The Production Schema & Scenario
Examine the two distinct tables loaded into your data warehouse:
stripe_charges (Ingested via Stripe Webhook Fivetran / Airbyte sync)
| charge_id | customer_email | amount_cents | status |
|---|---|---|---|
ch_101 | alice@topfolio.in | 5000 | succeeded |
ch_102 | bob@topfolio.in | 3000 | succeeded |
ch_103 | evan@topfolio.in | 4500 | succeeded |
ch_104 | frank@topfolio.in | 2000 | succeeded |
app_invoices (Internal Postgres application billing table)
| invoice_id | customer_email | amount_cents | plan_tier |
|---|---|---|---|
inv_901 | alice@topfolio.in | 5000 | pro_monthly |
inv_902 | bob@topfolio.in | 3000 | pro_monthly |
inv_903 | charlie@topfolio.in | 7500 | enterprise |
inv_904 | frank@topfolio.in | 2500 | pro_monthly |
An analyst auditing end-of-month cash balances must classify every single row into one of four critical reconciliation buckets:
- Clean Match: The customer was billed in the app and charged the exact identical amount on Stripe.
- Amount Discrepancy: The transaction exists in both systems, but the collected amount differs (e.g. Frank was invoiced $25.00 but Stripe charged only $20.00).
- Orphaned Gateway Charge (Phantom): Stripe charged Evan $45.00, but no matching invoice exists in the application database (potential unlinked customer or webhook failure).
- Uncollected Invoice (Overdue): Charlie has an internal invoice for $75.00, but no payment record exists in Stripe (unpaid account or failed billing attempt).
The Reconciliation Query
Here is the production-grade reconciliation query implementing FULL OUTER JOIN with COALESCE key harmonization and categorized discrepancy flags:
SELECT
-- 1. Unify the reconciliation key across both systems
COALESCE(s.customer_email, a.customer_email) AS unified_email,
-- 2. Preserve raw transactional identifiers
s.charge_id AS stripe_charge_id,
a.invoice_id AS app_invoice_id,
-- 3. Extract financial metrics in cents
s.amount_cents AS stripe_cents,
a.amount_cents AS invoice_cents,
-- 4. Calculate cash variance (handling nulls with COALESCE)
COALESCE(s.amount_cents, 0) - COALESCE(a.amount_cents, 0) AS variance_cents,
-- 5. Classify the ledger state
CASE
WHEN s.charge_id IS NOT NULL AND a.invoice_id IS NOT NULL AND s.amount_cents = a.amount_cents
THEN 'MATCHED'
WHEN s.charge_id IS NOT NULL AND a.invoice_id IS NOT NULL AND s.amount_cents != a.amount_cents
THEN 'AMOUNT_MISMATCH'
WHEN s.charge_id IS NOT NULL AND a.invoice_id IS NULL
THEN 'ORPHANED_STRIPE_CHARGE'
WHEN s.charge_id IS NULL AND a.invoice_id IS NOT NULL
THEN 'UNCOLLECTED_APP_INVOICE'
END AS audit_status
FROM stripe_charges s
FULL OUTER JOIN app_invoices a
ON s.customer_email = a.customer_email
ORDER BY unified_email ASC;The Reconciled Ledger Output
| unified_email | stripe_charge_id | app_invoice_id | stripe_cents | invoice_cents | variance_cents | audit_status |
|---|---|---|---|---|---|---|
alice@topfolio.in | ch_101 | inv_901 | 5000 | 5000 | 0 | MATCHED |
bob@topfolio.in | ch_102 | inv_902 | 3000 | 3000 | 0 | MATCHED |
charlie@topfolio.in | NULL | inv_903 | NULL | 7500 | -7500 | UNCOLLECTED_APP_INVOICE |
evan@topfolio.in | ch_103 | NULL | 4500 | NULL | +4500 | ORPHANED_STRIPE_CHARGE |
frank@topfolio.in | ch_104 | inv_904 | 2000 | 2500 | -500 | AMOUNT_MISMATCH |
Notice the analytical clarity provided by the FULL OUTER JOIN:
- If you had used an
INNER JOIN, Charlie, Evan, and Frank's pricing mismatch would have either disappeared or required complex secondary queries. - If you had used a
LEFT JOINstarting fromapp_invoices, Evan's unauthorized or unlinked $45.00 charge would have remained completely invisible to accounting.
Handling NULL Matches and Non-Coalesced Keys
When executing a full outer join in SQL, working with NULLs is unavoidable. Failing to anticipate NULL behavior in SELECT, WHERE, and aggregate clauses leads to silent bugs.
1. The Single-Column Join Key Trap
The most frequent bug in junior SQL queries is selecting the join key from only one table:
-- WRONG: If Charlie has no stripe record, s.customer_email returns NULL!
SELECT
s.customer_email,
s.amount_cents,
a.amount_cents
FROM stripe_charges s
FULL OUTER JOIN app_invoices a ON s.customer_email = a.customer_email;If a row originates solely from the right table (app_invoices), s.customer_email evaluates to NULL. The resulting dataset appears to contain anonymous or corrupt records.
The Fix: Always wrap join keys in COALESCE:
-- CORRECT: Returns the first non-null identifier from either side
SELECT
COALESCE(s.customer_email, a.customer_email) AS customer_email,
s.amount_cents,
a.amount_cents
FROM stripe_charges s
FULL OUTER JOIN app_invoices a ON s.customer_email = a.customer_email;2. The WHERE Clause Filter Pushdown Trap
A devastating mistake in analytics queries is appending a WHERE filter on an outer-joined table.
Consider this query intended to find high-value discrepancies:
-- TRAP QUERY: Unintentionally converts FULL OUTER JOIN into a RIGHT JOIN!
SELECT
COALESCE(s.customer_email, a.customer_email) AS customer_email,
s.amount_cents AS stripe_cents,
a.amount_cents AS invoice_cents
FROM stripe_charges s
FULL OUTER JOIN app_invoices a ON s.customer_email = a.customer_email
WHERE s.amount_cents > 2000;Why this breaks: For every customer without a Stripe charge (such as Charlie), s.amount_cents is NULL. In standard three-valued SQL logic, NULL > 2000 evaluates to UNKNOWN. The WHERE clause filters out any row that does not evaluate to TRUE. Consequently, all records where Stripe is missing are instantly dropped! The query silently turns into an INNER JOIN or LEFT JOIN.
The Two Correct Solutions:
Solution A (Filter in the ON clause):
-- Evaluates the condition during the join phase, retaining unmatched records
SELECT
COALESCE(s.customer_email, a.customer_email) AS customer_email,
s.amount_cents AS stripe_cents,
a.amount_cents AS invoice_cents
FROM stripe_charges s
FULL OUTER JOIN app_invoices a
ON s.customer_email = a.customer_email
AND s.amount_cents > 2000;Solution B (Null-safe WHERE predicate):
-- Explicitly allows NULL values generated by the outer join
SELECT
COALESCE(s.customer_email, a.customer_email) AS customer_email,
s.amount_cents AS stripe_cents,
a.amount_cents AS invoice_cents
FROM stripe_charges s
FULL OUTER JOIN app_invoices a ON s.customer_email = a.customer_email
WHERE s.amount_cents > 2000 OR s.amount_cents IS NULL;3. Aggregate Math on Nullable Columns
In arithmetic expressions, any operation involving NULL yields NULL (NULL - 500 = NULL). When calculating variances, always use COALESCE(column, 0).
Similarly, distinguish between COUNT(*) and COUNT(column):
COUNT(*)counts every row emitted by the full outer join, including rows where one side was absent.COUNT(s.charge_id)counts only rows where a Stripe charge was present.
Full Outer Join in SQL Examples: Full Anti-Join (Symmetric Difference)
A standard anti-join isolates records in Table A that have no counterpart in Table B (LEFT JOIN ... WHERE b.key IS NULL).
A Full Anti-Join (also known as a symmetric difference) isolates records that exist in either Table A or Table B, but not in both. This query is the ultimate audit filter to surface data drift between two systems.
-- Full Anti-Join: Returns strictly unmatched rows from either side
SELECT
COALESCE(s.customer_email, a.customer_email) AS discrepancy_email,
s.charge_id AS stripe_charge,
a.invoice_id AS app_invoice
FROM stripe_charges s
FULL OUTER JOIN app_invoices a
ON s.customer_email = a.customer_email
WHERE s.customer_email IS NULL
OR a.customer_email IS NULL;Result:
| discrepancy_email | stripe_charge | app_invoice |
|---|---|---|
charlie@topfolio.in | NULL | inv_903 |
evan@topfolio.in | ch_103 | NULL |
Alice, Bob, and Frank are completely excluded because they exist in both tables. This query immediately delivers an actionable queue for financial operations teams.
How to Use Full Outer Join in SQL When Dialects Lack Native Support (MySQL)
A frequent technical interview question asks: "How do you write a full outer join in MySQL when MySQL does not support the FULL OUTER JOIN keyword?"
MySQL syntax throws ERROR 1064 (42000): You have an error in your SQL syntax if you attempt to use FULL OUTER JOIN or FULL JOIN.
To simulate a full outer join in MySQL, combine a LEFT JOIN and a RIGHT JOIN using the UNION set operator:
-- MySQL Emulation of FULL OUTER JOIN
-- Step 1: All customers with matching orders + customers without orders
SELECT
c.customer_id AS customer_id,
c.name,
o.order_id,
o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
UNION
-- Step 2: All orders without matching customers
SELECT
c.customer_id AS customer_id,
c.name,
o.order_id,
o.amount
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL;Why UNION Rather Than UNION ALL?
If you simply run LEFT JOIN ... UNION ALL ... RIGHT JOIN, all matching rows (Alice and Bob) appear twice in the result set.
By either:
- Using
UNION(which incurs an internal sorting and deduplication step), or - Adding
WHERE c.customer_id IS NULLto theRIGHT JOINhalf and combining viaUNION ALL(recommended for large datasets because it avoids a costly full-table deduplication pass),
you produce the exact mathematical result set of an ANSI FULL OUTER JOIN.
Master Complex Joins in the Data Analyst Track
Build muscle memory writing multi-table joins, billing reconciliations, and window functions across 100+ real PostgreSQL interview benchmarks.
Explore the Data Analyst TrackCROSS JOIN — Every Combination
A CROSS JOIN produces the Cartesian product of two tables. Every row from the first table is paired with every row from the second table. No ON condition is permitted.
SELECT
c.name,
p.product_name
FROM customers c
CROSS JOIN products p;If customers has 4 rows and products has 3 rows, the result contains $4 \times 3 = 12$ rows.
Practical Analytical Use Case: Calendar Matrix Scaffolding
In financial reporting, business stakeholders demand reports showing revenue for every product across every month, including months with zero sales. An INNER JOIN drops zero-sales periods.
By combining CROSS JOIN with LEFT JOIN, you create an unbroken reporting spine:
SELECT
m.month,
p.product_name,
COALESCE(SUM(s.revenue), 0) AS total_revenue
FROM months m
CROSS JOIN products p
LEFT JOIN sales s
ON m.month = s.sale_month
AND p.product_id = s.product_id
GROUP BY m.month, p.product_name
ORDER BY m.month, p.product_name;Watch Out for Cartesian Explosions
Crossing two 10,000-row tables generates 100,000,000 rows. If executed in production without restrictive date filters, Cartesian products will saturate your database memory buffer pool and trigger out-of-memory (OOM) query terminations.
Self JOIN — Joining a Table to Itself
A self-join is not a distinct SQL keyword, but an architectural pattern where a single table is referenced multiple times using distinct table aliases. It is used to query hierarchical trees or compare rows within the same dataset.
Use Case 1: Organizational Hierarchy (Employee to Manager)
Given an employees table where each row contains an employee_id and an optional manager_id:
SELECT
e.name AS employee,
COALESCE(m.name, 'Executive Board / CEO') AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id;| employee | manager |
|---|---|
| Alice | Executive Board / CEO |
| Bob | Alice |
| Charlie | Alice |
| Diana | Bob |
Use Case 2: Finding Pairs Within the Same Group
To find pairs of customers residing in the same city without generating identical self-matches or duplicate permutations:
SELECT
c1.name AS customer_1,
c2.name AS customer_2,
c1.city
FROM customers c1
JOIN customers c2
ON c1.city = c2.city
AND c1.customer_id < c2.customer_id;The strict inequality predicate `c1.customer_id < c2.customer_id` guarantees:
- Customers are never paired with themselves (
customer_id != customer_id). - If
(Alice, Bob)is returned, the inverse duplicate(Bob, Alice)is omitted.
Anti-JOIN Patterns — Finding Missing Records
Anti-joins identify rows in one dataset that have zero matching entries in another. There are three standard methods:
Method 1: LEFT JOIN + IS NULL (Recommended Standard)
SELECT c.*
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;This pattern returns customers who have never placed an order (Charlie and Diana). Query optimizers easily recognize this syntax and transform it into efficient Hash Anti-Join execution plans.
Method 2: NOT EXISTS (Ideal for Correlated Subqueries)
SELECT c.*
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);NOT EXISTS is semantically explicit and short-circuits as soon as a single matching record is located in the target table.
Method 3: NOT IN (The NULL Trap Danger)
SELECT c.*
FROM customers c
WHERE c.customer_id NOT IN (
SELECT customer_id FROM orders
);Why NOT IN Fails Silently with NULL Values
If even a single customer_id inside the orders subquery is NULL, the entire NOT IN predicate evaluates to UNKNOWN for all rows. As a result, the query returns zero rows, completely concealing existing records. Never use NOT IN unless the target column has an explicit NOT NULL constraint. Prefer NOT EXISTS or LEFT JOIN ... WHERE IS NULL.
Multiple JOINs — Chaining Multi-Table Queries
Enterprise data warehouses structure schemas into third normal form (3NF) or star schemas requiring chains of 3 to 6 joins:
SELECT
c.name AS customer_name,
o.order_id,
p.product_name,
oi.quantity,
oi.unit_price,
(oi.quantity * oi.unit_price) AS line_total
FROM customers c
JOIN orders o
ON c.customer_id = o.customer_id
JOIN order_items oi
ON o.order_id = oi.order_id
JOIN products p
ON oi.product_id = p.product_id
WHERE o.order_date >= '2026-01-01'
ORDER BY o.order_id, oi.item_id;Guidelines for Multi-Table Joins
- Explicit Aliasing: Always prefix selected columns with clear table aliases (
c.name,o.order_id). Ambiguous column errors occur when multiple tables share column names (e.g.created_at,status). - Join Order Hierarchy: Position your primary dimensional entity on the left and chain child bridge tables sequentially before attaching peripheral lookup dimensions.
Performance Optimization & Query Tuning for Joins
Executing joins across tables with millions of rows requires careful resource management:
- Foreign Key Indexing: Ensure all join key columns are indexed with B-tree indexes. Without indexes on foreign keys, databases must default to sequential table scans.
- Push Predicates to WHERE / CTEs: Reduce table cardinality before joining. Filtering out historical years in a Common Table Expression (CTE) drastically reduces the memory allocated to join hash tables.
- Tune Memory Allocation (
work_mem): In PostgreSQL, if the hash table for an in-memory Hash Join exceedswork_mem, the planner spills hash batches to temporary disk files, degrading query throughput by up to 100x. - Avoid Expressions in Join Predicates: Writing
ON LOWER(c.email) = LOWER(u.email)prevents standard index lookups. Use functional expression indexes or clean data at ingestion time.
9. Try It Yourself in the Topfolio Practice Sandbox
Theoretical knowledge without query execution leads to interview failure. You can test join mechanics, spot join fan-out bugs, and practice queries directly in our PostgreSQL browser sandbox on Four-Table JOIN: Invoice Details.
The question loads a relational billing schema with 412 invoices and 2,240 line items. Test these two queries in the live code editor:
Query 1: The Naive Fan-Out Trap (8.95x Ledger Inflation)
Joining the invoice table directly to child invoiceline records without considering relationship cardinality:
-- Trap Query: Fan-out multiplication across 2,240 line items
SELECT ROUND(SUM(i.total), 2) AS fan_out_revenue
FROM invoice AS i
JOIN invoiceline AS il ON i.invoiceid = il.invoiceid;Sandbox Output: 20,848.62
Because single invoices span multiple line items, the join repeats each invoice total across every purchased line item, inflating the true 2,328.60 total by nearly 9x.
Query 2: The Pre-Aggregated Clean Fix
Preserve the true grain of one row per invoice before aggregating:
-- Fix Query: Deduplicate join keys to preserve 1-row-per-invoice grain
SELECT ROUND(SUM(i.total), 2) AS clean_revenue
FROM (SELECT invoiceid, total FROM invoice) AS i
JOIN (SELECT DISTINCT invoiceid FROM invoiceline) AS il
ON i.invoiceid = il.invoiceid;Sandbox Output: 2,328.60
The deduplication preserves the true grain of one row per invoice, ensuring your aggregate matches the genuine ledger balance.
Practice Real SQL JOIN Problems
Test your query optimization skills on real PostgreSQL sandboxes with instant execution benchmarks and company interview tests.
Start Practicing FreeFrequently Asked Questions
What does a full outer join in sql do?
A full outer join in sql returns all rows from both participating tables. When rows satisfy the join condition, columns combine side-by-side; when a row has no corresponding match in the opposite table, the database fills all columns from the missing side with NULL values.
How do you handle NULL values after a FULL OUTER JOIN?
To handle NULL values after a FULL OUTER JOIN, wrap non-matching identifiers and metrics in COALESCE. For example, use COALESCE(a.id, b.id) to retain a unified account key, and COALESCE(metric, 0) to prevent numeric calculations from propagating null values.
What is the difference between FULL OUTER JOIN and UNION ALL?
FULL OUTER JOIN merges columns horizontally based on a shared key condition, producing matched rows and side-padded NULL columns. UNION ALL stacks rows vertically from queries with identical schema without evaluating relational keys or removing duplicates.
Does MySQL support FULL OUTER JOIN syntax directly?
No, MySQL does not natively support the FULL OUTER JOIN keyword. To emulate a full outer join in MySQL, run a LEFT JOIN and a RIGHT JOIN with an IS NULL filter, combining both queries using a UNION operator.
Why does a WHERE clause turn a FULL OUTER JOIN into an INNER JOIN?
Filtering right or left table attributes in a WHERE clause using non-null predicates (such as WHERE table_b.status = 'active') discards all outer-joined rows where table_b was NULL, unintentionally converting the outer join into an inner join. Move predicates to the ON clause instead.

Written by
Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.
Related Articles
LEFT JOIN vs LEFT OUTER JOIN in SQL: Key Differences
Is there any difference between LEFT JOIN and LEFT OUTER JOIN in SQL? Learn ANSI syntax rules, performance benchmarks, and common WHERE clause traps.
SQL JOIN Fan-Out: How to Fix Duplicate Rows & Broken SUM
Fix duplicate rows and inflated SUM/AVG totals in SQL joins. Learn what causes join fan-out, how to pre-aggregate data, and solve classic interview traps.
ROW_NUMBER vs RANK vs DENSE_RANK in SQL (Tie Examples)
Understand the exact difference between ROW_NUMBER(), RANK(), and DENSE_RANK() in SQL. See how ties are handled (1,2,3 vs 1,2,2,4 vs 1,2,2,3) with interactive queries.