{"title":"Liquid error handling and validation","slug":"liquid-error-handling-and-validation","url":"https://support.storeconnect.com/articles/liquid-error-handling-and-validation","url_markdown":"https://support.storeconnect.com/articles/liquid-error-handling-and-validation.md","subtitle":null,"summary":"Diagnose why a StoreConnect Liquid template renders blank, prints a Liquid error, or silently writes nothing, and add the parameter checks, form error display, API response guards, and nil-safe access that stop those failures reaching customers.","type":"Developer_Documentation","video_url":"","keywords":"liquid error handling, liquid validation, silent failure, strict variables, parse error, form errors, form validation, parameter validation, type coercion, api response status, update tag preconditions, flash messages, notice, alert, defensive liquid, nil handling, liquid debugging","last_modified":"2026-09-15T02:32:04+0000","body_markdown":"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.\n\nThis article covers the failure modes and the guards. For the phases and action tags themselves, see the [Liquid controllers guide](liquid-controllers-guide); for query syntax, see [the query tag](liquid-query).\n\n## Before you start\n\n- You can edit and preview theme templates on the target store.\n- 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](debug-tag-reference).\n- For write operations, the field you intend to change already has a Custom Data Mapping marked editable.\n\n## Silent failures in Liquid\n\nTemplates 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.\n\n| Failure class | What the visitor sees | Where you find it |\n|---|---|---|\n| 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. |\n| Any other render-time error, such as a bad `{% query %}` field, a blank `{% cache %}` key, or `{% context %}` outside a component | `Liquid error (line N): \u003cmessage\u003e` printed into the page, where the tag was. | The page itself, plus the Console. |\n| Parse or syntax error | The whole template's output is empty, not just the offending tag. | Console, as `Liquid syntax error (\u003ctemplate\u003e line N): …`. |\n\nThree consequences worth internalizing:\n\n- 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.\n- A page that renders as nothing at all is a syntax problem, not a data problem. Suspect an unclosed tag or an unquoted `order by` first.\n- A `Liquid error (line N):` string is customer-visible. Treat any occurrence as a release blocker.\n\n### Controller tags are silent no-ops outside a controller\n\n`{% 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/\u003ccontroller\u003e/\u003caction\u003e.liquid`, and the controller and action pair must be one the platform registers.\n\nA 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.\n\n`{% respond %}` and `{% redirect %}` are also no-ops inside `{% final %}`, because the response has already been built by the time that phase runs.\n\n### The four preconditions on `{% update %}`\n\nEvery one of these fails silently.\n\n1. The tag runs inside a controller phase, either directly or in a snippet rendered from one.\n2. The first argument is a Drop, not a record returned by `{% query %}`. Pipe the record through `| cast:` first.\n3. The field has a Custom Data Mapping marked editable. A missing or read-only mapping logs a Console warning and returns.\n4. The field is a Custom Data Mapping field. Standard and managed-package fields cannot be written from Liquid.\n\n**Problem:** a view counter appears to be wired up correctly, but the count never changes and nothing is logged in the page.\n\n\n```liquid\n\n{%- comment -%} Broken: query returns records, not Drops, so nothing is written {%- endcomment -%}\n{% query 'Product2' as records, s_c__slug__c: slug %}\n{% for record in records %}\n  {% update record, field: \"view_count__c\", value: 1 %}\n{% endfor %}\n```\n\n\n\n```liquid\n\n{%- comment -%} Correct, in controllers/products/show.liquid {%- endcomment -%}\n{% before %}\n  {% query 'Product2' as records,\n      s_c__store__c: current_store.sfid,\n      s_c__slug__c: slug %}\n\n  {% if records.size == 1 %}\n    {% for record in records %}\n      {%- assign product = record | cast: 'Product' -%}\n      {%- assign next_count = product.data.view_count__c | default: 0 | plus: 1 -%}\n      {% update product, field: \"view_count__c\", value: next_count %}\n    {% endfor %}\n  {% endif %}\n{% endbefore %}\n```\n\n\n**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](#data-integrity-before-writes): the query is scoped to the current **Store**, and the write runs only when exactly one record matched.\n\n## Parameter validation\n\nAnything 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.\n\nCoerce 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.\n\n**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.\n\n\n```liquid\n\n{%- comment -%} controllers/carts/add.liquid {%- endcomment -%}\n{% before %}\n  {%- liquid\n    assign product_id = current_request.params.product_id\n    assign quantity = current_request.params.quantity | plus: 0\n\n    if quantity \u003c 1\n      assign quantity = 1\n    endif\n    if quantity \u003e 99\n      assign quantity = 99\n    endif\n  -%}\n\n  {% if product_id == blank %}\n    {% redirect to: current_store.root_path, alert: \"We could not tell which product to add.\" %}\n  {% endif %}\n\n  {% action \"cart.add\", product_identifier: product_id, quantity: quantity %}\n{% endbefore %}\n```\n\n\n**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.\n\nWhitelisting is the equivalent guard for a parameter with a fixed set of legal values. Compare against a list rather than trusting the submitted string.\n\n**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.\n\n\n```liquid\n\n{%- liquid\n  assign allowed_sorts = \"name asc,name desc,createddate desc\" | split: \",\"\n  assign requested = current_request.params.sort | default: \"name asc\"\n  assign sort_clause = \"name asc\"\n\n  for candidate in allowed_sorts\n    if candidate == requested\n      assign sort_clause = candidate\n      break\n    endif\n  endfor\n-%}\n\n{% query 's_c__article__c' as articles,\n    s_c__store__c: current_store.sfid\n    order by sort_clause %}\n```\n\n\n**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`.\n\nRange and date bounds need explicit checks, because `{% query %}` comparison operators accept unsigned numbers only. A negative bound such as `'\u003e-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 `'\u003e2026-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.\n\n## Form validation and error display\n\nA 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.\n\n- **`form.errors`**: the form replays errors into its fields on the same page. Most forms behave this way.\n- **Flash**: the form redirects with a message in `current_flash`, and `form.errors` stays empty forever. The promo code, cart, geolocation, and privacy forms use this channel. If you render only `form.errors` on one of those forms, the customer sees nothing at all.\n\n`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`.\n\nField 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:`.\n\n**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.\n\n\n```liquid\n\n{% form \"account\" %}\n  {% render \"form_errors\", errors: form.errors %}\n\n  {%- assign email = form.fields[\"email\"] -%}\n  \u003cdiv class=\"SC-Field{% if email.errors != blank %} has-error{% endif %}\"\u003e\n    \u003clabel for=\"{{ email.id }}\"\u003eEmail address\u003c/label\u003e\n    \u003cinput type=\"email\"\n           id=\"{{ email.id }}\"\n           name=\"{{ email.name }}\"\n           value=\"{{ email.value | escape }}\"\n           autocomplete=\"email\"\n           {% if email.errors != blank %}aria-invalid=\"true\" aria-describedby=\"{{ email.id }}-error\"{% endif %}\u003e\n\n    {% if email.errors != blank %}\n      \u003cspan class=\"SC-Field_error\" id=\"{{ email.id }}-error\" role=\"alert\"\u003e{{ email.errors | try: \"messages\" }}\u003c/span\u003e\n    {% endif %}\n  \u003c/div\u003e\n\n  \u003cbutton type=\"submit\"\u003eSave\u003c/button\u003e\n{% endform %}\n```\n\n\n**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.\n\nThe `{% 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.\n\n## API response error handling\n\n`{% 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.\n\n- 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.\n- If the body is not parseable JSON, the raw string is wrapped as `{ \"body\": \"…\" }`. Reading `response.body.result` on that returns blank, so a parse failure looks identical to a missing field unless you check for the wrapper.\n- In async mode there is no `response` object at all. `{{ response.status }}` inside an `async: true` block fails, and the `{% final %}` controller phase forces async regardless of what you pass.\n\n**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.\n\n\n```liquid\n\n{% api url: \"https://inventory.example.com/v1/stock\", method: \"get\" %}\n  {%- liquid\n    assign ok = false\n    if response.status \u003e= 200 and response.status \u003c 300\n      assign ok = true\n    endif\n  -%}\n\n  {% if ok and response.body.available_units != blank %}\n    \u003cp class=\"stock\"\u003e{{ response.body.available_units }} in stock\u003c/p\u003e\n  {% elsif ok %}\n    {%- comment -%} 2xx but unparseable or unexpected shape: response.body.body holds the raw string {%- endcomment -%}\n    {% debug stock_parse_failure: response.body.body %}\n    \u003cp class=\"stock\"\u003eStock updated shortly\u003c/p\u003e\n  {% else %}\n    {% debug stock_lookup_status: response.status %}\n    \u003cp class=\"stock\"\u003eStock updated shortly\u003c/p\u003e\n  {% endif %}\n{% endapi %}\n```\n\n\n**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.\n\nA 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.\n\n## Data integrity before writes\n\nA controller write acts on whatever record the query returned. Three checks make that safe.\n\n1. **Require exactly one match.** Test `records.size == 1` before 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.\n2. **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.\n3. **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](verify-custom-data-in-liquid).\n\n**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.\n\n\n```liquid\n\n{%- comment -%} controllers/pages/not_found.liquid, handling a custom endpoint {%- endcomment -%}\n{% before %}\n  {%- liquid\n    assign parts = current_request.path | split: '/'\n    assign note = current_request.params.note | strip | truncate: 255\n  -%}\n\n  {% if parts[1] == 'order' and parts[3] == 'note' %}\n    {% if current_customer == blank %}\n      {% respond body: '{\"ok\":false}', layout: false, status: 401 %}\n    {% endif %}\n\n    {% query 'Order' as records,\n        s_c__store__c: current_store.sfid,\n        accountid: current_customer.account.sfid,\n        sfid: parts[2] %}\n\n    {% if records.size == 1 and note != blank %}\n      {%- assign order = records.first | cast: 'Order' -%}\n      {% update order, field: \"customer_note__c\", value: note %}\n      {% respond body: '{\"ok\":true}', layout: false, status: 200 %}\n    {% endif %}\n\n    {% respond body: '{\"ok\":false}', layout: false, status: 404 %}\n  {% endif %}\n{% endbefore %}\n```\n\n\n**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.\n\nA Liquid write reaches Salesforce asynchronously. Verify the Salesforce field value and the customer-visible result after propagation, not immediately.\n\n## Error messaging to users\n\nBoth `{% 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.\n\n\n```liquid\n\n{% before %}\n  {% unless current_customer %}\n    {% redirect to: current_store.sign_in_path, alert: \"Please sign in to view your orders.\" %}\n  {% endunless %}\n{% endbefore %}\n\n{% after %}\n  {% redirect to: current_store.cart_path, notice: \"Item added to your cart.\" %}\n{% endafter %}\n```\n\n\nTwo rules govern how the flash region itself is written.\n\n- **Guard with `!= blank`, not truthiness.** `current_flash.notice` returns `\"\"` rather than nil when there is no message, and `\"\"` is truthy, so `{% if current_flash.notice %}` renders an empty flash container on every page.\n- **Keep both slots in the DOM and hide the wrapper.** The base theme's contract puts the `sc-hide` class 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.\n\n**Problem:** an empty white bar appears at the top of every page, and messages raised by client-side code never show up at all.\n\n\n```liquid\n\n{%- comment -%} snippets/flash.liquid {%- endcomment -%}\n{%- liquid\n  assign show_notice = false\n  if current_flash and current_flash.notice != blank\n    assign show_notice = true\n  endif\n\n  assign show_alert = false\n  if current_flash and current_flash.alert != blank\n    assign show_alert = true\n  endif\n-%}\n\u003cdiv class=\"SC-Flash floating\" data-flash-container\u003e\n  \u003cdiv class=\"sc-container{% unless show_notice %} sc-hide{% endunless %}\"\u003e\n    \u003cdiv class=\"SC-Notice\" data-notice\u003e\n      {%- if show_notice %}{{ current_flash.notice }}{% endif -%}\n    \u003c/div\u003e\n  \u003c/div\u003e\n\n  \u003cdiv class=\"sc-container{% unless show_alert %} sc-hide{% endunless %}\"\u003e\n    \u003cdiv class=\"SC-Notice SC-Notice-alert\" data-alert\u003e\n      {%- if show_alert %}{{ current_flash.alert }}{% endif -%}\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\n\n**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.\n\n### Error responses for endpoints that return JSON\n\nA controller that answers a `fetch` call needs two things on every `{% respond %}`, including the failure branches.\n\n- **Set `status:` explicitly.** Do not rely on a default. A caller that branches on `response.ok` needs a real status, and a failure returned as a success status is worse than no endpoint at all.\n- **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 missing `layout: false`.\n\nThe 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.\n\n\n```liquid\n\n{%- comment -%} controllers/pages/not_found.liquid, handling a token exchange {%- endcomment -%}\n{% before %}\n  {%- liquid\n    assign parts = current_request.path | split: '/'\n  -%}\n\n  {% if parts[1] == 'auth' and parts[2] == 'session' %}\n    {%- liquid\n      new Map result\n      assign status = 401\n      assign body = result | serialize\n\n      if parts[3] == store_variables['auth.token']\n        capture raw\n          render 'integrations/authenticate'\n        endcapture\n        assign result = raw | strip | deserialize\n\n        if result.access_token != blank\n          assign body = result | serialize\n          assign status = 200\n        endif\n      endif\n    -%}\n\n    {% respond body: body, status: status, layout: false %}\n  {% endif %}\n{% endbefore %}\n```\n\n\n**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.\n\n## Defensive nil access\n\nUnder 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.\n\n**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.\n\n**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.\n\n**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.\n\n**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`.\n\n**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.\n\n\n```liquid\n\n{%- comment -%} Broken: renders the wrapper, the heading, and \", \" with no content {%- endcomment -%}\n\u003cdiv class=\"shipping\"\u003e\n  \u003ch3\u003eShipping to\u003c/h3\u003e\n  \u003cp\u003e{{ order.shipping_address.city }}, {{ order.shipping_address.country }}\u003c/p\u003e\n\u003c/div\u003e\n\n{%- comment -%} Correct: one presence check gates the whole block {%- endcomment -%}\n{%- assign address = order.shipping_address -%}\n{% if address != blank %}\n  \u003cdiv class=\"shipping\"\u003e\n    \u003ch3\u003eShipping to\u003c/h3\u003e\n    \u003cp\u003e{{ address.city | default: address.state }}, {{ address.country }}\u003c/p\u003e\n  \u003c/div\u003e\n{% else %}\n  \u003cp class=\"shipping-pending\"\u003eShipping address not yet provided.\u003c/p\u003e\n{% endif %}\n\n{%- comment -%} Correct: the empty collection gets its own branch {%- endcomment -%}\n{% if product.gift_options.size \u003e 0 %}\n  \u003ch3\u003eGift options\u003c/h3\u003e\n  \u003cul\u003e\n    {% for option in product.gift_options %}\n      \u003cli\u003e{{ option.name }}\u003c/li\u003e\n    {% endfor %}\n  \u003c/ul\u003e\n{% endif %}\n```\n\n\n**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 \u003e 0` before a loop keeps a heading from surviving its own list.\n\nA 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.\n\n**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.\n\n\n```liquid\n\n{%- comment -%} Rendered from a controller phase, so the update applies {%- endcomment -%}\n{%- liquid\n  assign wishlist = current_customer.data | try: 'wishlist_json__c' | unescape | deserialize\n\n  if wishlist == blank or wishlist == nil\n    new List wishlist\n  endif\n\n  unless wishlist contains product_id\n    assign wishlist = wishlist | push: product_id\n    assign wishlist_string = wishlist | serialize\n    update current_customer, field: 'wishlist_json__c', value: wishlist_string\n  endunless\n-%}\n```\n\n\n**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.\n\nUse `| 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.\n\nDeclare defaults at the top of every snippet with `{% default %}`, so a parameter the caller forgot is visible rather than blank:\n\n\n```liquid\n\n{% default product: nil, show_price: true, heading: \"Featured\" %}\n```\n\n\nRemember 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.\n\n## Verifying your error handling\n\nWork through this list against the template you changed, in the debug Console rather than the page.\n\n1. Load the page and confirm no `Liquid error (line N):` text appears anywhere in the rendered source.\n2. Confirm the template renders at all. Output that is entirely empty is a parse error, reported in the Console as `Liquid syntax error`.\n3. For each value that renders blank, print `.size` on the collection or confirm the attribute exists on the Drop before you investigate the data.\n4. 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_flash` is rendered.\n5. 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.\n6. For each `{% api %}` block, confirm a non-2xx status and an unparseable body both produce customer-safe output.\n7. For each `{% update %}`, confirm the write happened in Salesforce after propagation, not just that the page did not error.\n8. Load the page as an anonymous visitor, as a signed-in customer, with an empty cart, and with no search results.\n9. Remove every `{% debug %}` and `{% timer %}` tag before publishing.\n\nWhen 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."}