FEEL Cheat Sheet: DMN Expressions by Example
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().
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, soorder.coupon.code != nullis the null check. There is nois definedin standard FEEL - it is a Camunda extension. - A bare name that does not exist is an error.
coupon != nullfails withunknown identifier couponwhen there is nocouponvariable at all. Check it through its parent instead. - Arithmetic and most functions do not accept
null.null + 5andstring length(null)are execution errors in the DMN spec, so guard optional fields before you compute with them.
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.
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
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 [].
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.
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.
Decision table cells
Input entries in a decision table are unary tests: the column's input is the implicit left-hand side.
| Cell | Matches when the input |
|---|---|
< 18 | is 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 write | In FEEL | Why |
|---|---|---|
a == b | a = b | There is no ==, FEEL has no assignment, so = is equality. |
a && b, a || b | a 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.month | Date parts are properties. |
is defined(x.y) | x.y != null | is 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
- The FEEL overview explains where FEEL runs in DMN and BPMN, and its FAQ goes deeper on null handling and lookups.
- Built-in functions lists every function with its parameters.
- The FEEL syntax guide covers types, unary tests and comments.
- To see FEEL inside a real decision, read What is DMN?.
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.