← back to the archiveCover illustration for “Give SQL exclusion queries an explicit NULL policy”
TILday 135·today·Published ·by Andy Padia

Give SQL exclusion queries an explicit NULL policy

In short: Test both sides of a SQL exclusion query: keep a NULL in processed IDs from hiding valid work, while rejecting unidentified incoming rows.

sql ▸ WITH incoming(id) AS (VALUES (1), (2), (3), (NULL)),
     processed(id) AS (VALUES (2), (NULL))
SELECT i.id
FROM incoming AS i
WHERE i.id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1 FROM processed AS p WHERE p.id = i.id
  )
ORDER BY i.id;

Three known IDs went in. One was already processed. The query returned nothing. In a synthetic SQLite 3.53.2 check on 13 October 2026, a single NULL in the processed-ID list was enough to hide the two remaining IDs from a NOT IN query.

Give SQL exclusion queries an explicit NULL policy on both sides. For this example, the rule is: return known incoming IDs with no matching processed record; leave unidentified incoming rows out. A correlated NOT EXISTS handles the exclusion, while i.id IS NOT NULL makes the input policy visible. Neither clause substitutes for the other.

Why SQLite NOT IN can return no candidates

SQLite's expression reference documents the awkward case: when an ID has no match but the exclusion set contains NULL, NOT IN can evaluate to NULL, not true. An ID that does match is excluded normally. Together, those outcomes can remove every candidate.

The SELECT reference explains the final step: WHERE keeps only true expressions. Both false and NULL are filtered out. This is not SQLite reporting that every item was processed; it is the query failing to establish the required condition.

That distinction matters when an empty result is interpreted as “nothing left to do”. The worker can look perfectly healthy while its selection rule hides the backlog. That is a plausible application consequence, not a production incident measured here.

Give SQL exclusion queries a NULL policy in code

Run this self-contained statement in SQLite. The two common table expressions provide disposable input rows; they do not create tables or touch an existing queue. Expected output: 1, then 3.

WITH incoming(id) AS (VALUES (1), (2), (3), (NULL)),
     processed(id) AS (VALUES (2), (NULL))
SELECT i.id
FROM incoming AS i
WHERE i.id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1 FROM processed AS p WHERE p.id = i.id
  )
ORDER BY i.id;

The inner query asks whether a matching processed row exists. SQLite's EXISTS rules depend on whether the subquery returns rows, not the values selected by those rows. Here, the equality predicate defines the match. The literal 1 is just a conventional projection.

I executed this exact statement through Python's sqlite3 binding, against an in-memory database. Removing the outer non-NULL guard also returned the unidentified incoming row. Changing operators fixed one failure but left a separate decision unresolved.

Test the empty exclusion set as well

The input stayed 1, 2, 3, NULL throughout these local checks. “Guarded” means the complete query above, including its input-ID check.

Processed IDsNOT IN resultGuarded NOT EXISTS result
EmptyNULL, 1, 2, 31, 2, 3
21, 31, 3
2, NULLNo rows1, 3
NULLNo rows1, 2, 3
2, 2, NULLNo rows1, 3

The empty-set case is worth keeping in the regression suite. With no processed rows, the unguarded NOT IN admitted the missing input ID. A fixture containing only valid, matching IDs would miss both surprises.

These were functional checks with small synthetic integer inputs. They establish the returned rows, not query speed, an optimal index or behaviour in another database engine.

My judgement for a document-ingestion queue

For a hypothetical document-ingestion worker, I would treat an unidentified row as a data-quality task, not as ordinary unprocessed work. The selection query should exclude it, and a separate check should count or quarantine it. Quietly excluding bad inputs forever would merely move the blind spot.

My review question would be: “What happens when either side has no usable identity?” That is more useful than a blanket rule to replace every NOT IN. If the schema guarantees non-NULL identifiers, the trade-off is different; inspect that guarantee before rewriting working queries.

This example only selects candidates. It does not claim them atomically, prevent two workers selecting the same item, or roll back downstream side effects. Those require their own transaction and ownership design. Nor does it deduplicate repeated incoming rows.

What's in it for you

  • Add empty, NULL-only and mixed exclusion sets to query tests before trusting “zero pending”.
  • Make missing input identity a visible policy, with a separate route for repair.

An exclusion query needs to say what is absent—and what was never identified.

Sources

#sql#sqlite#testing
← older drop
Cache awaited values in sequential Python batches

related drops

explore all 388 drops →
← back to the archiveday 135