Browse the docs

SendQL

Type rules

What the analyzer checks, and the exact rule behind every type error it can produce.

Parsing and type-checking are separate stages. A query that parses is well-formed; a query that analyzes is meaningful. This page is the complete set of rules the second stage enforces.

The five types

string, number, bool, datetime, duration.

duration never appears as an attribute’s type — it only exists as a literal, in a window (within 30d), an age comparison (> 7d), or a workflow schedule. So in practice an attribute is one of the other four.

An enum is not a sixth type. It is a string attribute with an additional constraint on which values are allowed.

Operators

Ordering — <, <=, >, >=. Both operands must be orderable and must have the same type. Orderable means number, datetime, duration — and string, which compares lexicographically.

SendQL
attr.plan > "m"

That is legal, and does what it says: plans whose name sorts after "m". It is almost never what anyone means, but the language is not in the business of guessing.

SendQLRejected
attr.plan > 5

operator > is not valid between string and number

Equality — =, !=. Both operands must have the same type. There is no coercion whatsoever: attr.score = "10" is an error, not a silent parse of the string.

String matching — contains, starts with, ends with. The left operand must be a string. The right operand is a string literal by grammar, so it cannot be anything else.

SendQLRejected
attr.score contains "1"

Sets — in [...]. Every element must have the same type as the left operand. A mixed list is an error, reported per offending element.

SendQLRejected
attr.score in [1, "two", 3]

Presence — has, is known, is unknown. No type rule at all. They work on every type, and are the only operators that do.

Enum membership

When an attribute is constrained to a set of allowed values, =, != and in [...] additionally check that the literal is a member:

SendQLRejected
attr.subscription in ["free", "gold"]

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

The check is deliberately not applied to ordering or string-matching operators, where testing against a non-member is legitimate (attr.subscription starts with "p").

Age terms

now - attr.x requires x to be a datetime. The result is a duration, so the comparison must be against a duration.

SendQLRejected
now - attr.plan > 7d

age expression `now - attr.plan` requires a datetime attribute, got string

A between whose bounds are both known and reversed is rejected:

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

Event terms

TermCompares against
count(...)a number
sum, avg, min, maxa number
last(...), first(...)a time — now, now - <duration>, or a date
SendQLRejected
count(open within 30d) >= "3"

Aggregate fields must exist on the source and be numeric:

SendQLRejected
sum(template of open) > 1

aggregate sum requires a numeric field, but open.template is string

where properties must exist on the source, and their comparisons are type-checked against the property’s own type:

SendQLRejected
exists(open where colour = "red")

Activity properties must be promoted before they can be filtered or aggregated, and the promoted type is then checked exactly as an event property’s would be.

Windows

A between window whose bounds are both bare date literals is checked for reversal:

SendQLRejected
count(open between 2026-02-01 and 2026-01-01) > 0

A window with now or an offset in it is not statically checked — its bounds are not known until it runs.

Reserved words

A reserved word may not be used as a bare name: not as an event name, not as a where property name, not as an aggregate field name.

SendQLRejected
sum(count of activity.order) > 1

Qualified and quoted names are exempt, because they cannot be ambiguous. attr.count is fine; so is in list "count".

Unknowns short-circuit

If one side of a comparison has already failed to resolve — an unknown attribute, an unknown property — the analyzer does not then also complain that its type does not match. You get one error about the real problem, not two errors about the same typo.