Liquid error handling and validation
On this page
StoreConnect Liquid fails quietly more often than it fails loudly. A misspelled Drop attribute renders an empty string, a {% update %} in the wrong template writes nothing, and an unquoted order by blanks an entire page with no message in the browser. Use this article to tell those failure modes apart, and to add the validation that keeps a bad request parameter, a failed form submission, or an unreachable API from reaching a customer as a broken page.
This article covers the failure modes and the guards. For the phases and action tags themselves, see the Liquid controllers guide; for query syntax, see the query tag.
Before you start
- You can edit and preview theme templates on the target store.
- You can open the debug Console for the store, because two of the three failure classes below are only visible there. See the debug tag.
- For write operations, the field you intend to change already has a Custom Data Mapping marked editable.
Silent failures in Liquid
Templates render with strict parsing, strict variables, and strict filters. That combination produces three distinct failure classes, and identifying which one you have rules out most causes immediately.
| Failure class | What the visitor sees | Where you find it |
|---|---|---|
| Undefined variable, undefined Drop attribute, undefined filter | Nothing. The expression renders as an empty string and the rest of the page renders normally. | Debug Console only. |
Any other render-time error, such as a bad {% query %} field, a blank {% cache %} key, or {% context %} outside a component |
Liquid error (line N): <message> printed into the page, where the tag was. |
The page itself, plus the Console. |
| Parse or syntax error | The whole template’s output is empty, not just the offending tag. | Console, as Liquid syntax error (<template> line N): …. |
Three consequences worth internalizing:
- A blank value is never proof the data is missing. It is equally likely you named an attribute that does not exist. Confirm which before you go looking for a data problem.
- A page that renders as nothing at all is a syntax problem, not a data problem. Suspect an unclosed tag or an unquoted
order byfirst. - A
Liquid error (line N):string is customer-visible. Treat any occurrence as a release blocker.
Controller tags are silent no-ops outside a controller
{% params %}, {% variables %}, {% respond %}, {% redirect %}, {% update %}, and {% action %} return immediately when they are not running inside a controller phase. There is no output and no error. If one of these tags “does nothing”, check where it is executing before you check anything else: the controller file must be at controllers/<controller>/<action>.liquid, and the controller and action pair must be one the platform registers.
A snippet rendered from inside a phase inherits that context, so a {% render %}ed snippet can carry an {% update %} or a {% respond %} and it will work. The same snippet rendered from a page template is a silent no-op. When a write appears to do nothing, trace the call chain up to the phase, not just the file the tag sits in.
{% respond %} and {% redirect %} are also no-ops inside {% final %}, because the response has already been built by the time that phase runs.
The four preconditions on {% update %}
Every one of these fails silently.
- The tag runs inside a controller phase, either directly or in a snippet rendered from one.
- The first argument is a Drop, not a record returned by
{% query %}. Pipe the record through| cast:first. - The field has a Custom Data Mapping marked editable. A missing or read-only mapping logs a Console warning and returns.
- The field is a Custom Data Mapping field. Standard and managed-package fields cannot be written from Liquid.
Problem: a view counter appears to be wired up correctly, but the count never changes and nothing is logged in the page.
```liquid
{%- comment -%} Broken: query returns records, not Drops, so nothing is written {%- endcomment -%} {% query ‘Product2’ as records, s_c__slug__c: slug %} {% for record in records %} {% update record, field: “view_count__c”, value: 1 %} {% endfor %} ```
```liquid
{%- comment -%} Correct, in controllers/products/show.liquid {%- endcomment -%} {% before %} {% query ‘Product2’ as records, s_c__store__c: current_store.sfid, s_c__slug__c: slug %}
{% if records.size == 1 %} {% for record in records %} {%- assign product = record | cast: ‘Product’ -%} {%- assign next_count = product.data.view_count__c | default: 0 | plus: 1 -%} {% update product, field: “view_count__c”, value: next_count %} {% endfor %} {% endif %} {% endbefore %} ```
Why it matters: the broken version has no symptom at all. The corrected version fixes the Drop problem and adds the two safety conditions covered in Data integrity before writes: the query is scoped to the current Store, and the write runs only when exactly one record matched.
Parameter validation
Anything from current_request.params is a string supplied by the client. Validate presence, coerce the type, bound the range, and reject values outside an allowed set before you pass a parameter to an action.
Coerce a numeric parameter by piping it through arithmetic. | plus: 0 turns a numeric string into an integer and turns a non-numeric string, including nil, into 0. Use | times: 1.0 when you need a decimal.
Problem: an add-to-cart link is hand-edited to ?quantity=abc or ?quantity=-5. Without coercion the value reaches the cart action as a string, and a negative value corrupts the line.
```liquid
{%- comment -%} controllers/carts/add.liquid {%- endcomment -%} {% before %} {%- liquid assign product_id = current_request.params.product_id assign quantity = current_request.params.quantity | plus: 0
if quantity < 1
assign quantity = 1
endif
if quantity > 99
assign quantity = 99
endif -%}
{% if product_id == blank %} {% redirect to: current_store.root_path, alert: “We could not tell which product to add.” %} {% endif %}
{% action “cart.add”, product_identifier: product_id, quantity: quantity %} {% endbefore %} ```
Why it matters: the coercion and clamp are the pattern a production theme uses, because they collapse every bad input, empty, non-numeric, zero, negative, and absurdly large, into one safe value without a chain of conditionals. The presence check is separate because there is no safe default for a missing product.
Whitelisting is the equivalent guard for a parameter with a fixed set of legal values. Compare against a list rather than trusting the submitted string.
Problem: a sort parameter is passed straight into an order by clause. An unexpected value produces Invalid order clause in the page; a malformed one is a parse error that blanks the template.
```liquid
{%- liquid assign allowed_sorts = “name asc,name desc,createddate desc” | split: “,” assign requested = current_request.params.sort | default: “name asc” assign sort_clause = “name asc”
for candidate in allowed_sorts if candidate == requested assign sort_clause = candidate break endif endfor -%}
{% query ‘s_c__article__c’ as articles, s_c__store__c: current_store.sfid order by sort_clause %} ```
Why it matters: exact-match whitelisting is the only safe way to let a request parameter influence a query clause. A contains test is not equivalent, because contains is a substring test: the value points-low-high also matches low-high.
Range and date bounds need explicit checks, because {% query %} comparison operators accept unsigned numbers only. A negative bound such as '>-100' falls through to an equality test against a string, which converts to 0 and returns the wrong rows with no error. An invalid date such as '>2026-21-02' returns zero rows and raises nothing. Validate any bound that came from a request before passing it in, or fetch a bounded set and filter in Liquid.
Form validation and error display
A registered {% form %} brings server-side validation with it. Your job in the theme is to render the errors it hands back. Two channels exist, and the difference decides what you must render.
form.errors: the form replays errors into its fields on the same page. Most forms behave this way.- Flash: the form redirects with a message in
current_flash, andform.errorsstays empty forever. The promo code, cart, geolocation, and privacy forms use this channel. If you render onlyform.errorson one of those forms, the customer sees nothing at all.
form.errors is a collection of FormError. Each entry has field, messages (the message alone), and full_messages (the message prefixed with the field name). Form-level errors that belong to no single input carry the field name base.
Field access is bracket lookup: form.fields["username"], never form.username. Dot access renders blank, with no error text in the page and nothing in the markup to explain it, so the mistake is invisible until someone notices the value is missing. The error is reported to the Console. Bracket lookup on a name that is not a field, form.fields["not_a_field"], returns nil, which makes {% if field %} the right guard for a conditionally present field. A field’s own errors are at field.errors, which is nil when the field is clean, so read it through | try:.
Problem: a customer submits the account form with a bad email address, the page reloads, and no message appears anywhere. The theme rendered the inputs but never rendered the error state.
```liquid
{% form “account” %} {% render “form_errors”, errors: form.errors %}
{%- assign email = form.fields[“email”] -%}
{% endform %} ```
Why it matters: three things in that block are easy to omit and each one loses information. {% render "form_errors" %} prints the summary, but by default only the messages whose field is base, so a field-level message is invisible unless you render it beside its input or pass include_fields: true. field.errors is nil when clean, so it must be tested with != blank rather than piped straight into output. And field.value is a customer-supplied string that is not escaped for you, so it needs | escape in an attribute and | j inside a script or JSON string.
The {% if x %} test is not a substitute for != blank here. Several Drops return "" rather than nil, and "" is truthy in Liquid, so {% if field.errors %} is true for a clean field and the error markup renders on every load.
API response error handling
{% api %} gives you a response object with status (an integer), body (parsed JSON), and headers (a Map). Three behaviors decide how you must guard it.
- Treat only a 2xx status as success. Test the range explicitly; a 4xx or 5xx response still has a
body, and rendering it produces a page full of somebody else’s error text. - If the body is not parseable JSON, the raw string is wrapped as
{ "body": "…" }. Readingresponse.body.resulton that returns blank, so a parse failure looks identical to a missing field unless you check for the wrapper. - In async mode there is no
responseobject at all.{{ response.status }}inside anasync: trueblock fails, and the{% final %}controller phase forces async regardless of what you pass.
Problem: a stock lookup renders a stock figure from a third-party service. When the service returns a 503 or an HTML error page, the storefront prints raw upstream text into the product page.
```liquid
{% api url: “https://inventory.example.com/v1/stock”, method: “get” %} {%- liquid assign ok = false if response.status >= 200 and response.status < 300 assign ok = true endif -%}
{% if ok and response.body.available_units != blank %} <p class="stock">{{ response.body.available_units }} in stock</p> {% elsif ok %} {%- comment -%} 2xx but unparseable or unexpected shape: response.body.body holds the raw string {%- endcomment -%} {% debug stock_parse_failure: response.body.body %} <p class="stock">Stock updated shortly</p> {% else %} {% debug stock_lookup_status: response.status %} <p class="stock">Stock updated shortly</p> {% endif %} {% endapi %} ```
Why it matters: every branch ends in something a customer can read, and the diagnostic detail goes to the Console through {% debug %} rather than into the page. Never print response.body wholesale as a fallback, and never render response diagnostics to a customer.
A synchronous {% api %} blocks the page render on a third party, so put a non-essential call in a deferred component or in {% final %}. If you move it to {% final %}, delete every reference to response in the block: the call is now fire-and-forget, and there is nothing to inspect.
Data integrity before writes
A controller write acts on whatever record the query returned. Three checks make that safe.
- Require exactly one match. Test
records.size == 1before writing. A query that matched two records will write to both; a query that matched none will loop zero times and look like a silent failure. - Scope by Store, and by the authenticated customer for customer data.
{% query %}is not store-scoped for you. Always add the current Store condition, and for anything a customer owns, add the customer condition too. Never update a record located by an unscoped, request-supplied identifier. - Confirm the mapping is editable. A read-only or missing Custom Data Mapping produces a Console warning and no write. See Verify custom data is available in Liquid.
Problem: a “save this note to my order” endpoint takes an order id from the request. Without a customer condition, any signed-in visitor can write a note onto any order in the org by changing the id.
```liquid
{%- comment -%} controllers/pages/not_found.liquid, handling a custom endpoint {%- endcomment -%} {% before %} {%- liquid assign parts = current_request.path | split: ‘/’ assign note = current_request.params.note | strip | truncate: 255 -%}
{% if parts[1] == ‘order’ and parts[3] == ‘note’ %} {% if current_customer == blank %} {% respond body: ‘{“ok”:false}’, layout: false, status: 401 %} {% endif %}
{% query 'Order' as records,
s_c__store__c: current_store.sfid,
accountid: current_customer.account.sfid,
sfid: parts[2] %}
{% if records.size == 1 and note != blank %}
{%- assign order = records.first | cast: 'Order' -%}
{% update order, field: "customer_note__c", value: note %}
{% respond body: '{"ok":true}', layout: false, status: 200 %}
{% endif %}
{% respond body: '{"ok":false}', layout: false, status: 404 %} {% endif %} {% endbefore %} ```
Why it matters: the authentication check, the Store condition, the customer condition, and the single-match test each close a different hole, and a request-supplied sfid needs all four. The response is a fixed JSON string in every branch, so a caller learns whether the write succeeded without learning anything about records it cannot see.
A Liquid write reaches Salesforce asynchronously. Verify the Salesforce field value and the customer-visible result after propagation, not immediately.
Error messaging to users
Both {% redirect %} and {% respond %} accept notice: for a confirmation and alert: for a failure. The message lands in current_flash on the next request, and the theme renders it. Use alert: for anything the customer needs to act on, and keep the wording free of record ids, statuses, and upstream error text.
```liquid
{% before %} {% unless current_customer %} {% redirect to: current_store.sign_in_path, alert: “Please sign in to view your orders.” %} {% endunless %} {% endbefore %}
{% after %} {% redirect to: current_store.cart_path, notice: “Item added to your cart.” %} {% endafter %} ```
Two rules govern how the flash region itself is written.
- Guard with
!= blank, not truthiness.current_flash.noticereturns""rather than nil when there is no message, and""is truthy, so{% if current_flash.notice %}renders an empty flash container on every page. - Keep both slots in the DOM and hide the wrapper. The base theme’s contract puts the
sc-hideclass on the slot’s parent, not on the slot. A theme that removes the empty slot entirely leaves client-side code with no target to write into, so a message raised in the browser never appears.
Problem: an empty white bar appears at the top of every page, and messages raised by client-side code never show up at all.
```liquid
{%- comment -%} snippets/flash.liquid {%- endcomment -%} {%- liquid assign show_notice = false if current_flash and current_flash.notice != blank assign show_notice = true endif
assign show_alert = false if current_flash and current_flash.alert != blank assign show_alert = true endif -%}
```
Why it matters: this is the shape a production theme uses. Both data-notice and data-alert are always present so client-side code always has a target, sc-hide sits on the wrapper as the base contract requires, and the != blank guards keep the empty container from taking up space.
Error responses for endpoints that return JSON
A controller that answers a fetch call needs two things on every {% respond %}, including the failure branches.
- Set
status:explicitly. Do not rely on a default. A caller that branches onresponse.okneeds a real status, and a failure returned as a success status is worse than no endpoint at all. - Pass
layout: false. Without it the entire theme layout renders into the response body, and the caller discards it as unparseable. A JSON endpoint that “returns HTML” is almost always a missinglayout: false.
The safest way to shape the statuses is to deny by default: assign the failure status and the failure body first, then upgrade both only on the success path. Every branch that falls through is then already correct.
```liquid
{%- comment -%} controllers/pages/not_found.liquid, handling a token exchange {%- endcomment -%} {% before %} {%- liquid assign parts = current_request.path | split: ‘/’ -%}
{% if parts[1] == ‘auth’ and parts[2] == ‘session’ %} {%- liquid new Map result assign status = 401 assign body = result | serialize
if parts[3] == store_variables['auth.token']
capture raw
render 'integrations/authenticate'
endcapture
assign result = raw | strip | deserialize
if result.access_token != blank
assign body = result | serialize
assign status = 200
endif
endif
-%}
{% respond body: body, status: status, layout: false %} {% endif %} {% endbefore %} ```
Why it matters: this is the shape a production theme uses. The status starts at 401 and the body starts as an empty serialized Map, so a missing token, a wrong token, and an integration that returned nothing usable all produce the same safe rejection without a branch of their own. Only a response that actually contains a token reaches status = 200. Note the | strip before | deserialize: a captured render carries surrounding whitespace, and an unstripped string deserializes to nil, which would silently fail the access_token test and reject a valid exchange.
Defensive nil access
Under strict variables a missing value renders as an empty string, so an unguarded template produces empty attributes and stray punctuation rather than an error you can chase. Four habits prevent most of it.
Supply a default at the point of use. | default: covers a nil, an empty string, and a false, which makes it the right filter for an optional Drop attribute or a snippet parameter.
Check presence before reaching through a relationship. {{ order.shipping_address.city }} on an order with no shipping address renders blank, and so does every sibling line, leaving a formatted block of nothing.
Handle the empty collection explicitly. A {% for %} over an empty collection renders zero iterations, which silently removes surrounding markup such as a heading or a count.
Remember that page-specific globals are nil off their page. current_product exists on a product page, current_article on an article page, current_search on a search page. cart_item, product, form, and paginate are block-local names supplied by a tag or a {% render %} parameter, not globals, and they render blank outside their block. There is no cart global; the cart is current_cart.
Problem: a shipping panel renders an empty bordered box with a comma and a stray heading whenever an order has no shipping address yet, and a list of gift options disappears without explanation when the collection is empty.
```liquid
{%- comment -%} Broken: renders the wrapper, the heading, and “, “ with no content {%- endcomment -%}
Shipping to
{{ order.shipping_address.city }}, {{ order.shipping_address.country }}
{%- comment -%} Correct: one presence check gates the whole block {%- endcomment -%} {%- assign address = order.shipping_address -%} {% if address != blank %}
Shipping to
{{ address.city | default: address.state }}, {{ address.country }}
{% else %}
Shipping address not yet provided.
{% endif %}
{%- comment -%} Correct: the empty collection gets its own branch {%- endcomment -%} {% if product.gift_options.size > 0 %}
Gift options
-
{% for option in product.gift_options %}
- {{ option.name }} {% endfor %}
{% endif %} ```
Why it matters: gating on the parent rather than on each attribute is what removes the wrapper, the heading, and the punctuation together. Testing .size > 0 before a loop keeps a heading from surviving its own list.
A custom field holding serialized JSON needs the same treatment, one step further. The field is absent on the first write, so the read has to survive a nil and produce an empty structure rather than propagating the nil into every later filter.
Problem: a wishlist stored as JSON on the customer record works for customers who already have one, and fails silently for everyone else. The first | push: runs against nil and writes nothing.
```liquid
{%- comment -%} Rendered from a controller phase, so the update applies {%- endcomment -%} {%- liquid assign wishlist = current_customer.data | try: ‘wishlist_json__c’ | unescape | deserialize
if wishlist == blank or wishlist == nil new List wishlist endif
unless wishlist contains product_id assign wishlist = wishlist | push: product_id assign wishlist_string = wishlist | serialize update current_customer, field: ‘wishlist_json__c’, value: wishlist_string endunless -%} ```
Why it matters: four guards, each covering a different state. | try: keeps an unmapped or newly added field from raising, | unescape reverses the automatic HTML escaping applied to a value read through a Drop, the blank or nil test seeds an empty List on the first write, and unless ... contains makes the operation idempotent so a double-submitted request does not store the same id twice.
Use | try: only for an attribute that is genuinely optional across platform versions. It returns "" for anything missing, which means it also hides a real typo, and its result must be tested with != blank rather than for truthiness.
Declare defaults at the top of every snippet with {% default %}, so a parameter the caller forgot is visible rather than blank:
```liquid
{% default product: nil, show_price: true, heading: “Featured” %} ```
Remember that {% render %} has an isolated scope. Nothing from the caller is visible inside the snippet unless you pass it, so {% assign product = current_product %} followed by {% render "products/card" %} leaves product nil inside the snippet.
Verifying your error handling
Work through this list against the template you changed, in the debug Console rather than the page.
- Load the page and confirm no
Liquid error (line N):text appears anywhere in the rendered source. - Confirm the template renders at all. Output that is entirely empty is a parse error, reported in the Console as
Liquid syntax error. - For each value that renders blank, print
.sizeon the collection or confirm the attribute exists on the Drop before you investigate the data. - Submit every form with an invalid value and confirm both the summary and the field-level message appear. For a flash-channel form, confirm
current_flashis rendered. - Send each request parameter a bad value: empty, non-numeric, negative, and out of range. Confirm the page still renders and the outcome is safe.
- For each
{% api %}block, confirm a non-2xx status and an unparseable body both produce customer-safe output. - For each
{% update %}, confirm the write happened in Salesforce after propagation, not just that the page did not error. - Load the page as an anonymous visitor, as a signed-in customer, with an empty cart, and with no search results.
- Remove every
{% debug %}and{% timer %}tag before publishing.
When this passes, the page renders cleanly in all four visitor states, every invalid input produces a readable message instead of blank markup or a Liquid error string, and every controller write is confirmed in Salesforce.
Was this article helpful?
Thanks for your feedback! It helps us improve our docs.