Liquid query
On this page
The query tag fetches records from a Salesforce object directly inside a Liquid template. It is designed for cases where you need a small, targeted set of records that aren’t already available through the standard Liquid global drops (current_store, current_account, current_cart, etc.).
| Tag name | Available from |
|---|---|
query |
StoreConnect Liquid v19 |
:::note
Use query only when no global, drop, or page context already exposes the data you need. Each query tag is a database call — they are not free, and they are not store-scoped. See When not to use a query below.
:::
Syntax
liquid
{%- query <object_name> as <variable_name>
[, <field>: <value>, ...]
[order by '<field> [asc|desc], <field2> [asc|desc]'] -%}
<object_name>— the Salesforce table/API name (case-insensitive). Can be a literal string ('Product2'), a quoted string, an unquoted identifier, or a Liquid variable.<variable_name>— the local Liquid variable that will hold the result set.- Each
<field>: <value>after the first comma is added as aWHEREcondition (see Filtering). All conditions are combined with AND — there is noORoperator. - The
order byclause must be quoted (single or double quotes). Multiple fields can be supplied, separated by commas.
The simplest possible call returns every record of an object:
```liquid {%- query ‘Product2’ as records -%}
{%- for record in records -%} {{ record.name }} {%- endfor -%} ```
Supported objects
Any Salesforce object that StoreConnect syncs to the experience site database can be queried. This includes standard Salesforce objects and StoreConnect’s own managed-package objects.
Common examples:
| Object name (use in tag) | Salesforce object |
|---|---|
Product2 |
Product |
Account |
Account |
Contact |
Contact |
Pricebook2 |
Pricebook |
PricebookEntry |
Pricebook entry |
Order |
Order |
OrderItem |
Order line item |
Campaign |
Campaign |
Lead |
Lead |
s_c__Promotion__c |
StoreConnect promotion |
s_c__Tag__c |
StoreConnect tag |
s_c__Availability__c |
Availability |
s_c__Collection_Point__c |
Collection point |
s_c__Store__c |
Store |
s_c__Voucher__c |
Voucher |
Custom objects (your own Salesforce custom objects, surfaced via Custom Data Mappings) are also queryable using their full API name, e.g. 'mch_account_data__c' or 'address__c'.
:::tip
The object name resolves by table name, which is case-insensitive. 'product2', 'Product2', and 'PRODUCT2' all work. For StoreConnect-managed objects the table name is the lowercase API name with the s_c__ prefix and __c suffix (e.g. s_c__availability__c).
:::
Filtering
Field names in conditions use the column name on the experience-site database, not the Salesforce label:
- Standard fields:
name,firstname,lastname,createddate,sfid, etc. - StoreConnect-managed fields: prefixed with
s_c__and suffixed with__c, e.g.s_c__slug__c,s_c__amount__c,s_c__usage_limit__c. - Custom data fields (jsonb): use the
data.<field_api_name>shortcut — see Filtering by custom data.
If a field doesn’t exist on the object, the tag raises Invalid liquid query field: <field>.
Strings (equality, LIKE, IN, case-insensitivity)
Equality on string-like fields (string, text, email, textarea, url, phone, reference) is case-insensitive — both sides are lowercased before comparison.
liquid
{%- query 'Product2' as records, name: 'Hiking Boots' -%}
% wildcards trigger a SQL LIKE match (also case-insensitive):
| Pattern | Behaviour | Example |
|---|---|---|
'%foo%' |
Contains foo |
name: '%shirt%' |
'foo%' |
Starts with foo |
name: 'My%' |
'%foo' |
Ends with foo |
name: '%cow' |
Pass an array (or a List) to do an IN match:
liquid
{%- query 'Product2' as records, s_c__slug__c: ['hiking-boots', 'rain-jacket'] -%}
Numbers (integer, float, decimal)
Pass a number for equality, or a comparison string for ranges:
liquid
{%- query 's_c__Promotion__c' as records, s_c__usage_limit__c: '>10' -%}
{%- query 's_c__Promotion__c' as records, s_c__usage_limit__c: '>=10' -%}
{%- query 's_c__Promotion__c' as records, s_c__usage_limit__c: '<10' -%}
{%- query 's_c__Promotion__c' as records, s_c__usage_limit__c: '<=10' -%}
{%- query 'Product2' as records, s_c__cost_price__c: 123.45 -%}
Dates, times and datetimes
The same >, >=, <, <= syntax works for date, time, and datetime columns:
liquid
{%- query 's_c__Availability__c' as records, s_c__start_date__c: '>=2026-01-01' -%}
{%- query 's_c__Availability__c' as records, s_c__start_time__c: '<14:31' -%}
{%- query 'Order' as records, s_c__submitted_date__c: '>=2026-01-01T00:00:00Z' -%}
:::warning
Invalid date or time strings (e.g. '>2023-21-02') silently return zero results — they do not raise. Validate user-supplied dates before passing them in.
:::
Booleans
liquid
{%- query 'Product2' as records, s_c__booking_require_attendee_details__c: true -%}
Multiple conditions (AND)
Every additional field: value pair adds another AND condition:
liquid
{%- query 'Contact' as records, firstname: 'Vikram', lastname: 'Seth' -%}
There is no OR operator. To union results, run two queries and merge them with concat or | uniq.
Filtering by custom data
Salesforce custom fields that are surfaced through Custom Data Mappings are stored in a jsonb custom_data column. Filter them with the data. prefix:
liquid
{%- query 'Product2' as records,
data.color__c: 'blue',
data.material__c: 'suede' -%}
This works for any data type configured in the mapping — string, integer, date, reference (lookup), boolean, etc. Comparison operators (>, >=, <, <=, LIKE patterns, IN arrays) all behave the same way as for standard columns.
For pure custom objects (an entire Salesforce custom object surfaced through Custom Data Mappings), use the field API name directly without data.:
liquid
{%- query 'address__c' as records, account__c: '0015000001AbcXYZAAA' -%}
:::note
A Custom Data Mapping must exist for the object and every field you reference. If the mapping is missing, the tag raises Invalid liquid query field.
:::
Ordering
The order by clause is always quoted and goes after the conditions:
liquid
{%- query 'Product2' as records order by 'name' -%}
{%- query 'Product2' as records order by "createddate desc" -%}
{%- query 'Product2' as records, name: 'Hiking Boots'
order by 'createddate asc' -%}
Multiple fields are comma-separated. Direction (asc / desc) is optional per field; default is asc. Both directions are case-insensitive.
liquid
{%- query 's_c__Availability__c' as records
order by 's_c__end_date__c, name desc' -%}
Unknown fields raise Invalid order clause: unknown field "<field>". An unquoted order by raises a syntax error.
Distance calculations
When you need to sort or filter by distance from a point (e.g. nearest collection points), build a distance struct with the struct tag and pass it as a query condition. The condition key becomes a column alias that you can then reference in order by and read off each record.
```liquid {%- struct dist = “distance”, lat_field: “s_c__geolocation__latitude__s”, lng_field: “s_c__geolocation__longitude__s”, lat_value: -33.8688, lng_value: 151.2093, units: “km” -%}
{%- query ‘s_c__collection_point__c’ as records, distance_km: dist order by ‘distance_km asc’ -%}
{%- for record in records -%} {{ record.name }} — {{ record.distance_km }} km {%- endfor -%} ```
Struct fields:
| Field | Required | Description |
|---|---|---|
lat_field |
Yes | Column on the queried object holding the record latitude. |
lng_field |
Yes | Column on the queried object holding the record longitude. |
lat_value |
Yes | Origin latitude (the point you’re measuring from). |
lng_value |
Yes | Origin longitude. |
units |
Yes | "km" or "mi". |
gt / gte |
No | Lower bound (only return records farther than this distance). |
lt / lte |
No | Upper bound (only return records within this distance). |
The condition key (distance_km in the example) is arbitrary — it becomes the column alias on each returned record. Pick a name that reflects the units.
:::tip
The bounds (gt, gte, lt, lte) filter at query time, so they are far cheaper than fetching everything and filtering in Liquid. Combine lte with order by '<alias> asc' to render “the 5 closest pickup points within 50 km”.
:::
Working with results
The variable assigned by the tag is a RecordSet — iterable with a for loop, with a .size property:
```liquid {%- query ‘Product2’ as records, s_c__slug__c: ‘hiking-boots’ -%} {{ records.size }}
{%- for record in records -%} {{ record.name }} {{ record.sfid }} {{ record.s_c__slug__c }} {%- endfor -%} ```
Useful filters:
record | record_fields— list of attribute names available on the record.record | record_name— the record’s table name.record | cast: 'Product'— cast a generic record into its full StoreConnect drop (giving you access to drop methods such as.images,.taxons, etc.).<drop> | recordize— the inverse: convert a fully-loaded drop back into a generic record.
liquid
{%- query 'Product2' as records, s_c__slug__c: 'hiking-boots' -%}
{%- for record in records -%}
{%- assign product = record | cast: 'Product' -%}
{{ product.name }} — {{ product.images.first.url }}
{%- endfor -%}
Field values are returned in their natural type. datetime values are serialised as ISO 8601 strings, time values strip the date portion (14:30:00), and custom_data is exposed as a hash (record.custom_data.color__c).
Limiting and pagination
The query tag has no built-in limit, offset, or pagination. To cap the result set, use the standard Liquid for modifiers on the loop:
```liquid {%- query ‘Product2’ as records order by ‘createddate desc’ -%}
{%- for record in records limit: 10 -%} {{ record.name }} {%- endfor -%} ```
For larger paged lists, pair the query with the paginate tag (see Paginate tag reference) — the records collection is a normal enumerable.
:::warning
for ... limit: 10 still fetches every matching record from the database before slicing. If you need to render only a handful from a large object, narrow the result with conditions, an order by, and a distance/range filter — don’t rely on limit alone for performance.
:::
Use cases
Good fits for query:
- Looking up a small set of related records that aren’t already in scope (e.g. fetching the active promotion for a banner, listing the 5 closest collection points, finding a product variant by slug for a custom landing page).
- Reading metadata configuration stored on a custom Salesforce object (feature flags, route mappings, layout settings).
- Building admin/reporting pages where standard customer-context data isn’t sufficient.
```liquid {# All collection points within 25 km of the cart’s shipping address #} {%- struct origin = “distance”, lat_field: “s_c__geolocation__latitude__s”, lng_field: “s_c__geolocation__longitude__s”, lat_value: cart.shipping_address.latitude, lng_value: cart.shipping_address.longitude, units: “km”, lte: 25 -%}
{%- query ‘s_c__collection_point__c’ as nearby, distance_km: origin order by ‘distance_km asc’ -%}
-
{%- for point in nearby limit: 5 -%}
- {{ point.name }} ({{ point.distance_km | round: 1 }} km) {%- endfor -%}
```
liquid
{# Active promotions that have plenty of usage left #}
{%- query 's_c__Promotion__c' as promos,
s_c__usage_limit__c: '>0',
s_c__active__c: true
order by 'createddate desc' -%}
liquid
{# Custom object lookup by reference field #}
{%- query 'address__c' as addresses, account__c: current_account.sfid -%}
When not to use a query
Reach for a global drop or built-in collection first. The query tag bypasses store scoping, caching, and pre-built relationships — most everyday data already lives somewhere safer and faster.
| Don’t query | Use instead |
|---|---|
| Customer & cart data | current_account, current_cart, current_contact |
| Current store / outlet | current_store, current_outlet |
| Current product page | product, product.images, product.variants |
| Catalogue listings | Catalogue / category drops; the search index for full-text |
| Order history for the logged-in customer | current_account.orders |
:::warning
A query tag returns records from every store in the org. There is no automatic scoping by store, account, locale, or active state. If you only want records for the current store, you must filter explicitly (e.g. s_c__store__c: current_store.sfid).
:::
Avoid running a query inside a hot loop — each tag is a separate DB round-trip. Pull the data once into a variable and iterate that:
```liquid {# Bad: runs the query once per cart item #} {%- for item in cart.items -%} {%- query ‘Product2’ as match, sfid: item.product.sfid -%} {%- endfor -%}
{# Good: one query, then look up by sfid in Liquid #} {%- assign sfids = cart.items | map: ‘product’ | map: ‘sfid’ -%} {%- query ‘Product2’ as products, sfid: sfids -%} ```
Limitations and considerations
- No store scoping. Queries return records from every store in the org. Always filter explicitly when you only want the current store.
- AND only, no OR. Multiple conditions are joined with
AND. For anOR, run separate queries and merge with| concatand| uniq. - Equality on strings is case-insensitive. Useful for matching but means you can’t do an exact case-sensitive lookup.
- No
SELECTprojection. Every column on the table is loaded — there’s no way to fetch only specific fields. - No joins. You query one object at a time. Use the result’s
sfids to drive a follow-up query if you need related records. - No aggregation (
COUNT,SUM,GROUP BY). Userecords.sizeafter the fact, and Liquid filters (map,sum) for in-memory aggregation on small result sets. - No native
limit/offset. Usefor ... limit:/paginateon the loop. The full result set is still loaded into memory first. order bymust be quoted and only references columns on the queried object (or computed aliases like distance).- Internal columns are not queryable or readable:
id,isdeleted,last_sync_timestamp,_hc_lastop,_hc_err,last_touched_atare blacklisted. - Custom Data Mappings are required for any custom object or
data.<field>filter. Missing mappings raiseInvalid liquid query field. - Invalid date/time strings return zero rows silently — they do not raise. Validate user input.
- Distance calculations only.
distanceis the only computed alias supported today; arbitrary SQL expressions are not exposed. - Performance. Each
querytag is a database call. Cache the result in a variable, narrow with conditions, and avoid running queries inside loops.
Errors and troubleshooting
| Error | Cause |
|---|---|
Liquid syntax error: valid syntax: {% query <object_name> as <variable_name> ... %} |
Missing as, missing variable name, or malformed tag. |
Liquid syntax error: order by clause must be quoted |
The order by argument was not wrapped in single or double quotes. |
Invalid liquid query field: <field> |
The field doesn’t exist on the object, or (for custom objects / data. filters) there is no Custom Data Mapping for it. |
Invalid order clause: unknown field "<field>" |
order by references a column that isn’t on the object and isn’t a computed alias from the same query. |
Invalid order clause segment: expected '<field name> [asc\|desc]' |
A segment in the order by clause is malformed (e.g. name ascending, trailing comma). |
| Query returns 0 records unexpectedly | Most often: invalid date/time string, the field is on a different object than expected, or the __c suffix / s_c__ prefix is missing. Add {{ records.size }} and inspect the conditions before debugging further. |