# Attributes

> Matching on the contact's own data — comparison, sets, string matching, presence, and enums.

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

An attribute term asks about a field on the contact. Every attribute reference is
qualified with `attr.`:

```sendql
attr.plan = "pro"
```

## Comparison

`=` and `!=` work on any type, provided both sides are the *same* type. The
ordering operators `<`, `<=`, `>`, `>=` work on numbers, datetimes, durations
and — perhaps surprisingly — strings, which compare lexicographically.

```sendql
attr.score >= 10 and attr.signup_date < 2026-01-01 and attr.verified = true
```

Mixing types is an error, not a coercion:

**Rejected — the parser refuses this:**

```sendql
attr.score = "10"
```

## Sets

`in [...]` tests membership of a literal set. Every element must have the same
type as the attribute.

```sendql
attr.country in ["US", "CA", "MX"] and attr.seats in [1, 2, 3]
```

The list may not be empty — `attr.country in []` is a parse error, because it
would be a term that matches nothing, spelled the long way.

## String matching

Three operators, all requiring a string on both sides:

```sendql
attr.email ends with "@altacoda.io"
  and attr.name starts with "A"
  and attr.company_name contains "Ltd"
```

They are operators, not functions — no parentheses, no arguments. There is no
regex operator, and no case-insensitive variant.

## Presence

An attribute can be absent. Two spellings ask whether it is:

```sendql
has attr.company_name
```

```sendql
attr.tier is known and attr.churned_at is unknown
```

`has attr.x` and `attr.x is known` mean exactly the same thing; pick whichever
reads better in context. `is known` / `is unknown` work on any type, and are the
only operators that do not care about the attribute's type at all.

These matter more than they look. A missing attribute does not compare as
`false` — it does not compare at all, so `attr.plan != "pro"` **skips** a contact
who has no plan rather than matching them. This is the [absence
contract](/docs/sendql/absence), and it has its own page because it is the one
semantic that reliably surprises people.

## Enums

An attribute can be constrained to a fixed set of allowed values. It still types
as a `string` and behaves like one, but comparing it to a value outside the set
is caught:

**Rejected — the parser refuses this:**

```sendql
attr.subscription = "gold"
```

> `"gold" is not an allowed value for attr.subscription; allowed values are "free", "pro", "enterprise"`

Without enums that query is perfectly well-typed and matches zero contacts
forever. This is the difference between a language that checks your work and one
that merely accepts it.

The check applies to `=`, `!=` and `in [...]`. It deliberately does **not** apply
to the ordering or string-matching operators, where comparing against a
non-member is a legitimate thing to do:

```sendql
attr.subscription starts with "p"
```

## Age

`now - attr.<datetime>` yields a duration, which you then compare against a
duration literal. It is the idiomatic way to ask "how long ago":

```sendql
now - attr.signup_date > 7d
```

```sendql
now - attr.signup_date between 3d and 14d
```

The attribute has to be a `datetime` — an age term on a string is an error — and
the bounds of a `between` may not be reversed. `between 14d and 3d` is rejected
statically rather than matching nobody.

Note that an age term is *always* absence-sensitive: a contact with no
`signup_date` is excluded from both of the queries above. See
[absence](/docs/sendql/absence).

## Everything together

```sendql
// Engaged trial users in North America, mid-trial, never bought.
attr.plan = "trial"
  and attr.country in ["US", "CA", "MX"]
  and now - attr.signup_date between 3d and 14d
  and has attr.company_name
  and not exists(activity.order)
```
