Skip to main content
A segment is a saved answer to “who are these people”. Flags aim at one, checklists are shown to one, changelog posts are addressed to one, surveys ask one. That reuse is the whole point: a module that grew its own filter would be a second vocabulary for audience, and the two would disagree the first time a field was added to one of them.

The shape

A conjunction of groups; within a group, a disjunction of conditions.
(A OR B) AND C, and two levels is the whole grammar. Arbitrary nesting is a precedence question, a parser and a form nobody can draw; any-of AND any-of is what an audience builder actually offers, and this one says out loud what it cannot express instead of half-supporting it. At most 8 groups of 12 conditions. That is a large audience and a query Postgres plans without noticing — the bound exists so a definition cannot be made arbitrarily expensive by pasting. An empty group is refused, never read as “always true”: a builder that lost its last condition would otherwise silently become everybody.

Four sources

Which operators apply is decided by the field, and a mismatch is refused: created_at contains "@" is not a query with an empty answer, it is a question nobody meant to ask. Text fields take eq/neq/contains/not_contains/ starts_with/ends_with/in/not_in/is_set/is_not_set; booleans take is_true/is_false; timestamps take before/after/within_days/ not_within_days; events take occurred/not_occurred; metrics take at_least/at_most. An operand the condition does not read is refused rather than dropped. A days on a condition where days mean nothing would otherwise be a window you set, the engine ignored, and you believe you have. An event condition’s window is mandatory (1 to 730 days). There is no unbounded occurred, because a segment that scans every event ever stored is a segment that gets slower every month it is left alone. If you mean “ever”, say two years and mean it. A timestamp that is not set matches none of the four time operators — not_within_days included. An absent value answers no question about itself; reach those contacts with is_not_set.

Filtering on an event property

An event condition can look at one key of what track() sent:
Without it the remaining path is encoding the dimension in the name — checkout.completed.pro beside checkout.completed.free — which turns the namespace into a product and takes the grouping the explorer is built on with it. Four rules, each there for a reason that shows up later:
  • Only on occurred. “Did not happen with plan=pro” reads two ways — never happened, or happened with another plan — and the engine refuses rather than picking. For the first, use not_occurred on the event itself.
  • One key per condition. Two would be an AND, and a group of conditions is already how this engine spells AND.
  • The comparison is textual. A property is compared as the text it renders as: 4 is "4", true is "true". The column carries no schema, and any other reading would need a declared type per key. The operators are the text ones minus in / not_in — over a property those are an OR, and an OR is a second condition in the same group.
  • Absent and null are the same state as empty. So is_not_set is how you ask for the events that carry nothing, and neq does not match them by accident.
count still applies alongside it: “checked out with plan = pro at least twice” is one condition. An event property is not a contact attribute. Writing properties.plan as an attribute gets a refusal that points here.

What it refuses, and why refusing beats approximating

Two conditions are perfectly reasonable to want, and this engine will not state them. Both name themselves in the error, so you hit a sentence rather than an empty audience. A custom contact attribute. attributes.plan_tier has nothing behind it — the contact columns are the profile and the first-touch attribution, and there is no attribute store to match against. The only way to honour it would be to match something nearly right. A segment built on an approximation is not a smaller mistake than an error message: it is an audience that gets mailed, or a feature that ships to the wrong tenth of your users, and nobody finds out from the definition. An entitlement. Whether a customer holds a feature is resolved by the entitlements engine — overrides that replace rather than maximise, an expiry, a dunning grace, and a status vocabulary whose unknown member honestly answers “cannot tell”. Restating that in SQL would be a second copy of the one function that is deliberately kept alone, and the copy would be wrong in the cases that matter most. What a segment can target is the plan, which is a row: plan.key and plan.subscription_status are mirrors of your gateway, not resolutions of anything. A third refusal is narrower and worth knowing: a metric condition in a test environment. The activity meter never records test, so the condition could only ever match nobody — and an audience that is empty for a structural reason looks exactly like an audience that is empty because nothing has happened yet.

Two evaluators, one meaning

The same definition is compiled twice: into SQL over an environment’s contacts (“who is in this”), and into an in-process evaluation of one contact (“is this person in it, right now”). A campaign resolving recipients wants the first; a flag deciding what to send a browser at boot, or a survey asking whether somebody just entered, wants the second. Two implementations of one meaning is the shape that drifts, so every field carries both halves in one place and a test drives real contacts through both paths and refuses a disagreement. It is worth knowing this exists, because it is the reason you can trust the preview: the count you saw is produced by the same rules the flag will apply one page load later.

Membership, entry and departure

Who is in a segment is written down, not recomputed on every read — because what the modules above actually subscribe to is the entry. “Somebody entered trial-ending-in-3-days” is a difference between two evaluations, and a difference has nowhere to live unless the previous evaluation was recorded. Two things keep it current:
  • A fact lands and the contacts it is about are re-decided in seconds. This is the path that carries anything caused by somebody doing something.
  • A nightly sweep walks every live definition and recomputes. It is the only thing that can notice a condition that became true because time passed — “created more than 30 days ago”, “not seen in three weeks” turn true with nothing published anywhere, because nothing happened.
Entering publishes segment.entered and leaving publishes segment.left. Both are deliverable as webhooks, and the payload carries the ids and the segment’s name — never the definition. The first computation of a definition announces nothing. Writing an audience down for the first time is a backfill: nobody changed, the audience did, and one webhook per existing contact for an act you performed in the panel three seconds ago is not a fact about anybody. Editing the definition puts it back in that state, for the same reason — re-aiming an audience is not a hundred people entering it. definition_version is bumped on a definition change and never on a rename, so anything holding a compiled audience can tell it is stale by comparing an integer. left_at is a timestamp rather than a deletion, because leaving is also something you will want to act on: a win-back audience is people who left “active in the last 30 days”.

Preview before you aim anything at it

The count and at most 25 contacts, newest first. It writes nothing and it costs one query. The habit worth forming is previewing before a flag, a survey or a changelog post names the segment — every module downstream inherits whatever this definition means, and the cheapest place to discover it means something else is here.

Permissions

Reading a segment is customers:read — the same seat that can list contacts, because a preview is reading contacts. Creating, editing and archiving is segments:manage, which owner and admin hold by default. The split is deliberate. An audience is a piece of product configuration that every other module inherits; being able to look at one is not the same act as being able to move what a flag, a survey and a release note are all aimed at.