Expressions (Python)
Expressions live inside {{ ... }} in any string-typed activity input. This
workflow declares expressionLanguage: python, so the runner evaluates them as
Python. You get the expression language you already know — comprehensions,
slicing, and/or/not, dict and list methods, len/sorted/sum/any/
all/zip/enumerate/range — plus the same four function packages the
other two flavours register, spelled identically.
Read this first: it is a dialect
The interpreter is Starlark. The expression language is Python's; the program language is not. These do not work, and fail to compile rather than failing at run time:
| Not available | Write instead |
|---|---|
f-strings — f"{a} owes {b}" | "%s owes %d" % (a, b), or "Hi {{ a }}, {{ b }} due" |
while | a comprehension, or for in a value slot |
try / except | dict.get(key, default), or guard with if |
class | a dict |
import | the bridged packages below |
generator expressions — sum(i.qty for i in items) | a list comprehension — sum([i["qty"] for i in items]) |
the walrus := | a local, in a value slot |
Everything else in this document is probed and works. If you need what the
left column describes, that is what RunPython is for — see the last section.
Syntax shapes
WriteLine(response) # bare variable - passes reference
WriteLine("{{ response.body }}") # property access
WriteLine("{{ [i for i in items if i['active']] }}") # full expression
WriteLine("Hi {{ user['name'] }}, {{ count }} items") # embedded in a string
Rule of thumb: if you're accessing a field, calling a function, doing
arithmetic, or chaining - wrap it in {{ }}. Bare names without braces only
work for single-variable references.
Expression or function body, depending on the field
Condition slots - IfCondition.condition, While.condition,
ForEachLoop.collection - and the numeric slots take a single expression.
A statement there fails to compile rather than evaluating to nothing, which a
boolean slot would otherwise go on to read as false.
String, JSON and object slots also accept a function body, so an awkward transform can use a local and an early return:
{{ shipped = [i["sku"] for i in items if i["qty"] > 0]
return ", ".join(shipped) }}
An expression may span lines, which matters more here than in the other two flavours: a newline is how Python separates statements. A semicolon works too, and is what a one-line body wants:
{{ n = len(items); return "empty" if n == 0 else "has %d" % n }}
Writing a multi-line expression in the DSL
The DSL is Python source, so a "..." in it cannot contain a real newline -
that is Python's rule about string literals, not this platform's. Either
triple-quote the argument:
WriteLine("""shipped: {{ out = [i["sku"] for i in items]
return ", ".join(out) }}""")
or keep it on one line and write the newline escaped, as a backslash followed
by n. Both read back to the same stored value. Editing the same field in the
designer needs neither: type the newline and it is stored as one.
Objects and lists
A workflow object is a dict, so it is subscripted rather than dotted:
{{ order["customer"]["name"] }} # not order.customer.name
{{ order.get("discount", 0) }} # a default instead of a KeyError
{{ "sku" in item }} # membership
{{ sorted(order.keys()) }}
Lists index, slice and iterate as you expect, including negative indices:
{{ codes[0] }} {{ codes[-1] }} {{ codes[0:2] }}
{{ len(items) }} {{ codes + ["QQ-9"] }}
Comprehensions do the work
This is where most expressions live:
{{ [i["sku"] for i in items if i["qty"] > 0] }} # filter and project
{{ [c.lower() for c in codes] }} # transform
{{ sum([i["qty"] for i in items]) }} # total
{{ len([i for i in items if i["active"]]) }} # count
{{ any([i["active"] for i in items]) }} # any / all
{{ sorted(items, key=lambda i: i["qty"]) }} # sort by a field
{{ [i for i, c in enumerate(codes) if c == "AA-2"] }} # positions
Strings
{{ name.upper() }} {{ name.lower() }} {{ name.strip() }}
{{ name.split(",") }} {{ ", ".join(codes) }} {{ name.replace("a", "b") }}
{{ name.startswith("AC") }} {{ "acme" in name }} {{ name[0:4] }}
{{ "%s owes %d" % (customer, total) }}
Numbers
{{ total * 2 + 1 }} {{ 7 // 2 }} {{ 7 % 2 }} {{ float(7) / 2 }}
{{ int("42") }} {{ str(count) }} {{ min(a, b) }} {{ max(a, b) }}
A division that comes out whole reads back as an integer, so a count that
passed through one does not land in a filename or a URL as 3.0.
Conditionals
{{ "yes" if shipped else "no" }}
{{ total > 100 and customer["active"] }}
{{ not archived }}
{{ order.get("note") == None }}
The bridged function packages
The same five the other two flavours register, spelled identically, so an expression ports between the three languages:
{{ strings.ToUpper(name) }} {{ strings.Contains(name, "acme") }}
{{ math.Round(total) }} {{ math.Max(1.0, 2.0) }}
{{ path.GetFileName(filePath) }} {{ path.GetExtension(filePath) }}
{{ fuzzy.Similarity(a, b) }} {{ collections.Unique(codes) }}
They take positional arguments only — they are Go functions, and Go has no keyword arguments.
What an expression cannot do
An expression is pure. It cannot change a workflow variable: the bindings are
frozen, so codes.append("x") fails rather than silently rewriting codes
for every activity that runs after it. Make a copy if you need one:
{{ out = list(codes); out.append("QQ-9"); return out }}
It also has no ambient authority at all — no open, no import, no network,
no clock. That is a property of the interpreter rather than a rule this
platform enforces, so there is no way around it and no way to get it wrong.
When RunPython is still the right choice
Use RunPython when you need:
- Real Python: f-strings,
try/except, classes, generators,while - External library calls (pandas, numpy, pillow, pymupdf, etc.)
- Multi-page / spatial PDF text extraction
- Stateful loops that mutate across activities (expressions are pure)
- Anything you'd describe as "a small program" rather than "a transform"
Everything else - filtering, dedup, sort, group, set ops, string manipulation - is an expression.