Browse the docs

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:

Contactattr.plan = "pro"attr.plan != "pro"
plan = "pro"matches
plan = "free"matches
no plan at allalso 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.

SendQL
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 > 7d excludes contacts with no signup date, always.
  • Recency terms. last(activity.login) < now - 14d excludes 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 does attr.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:

  • count and exists. Zero events is a perfectly well-defined number. count(open) = 0 and not 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:

SendQL
has attr.plan and attr.plan != "pro"

2. You meant to include them. Say that instead:

SendQL
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:

SendQL
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

SendQL
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:

SendQL
last(open) < now - 90d or count(open) = 0

The lint fires on the first one. Read your warnings.