SendQL
Time
Durations, windows, `now`, and the age of a datetime attribute.
Time shows up in SendQL in three places, and they are easy to keep apart once you have seen all three side by side.
now - attr.signup_date > 7d // age: how long ago was this attribute?
count(open within 30d) >= 3 // window: bound an event source in time
last(activity.login) < now - 14d // recency: when did this last happen?
Durations
A duration is an amount and a unit suffix, with no space between them:
30d 6mo 12h 2w 1y 90s
The units are s, m, h, d, w, mo, y. There is one form and only one
form — 30 days is not a duration, 30 d is not a duration, and -1d is not a
duration. Durations are unsigned; direction comes from the operator around them.
The unit you must be careful with is m, which is minutes. Months are mo.
5m // five minutes
5mo // five months
Months and years are nominal: 30 days and 365 days exactly. They are not
calendar-aware, and this is deliberate. A calendar-exact month would make a
literal’s meaning depend on when it happened to be evaluated, which would make it
impossible to check a window statically or to fold a schedule at compile time.
6mo means 180 days, on every day of the year.
The full table is on the durations reference.
now
now is the current instant, and it is only useful in two shapes:
now // the instant itself
now - 14d // an instant, fourteen days ago
You can subtract a duration from now, and that is the whole of the arithmetic
in SendQL. You cannot add to it, multiply it, or subtract two attributes from
each other. A date literal works anywhere now does:
last(open) > 2026-01-01
Windows
A window bounds an event source. It comes last inside the parentheses, after any
where clause.
within <duration> — relative to now, looking backwards:
count(click within 7d) >= 1
between <time> and <time> — an absolute or relative span:
count(open between 2026-01-01 and 2026-02-01) > 0
count(open between now - 30d and now - 7d) >= 2
The second one is the interesting shape: “opened at least twice, between a month
ago and a week ago” — a question about a slice of the past that within cannot
express.
Write the bounds backwards and you get an error rather than a query that matches nobody:
count(open between 2026-02-01 and 2026-01-01) > 0
Omit the window and the source means ever.
Age
now - attr.<datetime> is the age of a datetime attribute. It produces a
duration, which you compare against a duration:
now - attr.signup_date > 7d
now - attr.trial_ends_at between 3d and 14d
Read now - attr.signup_date > 7d as “signed up more than seven days ago”.
Bigger age means longer ago, which is the intuitive direction — but note that it
is the opposite direction from a recency term, where < means “longer ago”.
That is not an inconsistency: an age is a duration and gets bigger with time,
while last(...) is an instant and gets smaller the further back it was.
If you only remember one thing:
| Shape | > means | < means |
|---|---|---|
now - attr.x (a duration) | longer ago | more recently |
last(source) (an instant) | more recently | longer ago |
The attribute must be a datetime. An age term on a string is an error, and the
between bounds may not be reversed.
Age is absence-sensitive
Every age term silently excludes contacts where the attribute is unknown, and SendQL will warn you about it every time:
now - attr.signup_date > 7d
warning: predicate on 'attr.signup_date' excludes contacts where it is unknown
If that is what you wanted, silence it by saying so:
has attr.signup_date and now - attr.signup_date > 7d
If it was not, include the absent case explicitly:
now - attr.signup_date > 7d or attr.signup_date is unknown
See the absence contract for why this is a warning rather than a default.