Skip to main content

Liquid for Shopify Emails: Architecture and Implementation

Design and build Shopify email templates with Liquid syntax—layout architecture, variable rendering, and deliverability best practices.

Shopify's proprietary email infrastructure relies on a constrained subset of Liquid to render both marketing and transactional messages.

Liquid tag, variable, and filter rendering Liquid control-flow tags, output variables, and filters combine against a payload to produce final HTML. Liquid Rendering Model Tags {% if %} {% for %} Variables {{ order.name }} Filters | money | escape Liquid Parser sandboxed, synchronous Rendered HTML to SMTP relay
Tags drive control flow, variables emit payload data, and filters transform it before the parser produces HTML.
Unlike client-side frameworks, Shopify's rendering pipeline executes Liquid server-side before injecting the final HTML into the delivery queue. Understanding this execution model is critical when designing templates that must align with broader [Modern Email Templating Engines](/modern-email-templating-engines/) standards while adhering to platform-specific limitations. The notification system operates as a stateless, synchronous compiler: when an event triggers (e.g., `order/create`, `customer/account_update`), Shopify resolves the template against a pre-serialized JSON payload, compiles it to HTML, and pushes it to the SMTP relay. Any deviation from the allowed syntax or execution budget results in immediate render failure or fallback to default system templates.

Rendering Constraints and Execution Pipeline

The Liquid parser in Shopify's email stack operates with strict sandboxing. Arbitrary JavaScript execution is blocked, and only a curated set of objects, tags, and filters are permitted. Templates are compiled synchronously during order or notification triggers, which means heavy computational logic or nested loops exceeding platform thresholds will cause render timeouts. Developers migrating from component-driven paradigms like MJML Component Architecture or React Email Development must adapt to a linear, stateless rendering model where data is pre-fetched and injected via Shopify's notification payload.

Execution Limits & Provider-Specific Safeguards

  • Render Timeout: Approximately 2 seconds per template compilation. Exceeding this triggers a silent fallback to Shopify's default transactional template.
  • Object Allowlist: customer, order, line_items, shipping_address, billing_address, fulfillment, discount_codes, shop. Custom metafields require explicit namespace mapping (customer.metafields.namespace.key).
  • Filter Restrictions: json, escape, strip_html, date, money, truncate, replace are safe. Complex array manipulations (map, where, group_by) are available in Shopify's Liquid but not in all email contexts — test in the Notification preview tool before relying on them.

Debugging Render Failures

  1. Enable Liquid Error Logging: In Shopify Admin > Settings > Notifications, use the Preview button to surface Liquid errors inline. Partner accounts can access additional debugging via the Shopify CLI.
  2. Validate Payload Shape: Use the Shopify Admin API GET /admin/api/2025-07/orders/{id}.json to extract the exact payload structure Shopify passes to the email renderer.
  3. Isolate Timeout Culprits: Comment out loops and conditionals incrementally. Replace {% for item in line_items %} with a bounded slice to isolate performance bottlenecks: {% assign first_three = line_items | slice: 0, 3 %}.
{% comment %} Safe Fallback Pattern for Missing Variables {% endcomment %}
{% assign display_name = customer.first_name | default: "Valued Customer" %}
{% assign order_total = order.total_price | money_with_currency %}

<p>Hello {{ display_name }},</p>
<p>Your order {{ order.name }} totals {{ order_total }}.</p>

{% comment %} Conditional Block with Strict Type Checking {% endcomment %}
{% if order.line_items != blank %}
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
    {% for line in order.line_items %}
    <tr>
      <td>{{ line.title }}</td>
      <td align="right">{{ line.quantity }} &times; {{ line.price | money }}</td>
    </tr>
    {% endfor %}
  </table>
{% else %}
  <p>No items found in this notification.</p>
{% endif %}

