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

LocationWhat you writeWhat it returns
Exclusive gateway conditionA boolean expressiontrue or false — chooses the outgoing flow
Inclusive gateway conditionA boolean expression per branchEach true branch is activated
Sequence flow conditionA boolean expressionSame as exclusive-gateway semantics
Script task bodyAny expressionContext → multiple variables; scalar → Result Variable
Business rule task inputVariable references mapped to DMN inputsWhatever 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: counter is missing → resolves to null → defaulted to 0 → returns 1.
  • Second run: counter is 1 → returns 2.
  • 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

SituationFEEL behaviorEngine behavior
Variable name doesn’t existResolves to nullContinues
null + 1Returns nullContinues
Division by zeroReturns null (no exception)Continues
Syntax error in the expressionParse errorScript task: marks instance error, records a script evaluation failed event. Gateway: same outcome on the condition path.
Wrong return shape from a scriptContext expected, scalar returnedEngine 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