Expressions (FEEL Cookbook)
FEEL is a pure expression language — every expression evaluates to a single value, and there is no assignment, no statements, no mutation. To “update” a variable, return its new value from a script task; the engine writes it back.
This page is a cookbook of patterns. For the syntax index see the FEEL Reference. For how variables flow through scopes see Variables & Scopes.
Where FEEL runs in a process
| Location | What you write | What it returns |
|---|---|---|
| Exclusive gateway condition | A boolean expression | true or false — chooses the outgoing flow |
| Inclusive gateway condition | A boolean expression per branch | Each true branch is activated |
| Sequence flow condition | A boolean expression | Same as exclusive-gateway semantics |
| Script task body | Any expression | Context → multiple variables; scalar → Result Variable |
| Business rule task input | Variable references mapped to DMN inputs | Whatever the DMN decides |
How variables are resolved
A bare identifier — customer, amount, status — is looked up in the variable context the engine builds before every evaluation. If the name doesn’t exist, FEEL returns null (not an error). This is why null-safe patterns matter: a missing variable and a variable set to null look the same.
The reserved identifiers instanceId and instanceCounter are always set (see Variables & Scopes).
Null-safe patterns
Default a value when missing
if x = null then 0 else x
This is the canonical idiom. There is no ?? operator in FEEL.
Default inside an arithmetic expression
(if amount = null then 0 else amount) * 1.05
Parenthesise the conditional — if … then … else … is lower-precedence than *.
Safe property access
When a parent could be missing, gate it explicitly:
if customer = null then null else customer.tier
Path access on null propagates null in FEEL (customer.tier where customer is null returns null), but the explicit form makes the intent obvious and survives across evaluator versions.
Default an empty list
if items = null then [] else items
null comparison
Use =, not is. Both x = null and null = x work.
Counter pattern
A counter increments by one each time a script task runs. FEEL has no mutation, so the script reads the current value and returns the new value — the engine writes it back.
{ counter: (if counter = null then 0 else counter) + 1 }
- First run:
counteris missing → resolves tonull→ defaulted to0→ returns1. - Second run:
counteris1→ returns2. - And so on.
Place the script task inside a loop body (e.g. inside an exclusive-gateway loop) or just before each branch you want to count, and the counter advances.
For a scalar output style, set Result Variable = counter and write:
(if counter = null then 0 else counter) + 1
Accumulator pattern
Sum a stream of values seen at different points in a process:
{ total: (if total = null then 0 else total) + amount }
The amount is whatever the surrounding context produced — a task output, a service-task return, a literal.
Conditional counter
Increment only when a condition holds, otherwise pass through unchanged:
{ retries: if lastResult = "failure"
then (if retries = null then 0 else retries) + 1
else (if retries = null then 0 else retries) }
Two outputs from one script
A context literal sets multiple variables in a single hop:
{
counter: (if counter = null then 0 else counter) + 1,
lastSeenAt: now()
}
This is one script task, one transaction, two variables written.
List building
Append an item
{ items: append(if items = null then [] else items, newItem) }
append is non-mutating and returns a new list — exactly the shape FEEL needs.
Append only when not already present
{ items:
if list contains(if items = null then [] else items, newItem)
then (if items = null then [] else items)
else append(if items = null then [] else items, newItem)
}
Verbose, but pure. For repeated use, a worker performing the merge with proper data types is usually clearer than a long FEEL expression.
Aggregate from a list
{ stats:
{
count: count(orders),
total: sum(orders),
mean: mean(orders)
}
}
The nested context literal lets you set a single variable to a structured value.
Deep property access
If your variables are JSON objects, dot navigation works:
customer.address.country
If any segment is null, the result is null.
Gateway conditions
// Numeric thresholds
amount > 1000
// Combined conditions
amount > 1000 and tier = "gold"
// Negation
not(status = "closed")
// Set membership
tier = "gold" or tier = "platinum"
// Null-tolerant
amount != null and amount > 0
// List size
count(items) > 0
// Date comparison
date(orderDate) < today()
Conditional writes
Sometimes you want a script to write a variable only when a condition holds. FEEL has no statement-level if, but you can return a context that only mentions the variable in one branch — but be aware: a context literal sets every key it mentions. To leave a variable untouched, omit the script task on that path entirely (use an exclusive gateway to skip it) rather than trying to express “skip this write” in FEEL.
Operator precedence (high to low)
. property access
[ ] list index
unary - negation
** exponent
* / % multiplicative
+ - additive
< <= > >= = != comparison
not(...) logical not (a function call)
and logical and
or logical or
if then else conditional
Use parentheses freely — they’re free, and they make intent unambiguous.
What happens when something goes wrong
| Situation | FEEL behavior | Engine behavior |
|---|---|---|
| Variable name doesn’t exist | Resolves to null | Continues |
null + 1 | Returns null | Continues |
| Division by zero | Returns null (no exception) | Continues |
| Syntax error in the expression | Parse error | Script task: marks instance error, records a script evaluation failed event. Gateway: same outcome on the condition path. |
| Wrong return shape from a script | Context expected, scalar returned | Engine writes the scalar to Result Variable if configured, otherwise the value is dropped. |
For gateway conditions, an unmatched outgoing flow falls back to the default flow if one is configured; otherwise the instance errors. See Exclusive Gateway for the full rules.
Iteration
FEEL’s iteration constructs return a new value — they never mutate the source.
for produces a new list
{ totals: for line in order_lines return line.qty * line.price }
This is FEEL’s analogue of map — one output element per input.
Iterate with the index
for x in 1..count(list) walks indices; combine with list[i]:
{
numbered:
for i in 1..count(items)
return { idx: i, name: items[i].name }
}
Filtered iteration
FEEL has no filter keyword; combine for with if:
{ premium_lines: [ line in order_lines : line.price > 100 ] }
The [ x in list : cond ] list-comprehension form is the canonical way to filter.
Existential and universal
{ has_priority: some line in order_lines satisfies line.priority = "high" }
{ all_paid: every inv in invoices satisfies inv.status = "paid" }
Numeric recipes
Round to two decimals
{ rate_pct: decimal((received / ordered) * 100, 2) }
Clamp to a range
{ capped: if amount > 1000 then 1000 else (if amount < 0 then 0 else amount) }
Percent change with zero guard
{
pct_change:
if previous = null or previous = 0
then null
else decimal((current - previous) / previous * 100, 2)
}
Sum a list of objects’ fields
{ subtotal: sum(for line in order_lines return line.qty * line.price) }
sum expects a list of numbers; the for expression produces exactly that.
String recipes
Pad with leading zeros
{
ref:
"INV-" +
(if string length(string(counter)) >= 5
then string(counter)
else substring("00000" + string(counter), -5))
}
Lowercase a token
{ slug: lower case(replace(name, " ", "-")) }
Conditional prefix
{
label:
if vendor = null
then sku
else vendor + " · " + sku
}
Build a comma-separated list of names
{ tag_line: string join(for u in users return u.name, ", ") }
Date and time recipes
FEEL has native date / time / date-and-time / duration types. Use date(s), time(s), date and time(s), and duration(s) to parse.
Today as a string
{ today: string(date(date and time(today() + duration("P0D")))) }
Days between two dates
{ days: (date(end) - date(start)).days }
date - date yields a years-months duration; for accurate day counts, parse both as date-and-time and subtract:
{ days: ((date and time(end + "T00:00:00") - date and time(start + "T00:00:00")) / duration("P1D")) }
Add a duration
{ due_at: date and time(created_at) + duration("P7D") }
Is a date in the past?
{ overdue: date and time(due_at) < date and time(today() + "T00:00:00") }
Day-of-week
{ weekday: day of week(date(orderDate)) }
Returns a string like "Monday".
List recipes
Distinct values
{ vendors: distinct values(for line in order_lines return line.vendor) }
Find first matching
FEEL has no built-in first match; combine [ x : cond ] with [1]:
{ first_failure:
if count([ r in results : r.status = "error" ]) = 0
then null
else [ r in results : r.status = "error" ][1]
}
FEEL list indices are 1-based.
Count matching
{ error_count: count([ r in results : r.status = "error" ]) }
Index of an item
{ pos: index of(skus, "ABC-123")[1] }
index of returns a list of all matching positions; take the first.
Sort numerically
{ ascending: sort(amounts, function(a, b) a < b) }
sort takes a comparator function. To sort objects by a field:
{ by_price: sort(order_lines, function(a, b) a.price < b.price) }
Build a histogram
{
by_status:
{
pending: count([ r in results : r.status = "pending" ]),
success: count([ r in results : r.status = "success" ]),
error: count([ r in results : r.status = "error" ])
}
}
Working with nested objects
Pick a sub-tree
{ contact: customer.contact }
Spread an object via context literal
FEEL has no spread operator. Enumerate the keys you need:
{
customer_id: customer.id,
customer_name: customer.name,
customer_tier: customer.tier
}
If your shape is deep and stable, a jq step is usually a better fit.
Function literals
Inline functions are useful with sort, some, every, and as filter predicates:
{ adults: sort([ p in people : p.age >= 18 ], function(a, b) a.age < b.age) }
{ has_blocker: some t in tasks satisfies function(x) x.priority = "high" and x.status != "done" (t) }
The function(args) body form defines an anonymous function; the trailing (t) invokes it with the iteration variable.
Cross-references
- Full FEEL syntax: FEEL Reference
- Where script outputs go: Script Task
- Other languages in the pipeline: jq Cookbook, Rhai Cookbook
- Variable storage and scopes: Variables & Scopes
- Decision tables (the other main FEEL host): Decision Tables Overview