Note: Shopify Liquid uses {% comment %} for comments, not {#. The {# ... #} syntax is Jinja2/Nunjucks.

Implementation Workflows and Local Tooling

Effective development requires a decoupled testing environment that mirrors Shopify's production parser. Teams typically employ CLI-based linters, mock JSON payloads, and the Shopify CLI preview tool to validate syntax before deployment. Version control integration with automated preview generation ensures that template changes do not break downstream personalization logic. For dynamic content injection, engineers must map Shopify's notification schema to Dynamic Liquid tags for transactional emails while maintaining strict fallback chains for missing variables.

Local Development & CI/CD Pipeline

# Install Shopify CLI
npm install -g @shopify/cli

# Validate theme/template files (works for theme Liquid; for notification Liquid, use the Admin UI preview)
shopify theme check --path ./email-templates

For rendering Liquid locally against mock payloads, use the shopify-liquid npm package (which mirrors Shopify's engine) or the official Liquid Ruby gem in strict mode:

# Using the liquid gem (Ruby)
gem install liquid
ruby -e "
require 'liquid'
template = Liquid::Template.parse(File.read('order_confirmation.liquid'))
puts template.render(JSON.parse(File.read('./mocks/order_12345.json')))
" > ./dist/preview.html

GitHub Actions CI/CD Snippet:

name: Email Template Validation
on:
  push:
    paths: ['email-templates/**']
jobs:
  lint-and-render:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node
        uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm install -g @shopify/cli
      - run: shopify theme check --path ./email-templates
      - name: Upload Template Artifacts
        uses: actions/upload-artifact@v4
        with: { name: email-templates, path: ./email-templates/ }

Mock Webhook Payload Structure (Order Confirmation)

{
  "order": {
    "id": 5098471234,
    "name": "#1001",
    "total_price": "124.50",
    "currency": "USD",
    "created_at": "2024-05-12T14:30:00-04:00",
    "line_items": [
      {
        "title": "Wireless Headphones",
        "quantity": 1,
        "price": "89.50",
        "variant_title": "Black"
      }
    ]
  },
  "customer": {
    "first_name": "Alex",
    "last_name": "Chen",
    "email": "alex.chen@example.com"
  },
  "shop": {
    "name": "TechGear Store",
    "email": "support@techgear.com",
    "domain": "techgear.myshopify.com"
  }
}

Note: Shopify passes monetary values as formatted strings (e.g., "124.50") for the total_price field in notification payloads, not as integers. Use the money filter for display formatting.

Debugging Workflow Tip: Always test with strict mode in local renderers. Shopify's production parser silently ignores undefined variables, but local linters will flag them, preventing broken personalization in production.

Optimization and Maintainability Protocols

Maintaining a scalable Liquid codebase requires disciplined variable scoping, modular partial inclusion, and rigorous inline CSS compilation. Since Shopify's email renderer strips unsupported CSS properties and enforces table-based layouts, developers should implement post-processing pipelines that automatically inline styles and validate against major client rendering matrices. Implementing automated regression testing against historical notification payloads prevents silent failures during platform updates.

CSS Inlining & Post-Processing Pipeline

Shopify's email infrastructure does not support <style> blocks in <head> for all clients. Use juice in your build step to inline styles before uploading templates:

# Install juice
npm install -g juice

# Inline styles and output production-ready HTML
juice ./dist/raw.html --css ./src/styles/email.css --output ./dist/production.html

Client-Specific Fallback Configurations

Client Constraint Implementation Pattern
Outlook (Windows) Drops display: flex, ignores max-width Use <!--[if mso]> conditional tables with fixed widths. Wrap containers in <!--[if !mso]><!--> <!--<![endif]--> for modern clients.
Apple Mail Supports modern CSS; @media queries work Keep @media queries in <style> blocks; duplicate critical mobile rules as inline fallbacks.
Gmail (Web/Android) Strips <style> in <head> for some accounts Rely entirely on inline style attributes. Use class only for progressive enhancement.

Production Debugging Checklist

  • Variable Scope Validation: Ensure {% assign %} variables don't leak across {% include %} partials. Use explicit scoping.
  • Image Asset Resolution: Shopify emails require absolute URLs. Use {{ shop.secure_url }} or {{ image | img_url: 'master' }} to prevent broken assets.
  • Link Tracking Compatibility: Avoid wrapping <a> tags in {% if %} blocks that might strip href attributes. Use {{ url | default: '#' }} fallbacks.
  • Payload Regression Testing: Archive historical webhook payloads. Run local rendering against them after Shopify platform updates to catch schema drift.
  • Delivery Queue Monitoring: Monitor bounce rates and X-Shopify-Notification-ID headers in SMTP logs to correlate render failures with specific template versions.

By enforcing strict variable fallbacks, automating CSS inlining, and validating against real-world notification payloads, engineering teams can maintain high-deliverability transactional templates that scale alongside Shopify's notification ecosystem.

Liquid Objects, Tags, and Filters in the Shopify Notification Context

Liquid has exactly three constructs, and every Shopify email template is built from them. Objects ({{ ... }}) emit data from the pre-serialized notification payload. Tags ({% ... %}) drive control flow and assignment and produce no output of their own. Filters (| name) transform a value inside an output expression. The critical difference from a general-purpose Liquid environment is that Shopify's notification renderer exposes only a fixed object graph — you cannot query the Admin API mid-render, and you cannot reach objects that the triggering event did not serialize. An order/create notification has order, line_items, and customer; a customer/account_activate notification has customer and shop but no order. Referencing a missing object yields nil, which renders as an empty string, so the whole template hinges on disciplined fallback chains.

{% comment %} Objects: read-only, drawn from the notification payload {% endcomment %}
{{ order.name }}                  {%- comment %} "#1001" {% endcomment %}
{{ customer.first_name }}         {%- comment %} nil-safe via default below {% endcomment %}
{{ shop.name }}

{% comment %} Tags: assignment + control flow, emit nothing themselves {% endcomment %}
{% assign greeting_name = customer.first_name | default: "there" %}
{% if order.financial_status == "paid" %}Paid in full{% endif %}

{% comment %} Filters: chain left-to-right; money/date are the email workhorses {% endcomment %}
{{ order.total_price | money }}                     {%- comment %} "$124.50" {% endcomment %}
{{ order.created_at | date: "%B %-d, %Y" }}         {%- comment %} "May 12, 2024" {% endcomment %}
{{ line_item.title | escape | truncate: 40 }}       {%- comment %} HTML-safe, length-capped {% endcomment %}

A subtle but high-impact rule: Shopify passes monetary fields in notification payloads as already-formatted strings ("124.50"), not integers in cents. Calling | money on an already-formatted string can produce doubled symbols or mis-grouped digits depending on the locale. Confirm the field's type in the Notification preview before applying money, and prefer money_with_currency when the recipient base is multi-currency so the ISO code is unambiguous.

Safe Data Binding and Fallback Chains

Transactional mail cannot ship a blank order number or a raw {{ ... }} tag to the inbox. Because Shopify's production parser silently coerces missing objects to empty strings, every customer-facing value needs an explicit default, and every conditional branch needs an else. The pattern below binds the dynamic surface area you depend on in Dynamic Liquid tags for transactional emails, normalizing each value into a local variable first so the markup stays readable.

{% comment %} Normalize once, render many — keeps fallbacks in one place {% endcomment %}
{% assign display_name   = customer.first_name | default: "Valued Customer" | escape %}
{% assign order_label    = order.name | default: "your recent order" %}
{% assign order_total    = order.total_price | money_with_currency %}
{% assign support_email  = shop.email | default: "support@example.com" %}

<p>Hi {{ display_name }},</p>
<p>Thanks for {{ order_label }} — your total is {{ order_total }}.</p>

{% comment %} Guard every loop: an empty/nil collection must not emit a bare table {% endcomment %}
{% if order.line_items != blank %}
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
    {% for line in order.line_items %}
      <tr>
        {% comment %} escape user-controlled product titles — they can contain < > & {% endcomment %}
        <td style="font-family:Arial,sans-serif;font-size:14px;color:#334155;">{{ line.title | escape }}</td>
        <td align="right" style="font-family:Arial,sans-serif;font-size:14px;">{{ line.quantity }} &times; {{ line.price | money }}</td>
      </tr>
    {% endfor %}
  </table>
{% else %}
  <p>Your order details will appear here shortly.</p>
{% endif %}

<p>Questions? Reach us at {{ support_email }}.</p>

Custom Filters for Liquid Rendered Outside Shopify

Shopify's hosted notification editor only allows the built-in filter set, but many teams render the same Liquid templates outside Shopify — in a CI preview step, in a multi-channel notification service, or when migrating templates to a self-hosted engine. In those contexts (shopify-liquid/liquidjs in Node, the liquid gem in Ruby) you can register custom filters to centralize currency, UTM, and preheader logic. Keep the filter names identical to Shopify built-ins where they overlap so a template renders the same in both environments.

// liquidjs custom filters for the out-of-Shopify render path (CI preview, mirror service)
import { Liquid } from 'liquidjs';

const engine = new Liquid({ strictVariables: true, strictFilters: true });

// money: mirror Shopify's formatting so previews match production output exactly
engine.registerFilter('money', (cents, code = 'USD') =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: code })
    .format(Number(cents) / 100));

