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.
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,replaceare 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
- 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.
- Validate Payload Shape: Use the Shopify Admin API
GET /admin/api/2025-07/orders/{id}.jsonto extract the exact payload structure Shopify passes to the email renderer. - 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 }} × {{ 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 striphrefattributes. 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-IDheaders 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 }} × {{ 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
- Receive the trigger. A backend event (
order/create,fulfillment/create) produces the JSON payload. In a mirror service, snapshot the exact Shopify schema fromGET /admin/api/2025-07/orders/{id}.jsonso your local payload matches production field types. - Validate the payload shape against a schema before it touches the engine. Reject or route to fallback rather than rendering partial data.
- Parse the template in strict mode (
error_mode = :strictin Ruby,strictVariables/strictFiltersinliquidjs) so undefined variables and unknown filters raise instead of emitting blanks. - Render to a raw HTML string. Catch
Liquid::SyntaxError/UndefinedVariableErrorhere and route to the static fallback template. - Inline the CSS with
juice(orcss_inline) because Gmail strips<head>styles for many accounts; keep a media-query<style>block for Apple Mail and iOS Mail. - Resolve image URLs to absolute HTTPS. Use
{{ image | image_url }}/{{ shop.secure_url }}— relative paths render as broken images in webmail. - Assemble the MIME message with an explicit
charset=UTF-8and a plaintext alternative rendered from the same payload. - 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., acustomer/*event), soorder.nameisnil. 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 theSyntaxError. - Symptom: doubled currency symbol like
$$124.50. Cause:| moneyapplied 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| slicewhile debugging. - Symptom: styles correct in Apple Mail, gone in Gmail. Cause: Gmail stripped the
<head><style>. Fix: inline all rules withjuicepost-render; keep only@mediain the surviving<style>. - Symptom: broken image icons in Gmail/Outlook web. Cause: relative
srcpaths. 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;@mediablock 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-8set 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
Related
- Dynamic Liquid Tags for Transactional Emails — deterministic variable injection and fallback logic for order and customer data
- Jinja2 for Python Apps — a comparable sandboxed, server-side rendering model outside the Shopify stack
- MJML Component Architecture — for teams pairing Liquid data with a component-driven layout layer
- Handlebars Email Templates — another logic-light syntax with similar tag and filter ergonomics
← Back to Modern Email Templating Engines