SendQL
The absence contract
What happens when the data isn't there — why `attr.plan != "pro"` doesn't match a contact with no plan, and the lint that tells you.
This is the one semantic in SendQL that reliably surprises people. It is worth ten minutes now rather than an incident later.
The rule
An absent value does not compare. It is not false — it is nothing.
A contact with no plan attribute at all is not matched by attr.plan = "pro",
which is unsurprising. But they are also not matched by attr.plan != "pro",
which is very surprising, because in ordinary logic those two are exhaustive.
They are not exhaustive here. Three groups exist, not two:
| Contact | attr.plan = "pro" | attr.plan != "pro" |
|---|---|---|
plan = "pro" | matches | — |
plan = "free" | — | matches |
no plan at all | — | also no match |
The third row is the whole page. SendQL follows SQL’s three-valued logic: comparing against a missing value yields unknown, and a term that is unknown does not select the contact.
Why it is like this
The alternative is worse. If a missing attribute defaulted to “not equal to
everything”, then attr.churned_at != "2026-01-01" would match every contact who
has never churned — which is almost certainly the opposite of what a query
containing churned_at was reaching for. Guessing at the meaning of missing data
produces sends that are confidently wrong.
So SendQL never guesses. It excludes, and then it tells you it excluded.
The lint
Every absence-sensitive term produces a warning — not an error, because excluding the absent case is often exactly right.
attr.plan != "pro"
warning: predicate on 'attr.plan' excludes contacts where it is unknown — add 'or attr.plan is unknown' to include them, or 'has attr.plan' to silence this
The message contains both of the fixes, which is the point: the lint is not scolding you, it is asking you to make a decision explicit.
Which terms are sensitive
Not all of them. The lint is precise about this, so that it stays worth reading.
Always sensitive:
- Age terms.
now - attr.signup_date > 7dexcludes contacts with no signup date, always. - Recency terms.
last(activity.login) < now - 14dexcludes contacts who have never logged in. This one cannot be silenced — an event source has no presence to assert.
Sensitive only when negated:
!=.attr.plan != "pro"warns.attr.plan = "pro"does not.- A negated comparison.
not (attr.plan = "pro")warns, for the same reason. - A negated set or string match.
not attr.country in ["US"]warns;attr.country in ["US", "CA"]does not, and neither doesattr.email contains "@x.io". The positive form also excludes absent contacts — but that is the reading everyone expects. It is the negated form that surprises.
Never sensitive:
countandexists. Zero events is a perfectly well-defined number.count(open) = 0andnot exists(open)both correctly match never-openers.is known/is unknown/has. They are the presence test.
The three ways to respond
Once you see the warning you have three honest options.
1. You meant to exclude them. Say so, and the warning goes away:
has attr.plan and attr.plan != "pro"
2. You meant to include them. Say that instead:
attr.plan != "pro" or attr.plan is unknown
3. For a recency term, spell out the empty case. This is the important one,
because last(...) cannot be guarded with has:
last(activity.login) < now - 14d or count(activity.login) = 0
Read it as “logged in more than two weeks ago, or never logged in at all”. That is what a winback segment almost always means, and the version without the second clause quietly omits your most-lapsed users — the exact people the campaign exists for.
The one that has bitten everybody
last(open) < now - 90d
“Everyone who has not opened an email in ninety days.” It reads perfectly. It excludes every contact who has never opened an email at all — which, on most lists, is a substantial fraction of the very cohort you are trying to win back.
What you wanted:
last(open) < now - 90d or count(open) = 0
The lint fires on the first one. Read your warnings.