Skip to main content

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 availableWrite instead
f-strings — f"{a} owes {b}""%s owes %d" % (a, b), or "Hi {{ a }}, {{ b }} due"
whilea comprehension, or for in a value slot
try / exceptdict.get(key, default), or guard with if
classa dict
importthe 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.