DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Liquid Templates in Azure Logic Apps: What They Are and Why They Matter

The Problem Every Logic Apps Developer Hits

You are building an integration on Azure Logic Apps Standard. The upstream system sends you a rich, nested JSON payload — a sales order with line items, discount codes, shipping methods, and state-specific tax rates. The downstream system expects a flat, transformed structure with calculated totals, a carrier label, and an SLA timestamp.

The built-in expression language gets you partway there. But the moment you need a loop, a conditional lookup table, or a running subtotal across items, you hit a wall.

That is the moment you reach for Liquid templates.


What Is Liquid?

Liquid is an open-source template language originally created by Shopify. It is designed to be safe, sandboxed, and easy to read — output is produced by mixing static text with template tags that reference data.

There are two kinds of tags:

{{ customer.firstName }}          /* output tag — renders a value */
{% if order.total > 1000 %}       /* logic tag — controls flow */
Enter fullscreen mode Exit fullscreen mode

Note: The /* ... */ markers above are code annotations for readability only — they are not valid Liquid syntax. DotLiquid comments use {% comment %}...{% endcomment %}.

Variables, loops, conditionals, and filters cover most transformation needs without requiring you to write C# or JavaScript.


DotLiquid: The .NET Flavour Used by Logic Apps

Azure Logic Apps Standard does not use the original Ruby Liquid gem. It uses DotLiquid, a .NET port. This extension targets DotLiquid 2.0.361, which matches the version used by Logic Apps Standard at the time of writing.

This is not a minor implementation detail. There are several behavioural differences that will catch you off guard if you are used to standard Liquid or testing with an online Liquid playground.


Difference 1 — Filter names are sentence-cased

Standard Liquid uses lowercase filter names. DotLiquid uses sentence case.

Standard Liquid DotLiquid (Logic Apps)
upcase Upcase
downcase Downcase
divided_by DividedBy
times Times
round Round
split Split
join Join
sort Sort
map Map
replace_first ReplaceFirst
truncate Truncate
strip_html StripHtml
url_encode UrlEncode

If you use the wrong casing, the filter is silently ignored — the value passes through unchanged with no error.


Difference 2 — DividedBy truncates when both operands are whole numbers

This is the most common source of silent calculation errors.

Both standard Liquid and DotLiquid produce an integer result when the divisor is a whole number — this is not a DotLiquid-specific quirk, it is how both implementations work:

{{ 7 | divided_by: 2 }}   /* standard Liquid: outputs 3, not 3.5 */
{{ 7 | DividedBy: 2 }}    /* DotLiquid: outputs 3, not 3.5 */
Enter fullscreen mode Exit fullscreen mode

Output: 3

To get decimal output, make the divisor a float:

{{ 7 | DividedBy: 2.0 }}
Enter fullscreen mode Exit fullscreen mode

Output: 3.5

This matters for percentage calculations. total | Times: taxRate | DividedBy: 100 will truncate if the running value is a whole number — use 100.0 instead.