// utm: append tracking params. Gmail/Apple Mail follow the final href verbatim,
// so build the full URL here rather than relying on the ESP to rewrite links.
engine.registerFilter('utm', (url, campaign) => {
  const u = new URL(url);
  u.searchParams.set('utm_source', 'transactional');
  u.searchParams.set('utm_medium', 'email');
  u.searchParams.set('utm_campaign', campaign);
  return u.toString();
});

// preheader: hidden inbox-preview text. Gmail/Apple Mail otherwise pull the body's
// first words into the list snippet, so we emit a zero-height hidden block.
engine.registerFilter('preheader', (text) =>
  `<div style="display:none;max-height:0;overflow:hidden;">${text}</div>`);

The Render Pipeline: From Notification Trigger to Inbox

Inside Shopify the pipeline is opaque and synchronous, but when you own the render (self-hosted or mirror) you control every stage. The reliable shape is: validate the payload, render Liquid in strict mode, inline CSS, then dispatch — with a static fallback wired at the render boundary so a Liquid::SyntaxError never reaches a customer.

Numbered pipeline-integration steps

  1. Receive the trigger. A backend event (order/create, fulfillment/create) produces the JSON payload. In a mirror service, snapshot the exact Shopify schema from GET /admin/api/2025-07/orders/{id}.json so your local payload matches production field types.
  2. Validate the payload shape against a schema before it touches the engine. Reject or route to fallback rather than rendering partial data.
  3. Parse the template in strict mode (error_mode = :strict in Ruby, strictVariables/strictFilters in liquidjs) so undefined variables and unknown filters raise instead of emitting blanks.
  4. Render to a raw HTML string. Catch Liquid::SyntaxError/UndefinedVariableError here and route to the static fallback template.
  5. Inline the CSS with juice (or css_inline) because Gmail strips <head> styles for many accounts; keep a media-query <style> block for Apple Mail and iOS Mail.
  6. Resolve image URLs to absolute HTTPS. Use {{ image | image_url }} / {{ shop.secure_url }} — relative paths render as broken images in webmail.
  7. Assemble the MIME message with an explicit charset=UTF-8 and a plaintext alternative rendered from the same payload.
  8. Dispatch through the SMTP relay or ESP with a configuration set / message stream so bounces and complaints are tracked back to the template version.

