# 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.

Source: https://www.sendlang.com/docs/sendql/absence
Section: SendQL

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.

```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.
