Skip to content

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 customers
SHOW customer_name, email, orders, amount_spent
WHERE amount_spent >= 500 AND last_order_date > -30d
ORDER BY amount_spent DESC

Only 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 > 1000
customer_tags CONTAINS 'vip'
last_order_date >= -90d

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

OperatorMeaningExample
= / !=equals / not equalscustomer.state = 'ENABLED'
> >= < <=numeric or date comparisonamount_spent >= 500
BETWEEN x AND yinclusive rangeamount_spent BETWEEN 100 AND 999.99
CONTAINS / NOT CONTAINSlist membership, or substring on textcustomer_tags CONTAINS 'wholesale'
CONTAINS ALL (...) / CONTAINS ANY (...)every / at least one of the listed valuescustomer_tags CONTAINS ALL ('vip', 'newsletter')
STARTS WITH / ENDS WITHtext prefix / suffixcustomer.email ENDS WITH '@gmail.com'
IN (...) / NOT IN (...)one of a list of valuescustomer_countries IN ('IN', 'AE')
IS NULL / IS NOT NULLvalue absent / presentcustomer.birthday IS NOT NULL
IN LAST n UNIT / NOT IN LAST n UNITwithin the last n days/weeks/months/yearslast_order_date IN LAST 90 DAYS
IN NEXT n UNITwithin the coming n days/weeks/months/yearsloyalty MATCHES (next_expiration_date IN NEXT 30 DAYS)
IN MONTHrecurring date falls in a monthcustomer.birthday IN MONTH May
ON month dayrecurring date falls on a daycustomer.birthday ON May 24
MATCHES (...) / NOT_MATCHES (...)function attributes — see belowproducts_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 unitsamount_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.id and company.name list at most 250 companies.
  • loyalty.tier lists 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

FormExampleNotes
Absolute date2026-05-25yyyy-mm-dd
Absolute date-time2026-05-25T18:30:00interpreted in the store timezone
Offset-30d, -4w, -6m, -1y, +7dunits: d w m y, relative to now
Namedtoday, yesterday, tomorrowresolved 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-30 and 2026-13-01 are rejected, not rounded. A trailing timezone offset is range-checked too, so +05:30 is fine and +25:00 isn’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 DAYS is legal; IN LAST 99999 DAYS is not.
  • A BETWEEN range 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 cm
metafields.specs.capacity BETWEEN 250 AND 750 ml

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

  • Distancecustomer_within_distance MATCHES (coordinates = (13.0827, 80.2707), distance_km = 25) (use distance_mi for miles).
  • Anniversary sugaranniversary('customer.birthday') BETWEEN today AND +30d is 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 DAYS

Inside 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 = orExpr
orExpr = andExpr { OR andExpr }
andExpr = primary { AND primary }
primary = "(" orExpr ")" | condition
condition = 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 customers
amount_spent >= 5000 AND last_order_date NOT IN LAST 90 DAYS
-- Subscribed locals near the flagship store
customer.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 review
products_purchased MATCHES (category = 'ha-15') AND product_review IS NULL
-- Loyalty members with points expiring this month
loyalty MATCHES (status = 'enrolled', next_expiration_date IN NEXT 30 DAYS)
-- Waiting for a restock of a specific product
stock_notifications MATCHES (product_id = '018f2e6a-…', status = 'active')
-- Birthday campaign audience
customer.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.