CSS inlining stage

# Gmail (web + Gmail app) strips <head> <style> for many accounts; inline before send.
# juice keeps @media blocks by default so Apple Mail / iOS Mail still get responsive rules.
juice ./dist/order_confirmation.raw.html \
  --css ./src/styles/email.css \
  --preserve-media-queries true \
  --output ./dist/order_confirmation.html

Provider and Client Constraint Reference

Target Constraint that affects Liquid output Exact handling
Gmail (web/Android/iOS) Strips <head>/<style> for many accounts; clips > ~102KB Inline all CSS post-render; trim whitespace; keep total HTML lean
Outlook 2016/2019 (Windows) Word engine drops max-width/display:flex, renders inter-tag whitespace Conditional ghost tables with fixed widths; padding on <td>
Outlook 365 (Windows) Same Word engine on desktop Identical <!--[if mso]> conditional handling
Apple Mail (macOS) Reads <head> @media; honors modern CSS Keep media-query <style>; duplicate critical mobile rules inline
iOS Mail Honors @media; aggressive text auto-sizing Set explicit font-size; retain media block
Samsung Email Partial @media; quirky default link color Inline link colors; avoid class-only styling
Shopify renderer ~2s compile budget; fixed object allowlist; silent fallback on overrun Pre-aggregate data; bound loops; avoid nested heavy iteration
Postmark Separate outbound/broadcast streams Route transactional to outbound; TrackOpens:false
Amazon SES Requires exact MIME via send_raw_email; DKIM alignment on Source Assemble MIME by hand; set ConfigurationSetName
SendGrid Click-tracking rewrites links, breaking UTM/dynamic URLs Disable click/open tracking for transactional sends