Subtle difference for negative numbers: Standard Liquid (Ruby) uses floor division (-7 / 2 = -4), while DotLiquid (C#) uses truncation (-7 / 2 = -3). For positive numbers the results are identical, but if your data can contain negative values this divergence is worth knowing.


Difference 3 — Sort is case-insensitive

Standard Liquid sorts with case-sensitive comparison — uppercase letters sort before lowercase in ASCII/ordinal order (B < a), so ["Banana", "apple"] would sort to ["Banana", "apple"]. DotLiquid sorts case-insensitively (OrdinalIgnoreCase), so the same array becomes ["apple", "Banana"].

If you are sorting strings and the casing matters to the sort order, be aware the result will differ from a Ruby Liquid environment.


Difference 4 — Date format uses .NET format strings, not strftime

Standard Liquid uses strftime format codes:

{{ "now" | date: "%Y-%m-%d" }}
Enter fullscreen mode Exit fullscreen mode

DotLiquid uses .NET format strings:

{{ "now" | Date: "yyyy-MM-dd" }}
Enter fullscreen mode Exit fullscreen mode

The filter name is also sentence-cased (Date, not date).


Difference 5 — Some standard filters are missing or renamed

Some standard Liquid filters are not available in DotLiquid 2.0.361 at all; others exist but only under their sentence-cased name:

Standard Liquid filter DotLiquid equivalent
compact Compact (available, sentence-cased)
uniq Uniq (available, sentence-cased)
sort_natural Not available
where Not available
find Not available
sum Not available

If your template relies on these, you will need to work around them with loops and assign statements.


Difference 6 — The content wrapper

When Logic Apps Standard invokes a Liquid transform, the input JSON is wrapped in a content object:

{ "content": { ...your actual payload... } }
Enter fullscreen mode Exit fullscreen mode

So if your input is:

{ "orderNumber": "ORD-001", "total": 149.99 }
Enter fullscreen mode Exit fullscreen mode

Inside your template you access it as:

{{ content.orderNumber }}
{{ content.total }}
Enter fullscreen mode Exit fullscreen mode

Miss this and every variable renders blank with no error message.


Quick reference

Behaviour Standard Liquid DotLiquid 2.0.361
Filter casing upcase, downcase Upcase, Downcase
Integer division Truncates when divisor is integer (same behaviour) Truncates when both operands are whole numbers; floor vs truncation differs for negatives
Sort order Case-sensitive Case-insensitive
Date format strftime (%Y-%m-%d) .NET format (yyyy-MM-dd)
Input root Direct access Wrapped in content.*
compact / uniq Available (compact, uniq) Available (Compact, Uniq)
sort_natural / where / sum Available Not available

The Core Syntax You Will Use Daily

Output

{{ content.customer.firstName }}
{{ content.order.total | Round: 2 }}
{{ content.status | Upcase }}
Enter fullscreen mode Exit fullscreen mode

Assign (variables)

{%- assign subtotal = 0 -%}
{%- assign taxRate = 0.085 -%}
{%- assign tax = subtotal | Times: taxRate | Round: 2 -%}
Enter fullscreen mode Exit fullscreen mode

The - inside {%- and -%} strips the surrounding whitespace — essential for keeping JSON output clean.

Loops

{%- for item in content.items -%}
  {%- assign lineTotal = item.quantity | Times: item.unitPrice -%}
  {%- assign subtotal = subtotal | Plus: lineTotal -%}
{%- endfor -%}
Enter fullscreen mode Exit fullscreen mode

Conditionals

{%- if content.priority == "CRITICAL" -%}
  {%- assign slaHours = 4 -%}
{%- elsif content.priority == "HIGH" -%}
  {%- assign slaHours = 24 -%}
{%- else -%}
  {%- assign slaHours = 72 -%}
{%- endif -%}
Enter fullscreen mode Exit fullscreen mode

Filter chains

Filters are chained left to right. Each filter receives the output of the previous one:

{%- assign grandTotal = subtotal | Minus: discount | Plus: tax | Round: 2 -%}
Enter fullscreen mode Exit fullscreen mode

A Real-World Example: B2B Sales Order Transform

Here is a condensed version of a real Logic Apps transformation. The input is a B2B order with line items, a coupon code, and a shipping method. The output is a flat JSON structure ready for an ERP system.

Input (excerpt):

{
  "couponCode": "WINTER20",
  "header": { "priority": "HIGH" },
  "shipping": { "method": "FEDEX_GROUND" },
  "lines": [
    { "category": "HARDWARE", "quantity": 5, "unitPrice": 24.50, "discountPct": 10 },
    { "category": "SOFTWARE", "quantity": 3, "unitPrice": 99.00, "discountPct": 0  }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Template (excerpt):

{%- assign couponDiscount = 0 -%}
{%- if content.couponCode == "WINTER20" -%}{%- assign couponDiscount = 20 -%}
{%- elsif content.couponCode == "SAVE10" -%}{%- assign couponDiscount = 10 -%}
{%- endif -%}

{%- assign slaHours = 72 -%}
{%- if content.header.priority == "HIGH" -%}{%- assign slaHours = 24 -%}
{%- endif -%}

{%- assign carrier = "FedEx" -%}
{%- assign serviceLevel = "Ground" -%}

{%- assign subtotal = 0 -%}
{%- for line in content.lines -%}
  {%- assign grossLine = line.quantity | Times: line.unitPrice -%}
  {%- assign lineDiscount = grossLine | Times: line.discountPct | DividedBy: 100 -%}
  {%- assign netLine = grossLine | Minus: lineDiscount -%}
  {%- assign subtotal = subtotal | Plus: netLine -%}
{%- endfor -%}

{%- assign couponAmt = subtotal | Times: couponDiscount | DividedBy: 100 -%}
{%- assign grandTotal = subtotal | Minus: couponAmt | Round: 2 -%}

{
  "carrier": "{{ carrier }}",
  "serviceLevel": "{{ serviceLevel }}",
  "slaHours": {{ slaHours }},
  "subtotal": {{ subtotal | Round: 2 }},
  "couponDiscount": {{ couponAmt | Round: 2 }},
  "grandTotal": {{ grandTotal }}
}
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "carrier": "FedEx",
  "serviceLevel": "Ground",
  "slaHours": 24,
  "subtotal": 407.25,
  "couponDiscount": 81.45,
  "grandTotal": 325.8
}
Enter fullscreen mode Exit fullscreen mode

Output Is Not Just JSON

A common misconception is that Logic Apps Liquid transforms only produce JSON. The template engine outputs whatever text the template produces. The three other common output formats:

XML — for B2B, EDI, or ERP integrations:

<?xml version="1.0" encoding="UTF-8"?>
<PurchaseOrder id="{{ content.orderNumber }}">
  <Vendor>{{ content.vendor.name }}</Vendor>
  {%- for item in content.lines -%}
  <LineItem sku="{{ item.sku }}">{{ item.quantity }}</LineItem>
  {%- endfor -%}
</PurchaseOrder>
Enter fullscreen mode Exit fullscreen mode

HTML — for email bodies via SendGrid or similar:

<h1>Order Confirmation</h1>
<p>Hi {{ content.customer.firstName }},</p>
<p>Your order total is ${{ content.total | Round: 2 }}.</p>
Enter fullscreen mode Exit fullscreen mode

Plain text — for warehouse printers, SMS, or legacy systems:

TRACKING: {{ content.trackingNumber }}
SHIP TO:  {{ content.recipient.name }}
          {{ content.recipient.address.line1 }}
Enter fullscreen mode Exit fullscreen mode

The Pain: Flying Blind

Everything described above sounds straightforward — until you actually try to build it inside Logic Apps Studio.

The typical development loop looks like this:

  1. Write the template in the Azure Portal or a text editor
  2. Upload it to your Logic Apps integration account
  3. Deploy or trigger a test run
  4. Wait for the run history to populate
  5. Expand the transform action output
  6. Discover the output is wrong — or blank
  7. Guess which line is the problem
  8. Repeat

There is no syntax highlighting. No inline error messages. No way to inspect what a variable holds mid-template. A misnamed filter (upcase instead of Upcase) is silently ignored — the value passes through unchanged with no warning. A wrong dot-path (content.order.total instead of content.total) does the same.

The round-trip from "edit" to "see output" can take five minutes or more per iteration for a non-trivial template.


What Comes Next

In Part 2, we look at the DotLiquid Debugger VS Code extension — a tool that replaces the Azure Portal round-trip with instant local feedback, a step-by-step debugger, and filter chain tracing, all using the same DotLiquid 2.0.361 engine that Logic Apps Standard runs in production.


Part 2: Debug DotLiquid Templates Locally with the VS Code DotLiquid Debugger

Top comments (0)