Tier 3 · Intermediate

NULL Handling in Joins and Aggregates in SQL

By Owen Middleton · Updated September 2026 · Examples run on PostgreSQL 17

How do NULLs behave in SQL joins and aggregates?

A LEFT JOIN is designed to keep every row from the left table, even when there is no match on the right. The problem is that the NULLs it introduces can silently break the next thing you do with the result.

You're analyzing customer activity. You run a LEFT JOIN from customers to orders so that customers with no orders still appear in the output. Then you add a WHERE filter to see only completed orders. The LEFT JOIN gives you every customer, matched or not. The WHERE clause then removes all the unmatched customers, because orders.status = 'complete' is not true for NULL — and NULL doesn't fail the comparison exactly, it produces NULL, which WHERE treats as false. You get back only customers who have orders. No error. No warning. Just the wrong result.

SELECT customers.id, orders.status
FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id
WHERE orders.status = 'complete'

This returns only customers with a completed order. Customers with no orders — the ones the LEFT JOIN was specifically keeping — are silently dropped. The fix is to move the filter into the JOIN predicate, so it applies only to the matching logic and doesn't eliminate unmatched rows:

SELECT customers.id, orders.status
FROM customers
LEFT JOIN orders
  ON customers.id = orders.customer_id
  AND orders.status = 'complete'

Now unmatched customers still appear, with NULL in the status column.

How do NULLs from a LEFT JOIN affect SUM and COUNT?

The second place introduced NULLs cause trouble is in aggregate functions. SUM, AVG, MIN, MAX, and COUNT(column) all skip NULL values. This is usually what you want, but it becomes a subtle bug when you're counting or summing across a LEFT JOIN.

SELECT c.name AS customer_name, COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.name

For customers with no orders, o.id is NULL for every row in their group. COUNT(o.id) returns zero because it counts non-NULL values and finds none. This is correct for counting orders. But SUM(o.total_amount) for the same customers returns NULL, not zero — SUM of nothing is NULL. Whether that NULL should become zero with COALESCE is a judgment call. A customer with zero revenue is not the same as a customer with no data.

How does GROUP BY treat NULL values?

One more behavior worth knowing: GROUP BY treats NULL as a distinct value. If a column used in GROUP BY contains NULLs, all NULL rows are grouped into a single output row labeled NULL. This is easy to overlook when scanning results.

Why does a WHERE clause turn a LEFT JOIN into an INNER JOIN?

The one thing that trips people up: a WHERE filter on a joined column silently defeats a LEFT JOIN.

Any filter on a column from the right-side table will eliminate unmatched rows, because that column is NULL for those rows, and NULL fails every comparison. If you need to filter on a right-side column while preserving unmatched rows, the condition belongs in the ON clause, not the WHERE clause.

Check your understanding

You run a LEFT JOIN from customers to orders. You then add WHERE orders.id IS NOT NULL. What happens to customers with no orders?

Practice NULL Handling in Joins

Practice · easy analytics · Streamhub

Streamhub's engineering team is auditing session data quality.

Write a query to return the total number of sessions and the number of completed sessions as a single row.

Assumptions:

  • The sessions table has one row per session with an ended_at value.
  • Sessions still in progress have a missing ended_at; completed sessions have a recorded ended_at.
  • The total number of sessions covers every session record. The number of completed sessions covers only sessions with a recorded ended_at.

Output:

  • A single row with columns total_sessions and completed_sessions.
Schema · analytics5 tables? = nullable
users
idinteger
nametext
emailtext
countrytext
plantext
signed_up_attimestamptz
is_activeboolean
conversions
idinteger
user_id?integer
converted_attimestamptz
plantext
amountnumeric
sessions
idinteger
user_id?integer
started_attimestamptz
ended_at?timestamptz
event_countinteger
events
idinteger
user_id?integer
session_id?integer
event_typetext
occurred_attimestamptz
properties?jsonb
periods
idinteger
nametext
start_monthinteger
end_monthinteger

Run previews · Check grades

Write a query, then run it to see results here.

Worked solution

The full breakdown walks through the shape, each clause, why this approach beats the alternatives, and the trap to avoid.

See the full worked solution

9 NULL Handling in Joins practice problems

Start learning to practice all 9 NULL Handling in Joins problems, with instant grading and mastery tracking.

Common questions about NULL Handling in Joins

What is the difference between filtering in ON and filtering in WHERE?

ON decides which rows count as a match; WHERE decides which rows survive after the join has run. On a LEFT JOIN that difference is the whole ballgame, because a WHERE test on a right-side column removes the unmatched rows the join was written to keep.

Can you tell whether a NULL came from the data or from an unmatched join?

Not from the value alone, which is why it is worth deciding at the join. Test a column that is never NULL in the source table, usually its key: a NULL there means no match was found, while a NULL anywhere else may simply be missing data.

Should you replace every NULL a LEFT JOIN produces with zero?

Only when zero is the honest answer. A customer with no orders genuinely has a revenue of nothing, so zero reads well. But no data and a measured zero are different facts, and flattening one into the other hides the difference from whoever reads the report.

How you actually get good at SQL

Reading explains SQL. Writing it, over and over with instant feedback, is what makes you fluent.

That's the whole SQLMaxx loop: 600+ real problems, instant AI feedback, mastery you can actually see, and spaced review that won't let you forget.

A stack of SQL practice problem cards, the top card showing an employees table.
615 problems · 66 concepts

Real problems. Not toy examples.

615 hand-built problems spanning all 66 concepts, from basic SELECTs to window functions, built on real schemas and real business questions, the kind you'll actually get asked on the job. Enough reps to make SQL automatic.

A retro computer showing a SQL query marked correct with a green checkmark.
Instant AI feedback

Write a query. Know if it's right in one second.

No copying an answer and hoping it clicked. The AI grader checks your real query against real data, catches exactly what's wrong, and explains the fix in plain English, like a senior analyst reading over your shoulder on every problem.

A circular mastery progress dial filling from blue to green, the SQLMaxx diamond at its center.
Mastery tracking

Stop guessing whether you actually know it.

SQLMaxx tracks every concept and shows you what you've mastered and what's still shaky. Your skills fill in one concept at a time, so 'I think I get joins' becomes something you can prove.

A SQL query editor circled by a blue return arrow with a clock, scheduled to come back for review.
Spaced review

Learn it once. Keep it for good.

Most of what you learn this week fades by next week. So when a concept comes due for review, SQLMaxx hands you a fresh problem to solve from a blank editor, not a flashcard to re-read. A research-backed spaced-repetition algorithm (FSRS) times each return for right before you'd forget, so your SQL is still there months later, when the interview or the job actually needs it.