Segment query language
Customer segments are defined in SegmentQL, a small query language you edit
directly in the segment editor. If you have written a spreadsheet filter or a SQL
WHERE clause, you already know most of it. This page is the complete language
reference; for the list of every attribute you can filter on, see the
segment attribute reference, and for the
merchant-level guide see
Customers & segments.
The editor scaffold
The editor always shows a four-line query:
FROM customersSHOW customer_name, email, orders, amount_spentWHERE amount_spent >= 500 AND last_order_date > -30dORDER BY amount_spent DESCOnly the WHERE clause is editable — it is the segment definition.
FROM customers and SHOW are fixed (the SHOW columns mirror the preview
table below the editor), and ORDER BY is rewritten by the Sort by control
in the toolbar rather than typed. Keywords are case-insensitive when you type;
the editor prints them uppercase.
As you type, the editor offers context-aware completions (attributes, then the
operators valid for that attribute, then values), underlines invalid parts in
red with a hover explanation, and shows a live count above the editor —
Run (or Cmd/Ctrl + Enter) refreshes the matching-customers preview.
Counts above 10,000 display as 10,000+. A segment cannot be saved while the
query has errors.
Conditions and boolean logic
A condition is attribute operator value:
amount_spent > 1000customer_tags CONTAINS 'vip'last_order_date >= -90dCombine conditions with AND and OR. AND binds tighter than OR, so
amount_spent > 1000 AND orders_placed IS NOT NULL OR customer_tags CONTAINS 'vip'means (spent over 1,000 AND has ordered) OR is tagged vip. Use parentheses to group explicitly — they always win over precedence:
amount_spent > 1000 AND (customer_location.country = 'IN' OR customer_tags CONTAINS 'vip')There is no NOT (...) group operator. Negation is written per condition with
the negative operator forms: !=, NOT CONTAINS, NOT IN, NOT_MATCHES,
IS NOT NULL, NOT IN LAST.
Operators
Which operators an attribute accepts depends on its type — the attribute reference lists them per attribute, and the editor only ever suggests valid ones.
| Operator | Meaning | Example |
|---|---|---|
= / != | equals / not equals | customer.state = 'ENABLED' |
> >= < <= | numeric or date comparison | amount_spent >= 500 |
BETWEEN x AND y | inclusive range | amount_spent BETWEEN 100 AND 999.99 |
CONTAINS / NOT CONTAINS | list membership, or substring on text | customer_tags CONTAINS 'wholesale' |
CONTAINS ALL (...) / CONTAINS ANY (...) | every / at least one of the listed values | customer_tags CONTAINS ALL ('vip', 'newsletter') |
STARTS WITH / ENDS WITH | text prefix / suffix | customer.email ENDS WITH '@gmail.com' |
IN (...) / NOT IN (...) | one of a list of values | customer_countries IN ('IN', 'AE') |
IS NULL / IS NOT NULL | value absent / present | customer.birthday IS NOT NULL |
IN LAST n UNIT / NOT IN LAST n UNIT | within the last n days/weeks/months/years | last_order_date IN LAST 90 DAYS |
IN NEXT n UNIT | within the coming n days/weeks/months/years | loyalty MATCHES (next_expiration_date IN NEXT 30 DAYS) |
IN MONTH | recurring date falls in a month | customer.birthday IN MONTH May |
ON month day | recurring date falls on a day | customer.birthday ON May 24 |
MATCHES (...) / NOT_MATCHES (...) | function attributes — see below | products_purchased MATCHES (id = '…', quantity >= 2) |
On function attributes, IS NOT NULL means has ever and IS NULL means
never — for example orders_placed IS NULL is “has never placed an order”.
Which operators an attribute offers
Autocomplete only ever suggests operators the attribute accepts, and the validator holds a hand-typed query to the same rules — so a query that was valid to type is also valid to save.
IS NULL / IS NOT NULL need an attribute that can actually be empty.
Attributes that always have a value don’t offer them: a customer who has never
ordered has number_of_orders = 0, not a missing value. That covers
number_of_orders, amount_spent, customer_added_date,
customer.account_anniversary, customer_account_status,
email_subscription_status, and the always-set profile booleans —
customer.tax_exempt, customer.is_walkin, customer.is_guest,
customer.has_verified_email, customer.has_verified_phone,
customer.was_referred, customer.is_b2b_buyer,
customer.has_purchased_gift_card, customer.email_can_receive_marketing,
customer.email_open_tracking_allowed, and
customer.email_click_tracking_allowed. Write the comparison you mean instead
(number_of_orders = 0, customer.tax_exempt = false). customer_language
is nullable and keeps both operators.
IN MONTH and ON <month> <day> need a recurring date. They match every
year, so they only make sense on the recurring-date attributes —
customer.birthday, customer.account_anniversary,
customer.first_order_anniversary — and on customer date / date-time
metafields. On a plain date attribute such as last_order_date they are
rejected at validation; use a BETWEEN range or an IN LAST window there.
Values
Text
Single-quoted. Escape a quote as \' (typing '' also works; the editor
prints \').
customer.note CONTAINS 'says \'call after 6\''Text comparison is case-sensitive, with a few deliberate exceptions: tag
values, discount codes, color metafields, and customer_email_domain equality
all match case-insensitively (customer_email_domain = 'Gmail.com' finds the
same people as 'gmail.com'). CONTAINS on text attributes is a
case-insensitive substring match; CONTAINS on list attributes (like
customer_tags) is an exact-member match.
Numbers and money
Plain numbers with a . decimal separator and no thousands separators. Money
values are written in major currency units — amount_spent > 500 means
₹500 (or your store currency), not 500 paise, and carry at most three
decimal places (enough for every currency’s minor units; a fourth is
rejected). Numbers are capped at a magnitude of 1e12 in either direction, so a
mistyped amount_spent > 50000000000000 is caught as you write it rather than
compiling into an arithmetic overflow.
Enums and option lists
Most enum attributes are closed: the canonical status lists
(customer_account_status, email_subscription_status, order
financial_status, and so on) accept only the spellings in the
attribute reference, case-insensitively.
A few are open — the editor’s dropdown is a convenience, not the whole world of legal values:
company.idandcompany.namelist at most 250 companies.loyalty.tierlists your store’s current tiers.
On these, a value outside the offered list still compiles and saves; the editor just warns you that it doesn’t recognise it. That’s deliberate — it keeps a query working on a store with more companies than the picker can show, and keeps a saved segment valid after a tier is renamed or retired.
Booleans
true / false, unquoted:
product_review MATCHES (has_video = true)Dates and times
| Form | Example | Notes |
|---|---|---|
| Absolute date | 2026-05-25 | yyyy-mm-dd |
| Absolute date-time | 2026-05-25T18:30:00 | interpreted in the store timezone |
| Offset | -30d, -4w, -6m, -1y, +7d | units: d w m y, relative to now |
| Named | today, yesterday, tomorrow | resolved daily in the store timezone |
Offsets and named dates are dynamic: a segment using last_order_date > -30d
re-evaluates every day, so membership stays current without edits.
Unit letters are case-insensitive, so +7D and -6M work as well as +7d
and -6m.
What the validator checks
A date that can’t exist, or a window nothing could fall into, is an error you see while typing rather than a segment that quietly matches nobody:
- Bare dates and date-times are calendar-checked.
2026-02-30and2026-13-01are rejected, not rounded. A trailing timezone offset is range-checked too, so+05:30is fine and+25:00isn’t. - Windows and offsets cap at about a century per unit — 36,600 days,
5,300 weeks, 1,200 months, or 100 years.
IN LAST 36600 DAYSis legal;IN LAST 99999 DAYSis not. - A
BETWEENrange that resolves to nothing is a compile error. If the bounds invert once they’re resolved in the store timezone — a lower bound written with an explicit offset against a naive upper bound, for instance — you get told the range is empty instead of a segment that silently returns zero customers.
Measurements
Dimension, volume, and weight metafields take a number followed by a unit;
values are compared with unit conversion (a 5 cm condition matches a value
stored as 50 mm):
metafields.specs.max_width <= 5 cmmetafields.specs.capacity BETWEEN 250 AND 750 mlResource IDs and named values
Attributes that reference products, variants, collections, locations, brands,
or email campaigns take the resource’s ID — and you never have to type one:
the editor’s autocomplete searches by name and inserts the ID for you. A raw
ID left in the query doesn’t stay opaque either: when it resolves, the editor
prints the resource’s title next to it in dim inline text, so
products_purchased MATCHES (id = '018f2e6a-…') reads as the product you
actually meant. The same name-based suggestions cover tag values
(customer_tags, products_purchased.tag) and taxonomy categories
(products_purchased.category), which insert the quoted value instead of an
ID.
Free-text attributes get value suggestions too, drawn from your own store’s
data — see
value suggestions
in the segments guide, which matters most for customer_cities and
customer_regions, whose stored values are composites you can’t guess.
Function attributes and MATCHES
Function attributes describe related records — orders, purchased products,
reviews, gift cards, event tickets, and so on. Their conditions go inside
MATCHES ( ... ), separated by commas (a comma means AND; OR is not allowed
inside the parentheses):
orders_placed MATCHES (count >= 3, date >= -6m)products_purchased MATCHES (tag = 'sportswear', sum_quantity >= 3)product_review MATCHES (product_id = '018f2e6a-…', has_video = true)gift_cards MATCHES (balance > 0, next_expiry_date IN NEXT 14 DAYS)All conditions inside one MATCHES scope apply to the same related record
(or the same aggregation over them) — “rated this product below 3 stars” is
one scope:
product_review MATCHES (product_id = '018f2e6a-…', rating <= 2)NOT_MATCHES ( ... ) (also accepted as NOT MATCHES) selects customers with
no related record satisfying the scope. IN ( ... ) value lists are
allowed on ID parameters inside a scope:
products_purchased MATCHES (id IN ('018f…', '018e…'), date >= -90d)Parameters belong to their function — one that exists on one function is not
automatically available on another. That matters most on the storefront
behavior events, where the product parameters (id, variant_id, tag,
category, and product metafield.…) exist only on
storefront.product_viewed, product_added_to_cart, and
product_removed_from_cart; id and metafield.… on
storefront.collection_viewed address the collection; and the generic events —
page_viewed, cart_viewed, checkout_started, checkout_completed,
search_submitted, the discount_code_* events, and payment_failed — carry
only date, count, path, campaign_id, message_id, and value_amount.
Reaching for a product parameter on a generic event is an error you see as you
type, not a condition that matches nobody.
Two functions have special parameter forms:
- Distance —
customer_within_distance MATCHES (coordinates = (13.0827, 80.2707), distance_km = 25)(usedistance_mifor miles). - Anniversary sugar —
anniversary('customer.birthday') BETWEEN today AND +30dis accepted as shorthand for the recurring-date operators on any recurring-date attribute; the editor rewrites it to the canonical form when it reprints the query.
Metafield paths
Customer metafields are addressed as metafields.<namespace>.<key>
(customer.<namespace>.<key> is also accepted):
metafields.b2b.price_band = 'gold'metafields.facts.renewal_date IN NEXT 30 DAYSInside a MATCHES scope, metafields of the related record use
metafield.<namespace>.<key> and resolve against that record’s owner type:
products_purchased MATCHES (metafield.materials.fabric = 'cotton')A metafield must have Use as filter enabled on its definition to appear in segments. Namespaces or keys that don’t fit the identifier syntax are written in backticks. See the attribute reference for which value types are filterable and how list metafields behave.
Grammar summary
where = orExprorExpr = andExpr { OR andExpr }andExpr = primary { AND primary }primary = "(" orExpr ")" | conditioncondition = attribute operator [ value ] | function ( "MATCHES" | "NOT_MATCHES" ) "(" param { "," param } ")" | function ( "IS NULL" | "IS NOT NULL" )param = subfield operator [ value ]value = string | number [ unit ] | boolean | date | offset | id | "(" value { "," value } ")"Worked examples
-- Lapsed high-value customersamount_spent >= 5000 AND last_order_date NOT IN LAST 90 DAYS
-- Subscribed locals near the flagship storecustomer.email_subscription_status = 'SUBSCRIBED' AND customer_within_distance MATCHES (coordinates = (13.0827, 80.2707), distance_km = 25)
-- COD-heavy repeat buyers (for prepaid-incentive campaigns)orders_placed MATCHES (count >= 3) AND orders_placed MATCHES (is_cod = true, date >= -6m)
-- Bought from the Beauty category but never left a reviewproducts_purchased MATCHES (category = 'ha-15') AND product_review IS NULL
-- Loyalty members with points expiring this monthloyalty MATCHES (status = 'enrolled', next_expiration_date IN NEXT 30 DAYS)
-- Waiting for a restock of a specific productstock_notifications MATCHES (product_id = '018f2e6a-…', status = 'active')
-- Birthday campaign audiencecustomer.birthday IN MONTH May AND customer.email_subscription_status = 'SUBSCRIBED'How segments stay current
Dynamic segments are recomputed automatically: profile edits, orders, consent
changes, loyalty/review/referral activity, and metafield writes all mark the
affected customers for re-evaluation within seconds, and date-based rules
(offsets, IN LAST, birthdays) are re-scanned daily in the store’s timezone.
You never need to press anything to keep a segment fresh — Refresh on the
segment page just forces an immediate pass.