Skip to main content

FEEL Cheat Sheet: DMN Expressions by Example

· 8 min read
Richard Bízik
Founder of QuantumBPM

FEEL - the Friendly Enough Expression Language - is what you write inside every DMN decision table cell and, in QuantumBPM, in every BPMN condition and I/O mapping. It is small, but it does not look like the languages most of us came from: equality is =, lists start at 1, function names have spaces in them, and there is no map.

This is the page to keep open while you write it. Every section works on the same sample order, and every section ends with a link that opens it in the FEEL playground with the data already loaded, so you can press Run and change things.

The sample data

All examples below evaluate against this input:

{
"order": {
"id": "QB-2026-001",
"total": 1250.50,
"placed": "2026-09-23",
"customer": { "name": "Ada Lovelace", "tier": "gold", "email": "ada@example.com" },
"items": [
{ "sku": "A1", "price": 50, "qty": 2, "category": "books" },
{ "sku": "B2", "price": 150, "qty": 1, "category": "electronics" },
{ "sku": "C3", "price": 20, "qty": 5, "category": "books" }
]
},
"tags": ["new", "vip", "new"]
}

Operators and conditions

1 + 2 * 3                                           // 7
10 / 4 // 2.5
2 ** 10 // 1024
modulo(17, 5) // 2

order.customer.tier = "gold" // true - one =, never ==
order.total != 0 // true
order.total > 1000 and order.customer.tier = "gold" // true
not(order.customer.tier = "gold") // false

if order.total > 1000 then "large" else "small" // "large" - else is required
order.total between 1000 and 2000 // true
order.total in [1000..2000] // true
order.customer.tier in ["gold", "platinum"] // true
"vip" in tags // true

"Order " + order.id // "Order QB-2026-001"
"Total: " + string(order.total) // "Total: 1250.5"

There is no implicit conversion: "Total: " + order.total is an error, not a string. Convert with string() and number().

Run these in the playground

Null and missing data

order.coupon.code                                   // null - a missing key is null, at any depth
order.coupon.code != null // false
if order.customer.phone != null
then order.customer.phone
else "n/a" // "n/a"
get value(order, "coupon") // null - key name computed at runtime

null = null // true
null > 5 // null
null or true // true

Three rules cover almost every null question:

  • A path to a missing field is null, so order.coupon.code != null is the null check. There is no is defined in standard FEEL - it is a Camunda extension.
  • A bare name that does not exist is an error. coupon != null fails with unknown identifier coupon when there is no coupon variable at all. Check it through its parent instead.
  • Arithmetic and most functions do not accept null. null + 5 and string length(null) are execution errors in the DMN spec, so guard optional fields before you compute with them.

Run these in the playground

Strings

string length("hello")                              // 5
substring(order.id, 4, 4) // "2026" - positions start at 1
substring(order.id, -3) // "001" - negative counts from the end
substring before(order.customer.email, "@") // "ada"
substring after(order.customer.email, "@") // "example.com"
upper case(order.customer.tier) // "GOLD"
contains(order.id, "2026") // true
starts with(order.id, "QB") // true
ends with(order.id, "001") // true
split("a,b,c", ",") // ["a", "b", "c"]
string join(order.items.sku, ", ") // "A1, B2, C3"
replace("2026-09-23", "-", "/") // "2026/09/23"
matches(order.customer.email, "^[^@]+@[^@]+$") // true

substring("QB-2026-001", 0, 2) quietly returns "": position 0 is not the first character. replace, matches and split take regular expressions, so a literal backslash is "\\\\" in the pattern. There is no substr.

Run these in the playground

Numbers

sum(for i in order.items return i.price * i.qty)    // 350
sum(order.items.price) // 220
max(order.items.price) // 150
mean([1, 2, 3, 4]) // 2.5
decimal(1 / 3, 2) // 0.33
round half up(2.5, 0) // 3
round down(2.567, 2) // 2.56
floor(2.7) // 2
ceiling(2.1) // 3
abs(-4) // 4
number("42") // 42
number("1.234,56", ".", ",") // 1234.56 - grouping and decimal separators
0.1 + 0.2 = 0.3 // true - FEEL numbers are decimals, not floats

Run these in the playground

Lists

order.items[1].sku                                  // "A1" - lists start at 1
order.items[-1].sku // "C3" - negative counts from the end
order.items[0] // null, not the first item

order.items[price > 100].sku // ["B2"] - filter, fields are in scope by name
order.items[item.price > 100].sku // ["B2"] - same, with the implicit item
order.items[sku = "B2"][1].price // 150 - look up one record
order.items.sku // ["A1", "B2", "C3"] - projection
count(order.items[category = "books"]) // 2

