SendQL
SendQL
A query language for defining who a message is for. One query is one boolean expression.
A SendQL query is one boolean expression. There is no SELECT, no FROM,
no statement separator, no trailing semicolon. You write the condition and
nothing else, because there is only ever one thing being selected: contacts.
attr.plan = "pro" and attr.country in ["US", "CA", "MX"] and not suppressed
Read it aloud and you have its meaning. That is the entire design goal.
The five kinds of term
Every SendQL query is built from terms of exactly five kinds, combined with
and, or, not and parentheses.
Attribute terms ask about the contact’s own data.
attr.score >= 10 and attr.email ends with "@altacoda.io" and has attr.company_name
Event terms ask about the raw event and activity stream — how many, whether any, the sum of, the most recent.
count(open within 30d) >= 3 and exists(click where url contains "/pricing" within 7d)
Age terms ask how long ago a datetime attribute was.
now - attr.signup_date between 3d and 14d
Consent terms ask what the contact has agreed to receive.
subscribed to "product-updates" and not suppressed
Membership terms ask whether they are in a list or another segment.
in list "beta-testers" and not in segment "power-users"
Each has its own page. That is the whole language.
Events are first class
This is the part that is genuinely different from most segment builders.
Most engines let you filter on precomputed rollups: a total_opens counter, a
last_seen timestamp — fields somebody decided to materialise in advance. If the
rollup you need was not precomputed, you cannot ask the question.
SendQL queries the stream directly. count, exists, sum, avg, min,
max, last and first all operate over raw events and activities, each of
which can carry a where filter over its properties and a time window bound to
it:
sum(amount of activity.order where status = "paid" within 90d) > 500
Nobody had to precompute “revenue from paid orders in the last ninety days”. There is no rollup here at all — the term is the question.
Precedence
or binds loosest, then and, then not, then a primary. So:
a or b and c // parses as: a or (b and c)
Parentheses override, and are worth using even when they are technically redundant:
(attr.plan = "pro" or attr.plan = "team") and not suppressed
Without the parentheses that query means something quite different, and quietly mails your suppressed pro users.
What a query does not have
No sorting. No limits. No projection — you never choose which fields come back,
because a segment is a set of contacts, not a table. No joins you write
yourself; the event terms are the join. No functions beyond the eight aggregates,
no arithmetic beyond now - <duration>, and no way to call your own code.
The language is small on purpose. Everything it can express, it can also type-check, explain in an error message, and compile to a query that runs over millions of contacts without you thinking about it.
Next
- Syntax — the tokens, literals and operators
- Attributes — matching on contact data
- Events and activities — querying the stream
- The absence contract — the one semantic that surprises people