SQL NOT IN and NULL

If even ONE value in a NOT IN list is NULL, the entire condition silently returns zero rows for every row in the table — one of the most consequential gotchas in all of SQL.

What & Why

NOT IN (list) is shorthand for a chain of <> comparisons joined by AND. If any single value in that list is NULL, one of those <> comparisons becomes UNKNOWN — and per three-valued logic's AND rules from earlier in this series, UNKNOWN AND anything-not-FALSE stays UNKNOWN. The practical result: the whole WHERE clause evaluates to UNKNOWN or FALSE for every single row, and the query returns nothing at all.

See How It Works

BUSINESS QUESTION

Marketing compares an unsafe and a NULL-filtered NOT IN check for campaigns without converted leads.

idcampaign_idemailcreated_atqualified_atconverted_atlead_scoresourcecountry
3011ana@example.com2024-01-21 09:10:00+002024-01-22 11:00:00+002024-02-02 10:00:00+0086google_adsUS
3021ben@example.com2024-01-24 12:40:00+00NULLNULL52google_adsCA
3032chloe@example.com2024-02-16 08:30:00+002024-02-18 14:20:00+00NULL74emailGB
3043dev@example.com2024-03-20 17:15:00+002024-03-21 09:00:00+002024-04-04 16:00:00+0091linkedinUS
idnamechannelspendstart_dateend_datestatustarget_segment
1Spring Launchgoogle_ads55000.002024-01-152024-03-31activesmb
2Retention Webinaremail45000.002024-02-102024-04-15activeenterprise
3Finance Retargetinglinkedin50000.002024-03-122024-05-31activeenterprise
4Enterprise Searchgoogle_ads60000.002024-04-012024-06-30activeenterprise
Watch one NULL poison a NOT IN exclusionStep 1 of 2
id NOT IN (1, 3)
idnamenot_in_result
1Spring LaunchFALSE
2Retention WebinarTRUE
3Finance RetargetingFALSE
4Enterprise SearchTRUE

After NULL is filtered out, campaigns 2 and 4 are correctly absent from the converted-campaign set.

EXAMPLE QUERY
WITH converted_campaign_ids AS (
  SELECT
    CASE WHEN converted_at IS NOT NULL THEN campaign_id END AS campaign_id
  FROM marketing.leads
)
SELECT
  c.id,
  c.name,
  c.id NOT IN (
    SELECT campaign_id FROM converted_campaign_ids
  ) AS unsafe_not_in,
  c.id NOT IN (
    SELECT campaign_id
    FROM converted_campaign_ids
    WHERE campaign_id IS NOT NULL
  ) AS safe_not_in
FROM marketing.campaigns c
ORDER BY c.id;
Now You Try

Practice this concept

Marketing compares unsafe and NULL-filtered NOT IN checks for campaigns without converted leads.

Available schema
marketing

Prefix tables with marketing.table_name.

idnameunsafe_not_insafe_not_in
marketing.campaigns
ColumnType
idinteger
nametext
channeltext
spendnumeric
start_datedate
end_datedate
statustext
target_segmenttext
legacy_idtext
marketing.leads
ColumnType
idinteger
campaign_idinteger
emailtext
created_attimestamp with time zone
qualified_attimestamp with time zone
converted_attimestamp with time zone
lead_scoreinteger
sourcetext
countrytext
archive_statustext
query.sql
Intermediate business practice

Sign up free to try it on a real business scenario