for item in order.items return item.price * item.qty // [100, 150, 100] - FEEL's map
for i in 1..3 return i * i // [1, 4, 9]
some i in order.items satisfies i.price > 100 // true - no brackets after some
every i in order.items satisfies i.qty > 0 // true

list contains(tags, "vip") // true
distinct values(tags) // ["new", "vip"]
index of(tags, "new") // [1, 3]
append(tags, "late") // ["new", "vip", "new", "late"]
concatenate([1, 2], [3]) // [1, 2, 3]
sublist([10, 20, 30, 40], 2, 2) // [20, 30]
flatten([[1, 2], [3]]) // [1, 2, 3]
reverse([1, 2, 3]) // [3, 2, 1]
sort(order.items, function(a, b) a.price > b.price).sku // ["B2", "A1", "C3"]
all([true, false]) // false
any([true, false]) // true

A filter always returns a list, which is why a lookup ends in [1]. Putting the key in the brackets, order.items.price["B2"], is not a lookup: it filters by a constant string and returns [].

Run these in the playground

Contexts

A context is FEEL's object: key-value pairs, with JSON input arriving as nested contexts.

{name: "Ada", tier: "gold"}.tier                    // "gold"
context put(order.customer, "tier", "platinum") // copy with one key changed
context merge([{a: 1}, {b: 2}]) // {a: 1, b: 2}
get entries({a: 1, b: 2}) // [{key: "a", value: 1}, {key: "b", value: 2}]
context(for c in ["LIT", "DDE"] return {key: c, value: true}) // {LIT: true, DDE: true}

{
subtotal: sum(for i in order.items return i.price * i.qty),
tax: subtotal * 0.2,
total: subtotal + tax
}.total // 420 - later entries see earlier ones

{
discount: function(amount, rate) amount * (1 - rate),
result: discount(order.total, 0.1)
}.result // 1125.45 - a local function

The last two patterns are how you write intermediate variables and helper functions in a single expression. Asking for a key the context does not have, such as .result on a context without one, gives null, not an error.

Run these in the playground

Dates, times and durations

date(order.placed)                                  // 2026-09-23 - JSON strings need converting
@"2026-09-23" // the same date as a literal
date(order.placed) + duration("P10D") // 2026-10-03
date(order.placed) + @"P1M" // 2026-10-23
date("2026-12-25") - date(order.placed) // P93D
date(order.placed) > date("2026-01-01") // true

date(order.placed).year // 2026
date(order.placed).month // 9 - properties, not month()
date(order.placed).day // 23
date(order.placed).weekday // 3 (Monday is 1)
day of week(date(order.placed)) // "Wednesday"
last day of month(date(order.placed)) // 2026-09-30

date and time("2026-09-23T14:00:00Z") + duration("PT90M") // 2026-09-23T15:30:00Z
date and time("2026-09-23T14:00:00@Europe/Paris") // with a time zone
date(date and time("2026-09-23T14:00:00Z")) // 2026-09-23
time("14:30:00") // 14:30:00
years and months duration(date("1990-05-15"), date(order.placed)).years // 36 - an age
today() < today() + duration("P1D") // true

Durations need quotes or the @ form: duration(P1D) without quotes is read as a variable called P1D. A date minus a number is an error, add or subtract a duration.

Run these in the playground

Decision table cells

Input entries in a decision table are unary tests: the column's input is the implicit left-hand side.

CellMatches when the input
< 18is less than 18
[18..65]is between 18 and 65, both included
(18..65]is over 18 and at most 65
"gold", "platinum"is either value
not("blocked")is anything except "blocked"
-is anything (the rule does not care)

The same tests work in an expression after in: order.total in > 1000 and order.total in [1000..2000] are both true.

Coming from another language

You might writeIn FEELWhy
a == ba = bThere is no ==, FEEL has no assignment, so = is equality.
a && b, a || ba and b, a or b&& is a parse error.
x > 0 ? "a" : "b"if x > 0 then "a" else "b"No ternary, if is an expression and needs else.
list[0]list[1]Lists and strings start at 1.
substr(s, 0, 2)substring(s, 1, 2)substr does not exist.
month(d)d.monthDate parts are properties.
is defined(x.y)x.y != nullis defined is a Camunda extension.
list.map(x -> ...)for x in list return ...No map and no arrow functions.
"Total: " + 5"Total: " + string(5)No implicit conversion.
some(x in l satisfies ...)some x in l satisfies ...Quantifiers take no brackets.

Where to go next

Or skip the reading and open the playground: the same engine that runs QuantumBPM decisions in production, with autocomplete, hover docs and a share link for every expression.