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 */
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 */
Output: 3
To get decimal output, make the divisor a float:
{{ 7 | DividedBy: 2.0 }}
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" }}
DotLiquid uses .NET format strings:
{{ "now" | Date: "yyyy-MM-dd" }}
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... } }
So if your input is:
{ "orderNumber": "ORD-001", "total": 149.99 }
Inside your template you access it as:
{{ content.orderNumber }}
{{ content.total }}
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 }}
Assign (variables)
{%- assign subtotal = 0 -%}
{%- assign taxRate = 0.085 -%}
{%- assign tax = subtotal | Times: taxRate | Round: 2 -%}
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 -%}
Conditionals
{%- if content.priority == "CRITICAL" -%}
{%- assign slaHours = 4 -%}
{%- elsif content.priority == "HIGH" -%}
{%- assign slaHours = 24 -%}
{%- else -%}
{%- assign slaHours = 72 -%}
{%- endif -%}
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 -%}
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 }
]
}
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 }}
}
Output:
{
"carrier": "FedEx",
"serviceLevel": "Ground",
"slaHours": 24,
"subtotal": 407.25,
"couponDiscount": 81.45,
"grandTotal": 325.8
}
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>
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>
Plain text — for warehouse printers, SMS, or legacy systems:
TRACKING: {{ content.trackingNumber }}
SHIP TO: {{ content.recipient.name }}
{{ content.recipient.address.line1 }}
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:
- Write the template in the Azure Portal or a text editor
- Upload it to your Logic Apps integration account
- Deploy or trigger a test run
- Wait for the run history to populate
- Expand the transform action output
- Discover the output is wrong — or blank
- Guess which line is the problem
- 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)