Named-Symptom Debugging Reference

  • Symptom: order number renders blank in the live email but fine in preview. Cause: the notification event did not serialize order (e.g., a customer/* event), so order.name is nil. Fix: confirm the object exists for that notification type; add | default: and an {% if %} guard.
  • Symptom: raw {{ order.total_price }} text appears in the inbox. Cause: a typo or unsupported tag caused a silent parse fallback to default content, or the tag sits outside a Liquid context. Fix: validate in the Notification preview; in mirror renders enable strict mode to surface the SyntaxError.
  • Symptom: doubled currency symbol like $$124.50. Cause: | money applied to an already-formatted string from the payload. Fix: check the field type in preview; drop the filter or use the raw string directly.
  • Symptom: template renders the default Shopify system email instead of yours. Cause: render exceeded the ~2s compile budget (usually a large unbounded {% for %}). Fix: pre-aggregate line items; bound the loop with | slice while debugging.
  • Symptom: styles correct in Apple Mail, gone in Gmail. Cause: Gmail stripped the <head> <style>. Fix: inline all rules with juice post-render; keep only @media in the surviving <style>.
  • Symptom: broken image icons in Gmail/Outlook web. Cause: relative src paths. Fix: emit absolute HTTPS URLs via {{ image | image_url }} / {{ shop.secure_url }}.
  • Symptom: variable from one partial leaks into another. Cause: an {% assign %} persisted across {% include %} scope. Fix: prefix partial-local variables and re-assign defaults at the top of each partial.

A Second Pattern: Multi-Currency and Localized Notifications

Stores selling across regions ship the same notification template to recipients whose currency and language differ per order. Liquid in the Shopify notification context exposes the order's presentment currency, so the safe pattern is to format against that field rather than a store default — otherwise a EUR order renders with a $ prefix and erodes trust at exactly the moment a customer is checking a charge.

{% comment %} Use the order's presentment currency, not the shop default, so a
   EUR/GBP buyer never sees a mismatched symbol. money_with_currency emits the
   ISO code (e.g. "124.50 EUR") which is unambiguous across Gmail and Apple Mail. {% endcomment %}
{% assign cur = order.presentment_currency | default: shop.currency | default: "USD" %}
<p>Order total: {{ order.total_price | money_with_currency }}</p>

{% comment %} Language branch: keep copy in the template, not the data layer, so the
   Notification preview tool can verify each locale before publish. {% endcomment %}
{% case customer.locale %}
  {% when "fr" %}<p>Merci pour votre commande {{ order.name }}.</p>
  {% when "de" %}<p>Vielen Dank für Ihre Bestellung {{ order.name }}.</p>
  {% else %}<p>Thank you for order {{ order.name }}.</p>
{% endcase %}

When the same template is rendered outside Shopify in a mirror service, compute the localized strings at the application layer and inject them as pre-formatted variables, so the Liquid stays a thin presentation layer and the heavy Intl/babel formatting never runs inside the ~2s compile budget. This mirrors the strict-mode, pre-merge discipline used for Dynamic Liquid tags for transactional emails and keeps both render paths byte-comparable.

Validation and Deployment Checklist

  • Every customer-facing object has an explicit | default: fallback
  • Every {% for %} loop guarded by {% if collection != blank %} with an {% else %} branch
  • All user-controlled strings (product titles, notes) passed through | escape
  • Monetary fields confirmed as string vs integer in the Notification preview before | money
  • Image URLs resolved to absolute HTTPS via image_url / shop.secure_url
  • CSS inlined post-render with juice; @media block preserved for Apple Mail / iOS Mail
  • Strict mode enabled in the out-of-Shopify render path to catch undefined variables in CI
  • Static fallback template wired to catch SyntaxError/validation failure at the render boundary
  • Plaintext alternative rendered from the same payload; charset=UTF-8 set on the HTML part
  • Tested against archived historical payloads after each Shopify platform update for schema drift
  • Rendered across Gmail, Outlook 2016/2019/365, Apple Mail, iOS Mail, and Samsung Email

← Back to Modern Email Templating Engines