Browse the docs

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.

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

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

SendQL
count(open within 30d) >= 3 and exists(click where url contains "/pricing" within 7d)

Age terms ask how long ago a datetime attribute was.

SendQL
now - attr.signup_date between 3d and 14d

Consent terms ask what the contact has agreed to receive.

SendQL
subscribed to "product-updates" and not suppressed

Membership terms ask whether they are in a list or another segment.

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

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

SendQLSyntax
a or b and c        // parses as: a or (b and c)

Parentheses override, and are worth using even when they are technically redundant:

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