Browse the docs

SendQL

Attributes

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

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:

SendQLRejected
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, 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:

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

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)