Variables
Variables are named values attached to a running process instance. They are how data flows in at start, between activities, across sub-processes, and out at the end. Every FEEL expression in your model — gateway conditions, I/O mappings, completion conditions, dueDate expressions, error catch matches — reads from a variable scope.
This page covers what variables are, how scoping works, what types are supported, how data crosses scope boundaries, and the most common gotchas.
The scope chain
Each running process instance owns a tree of scopes. A scope is a named bag of variables; every flow node executes against one. The tree is built dynamically as the process runs — sub-processes, multi-instance loops, ad-hoc sub-processes, event sub-processes and boundary handlers all create their own scopes. A scope is destroyed when its owning element completes or is cancelled.
| Scope | Created by |
|---|---|
| Root | Process start. Holds the process's initial variables and is what the engine returns when the instance completes. |
| Sub-process | Each embedded, event, or ad-hoc sub-process instance. |
| Call activity child | The called process runs in its own root scope inside its own workflow — it is not a child scope of the caller in the same chain. |
| MI wrapper | Any activity with multiInstanceLoopCharacteristics. Holds the MI counters. |
| MI instance | One per inner iteration. Holds loopCounter and inputElement. |
| Boundary handler | Created when a boundary event fires. Receives event payload (e.g. errorCode and any thrown error variables). |
| Event sub-process instance | Each time an event sub-process is triggered. |
When a FEEL expression references x, the engine walks innermost to outermost and returns the first match. An inner scope therefore shadows the same name in an outer one — the parent value is unchanged, just hidden.
Process root { customerId: 42, region: "EU" }
└─ Sub-process { region: "DE" } ← shadows root.region
└─ Service task reads region → "DE"
reads customerId → 42 (from root)
Writing inside the sub-process never reaches up. To make a value cross outward you have to opt in — see How variables propagate outward.
Supported types
Variables are persisted in workflow state as JSON, so any FEEL value that is JSON-representable is supported.
| FEEL type | JSON form | Notes |
|---|---|---|
| number | number | IEEE-754 double precision |
| string | string | UTF-8 |
| boolean | true / false | |
| null | null | |
| list | array | Mixed element types allowed |
| context | object | Nest freely; access nested fields with . in FEEL (order.shipping.address) |
| date / time / dateTime / duration | ISO-8601 string | Parsed by FEEL on read; serialised back to ISO-8601 |
Anything that fails JSON marshal — channels, functions, custom struct types without JSON tags, raw byte streams, NaN / ±Inf — is rejected. Variables are also bounded by Temporal's per-event size limit (~10 MB by default); store bulk data (files, large JSON blobs, attachments) externally and put a reference (URL, ID) in the variable.
How variables enter a scope
| Source | Lands in | Notes |
|---|---|---|
| Process start payload | Root | Passed via REST POST /processes/{id}/start body |
quantum:ioMapping <input> on an activity / sub-process | The activity's own scope | Source FEEL is evaluated against the outer scope; result stored under target |
| Service task / external worker / user task / business-rule task result | The activity's scope | Then propagated outward per the activity's I/O mapping |
Multi-instance inputElement | Each MI instance scope | Bound to the per-iteration item from inputCollection |
| Multi-instance counters | MI wrapper / instance scope | Engine-injected — see Reserved names |
| Message subscription payload | Subscribing event's scope | Via the event's output mapping |
| Signal payload | Subscribing event's scope | Via the event's output mapping |
| Error / escalation thrown into a boundary | Boundary handler scope | Includes errorCode for error events, plus any variables attached to the thrown error |
REST: PATCH /processes/{id}/variables | Root scope of the named instance | Use sparingly; prefer modelled flow |
How variables propagate outward
Outward propagation is always opt-in. There is no implicit "publish to parent" — a child scope writing x = 5 leaves the parent's x untouched.
| Mechanism | Effect |
|---|---|
quantum:ioMapping <output> on an activity / sub-process | Evaluates source FEEL in the inner scope, writes the result into the parent under target |
MI outputCollection + outputElement | Each instance's outputElement is appended to a list; the resulting list is written to the parent under outputCollection. MI counters and loopCounter are not propagated. |
Call activity propagateAllChildVariables="true" | All variables from the called child's root are merged into the parent on return. The reserved quantum namespace is excluded so the child's process metadata can't overwrite the parent's. With false (the default), only the call activity's output mapping crosses |
| Process completion | Root-scope variables are returned to the caller as the workflow result |
A few important consequences:
- Sub-process variables don't leak. A non-multi-instance sub-process only exposes what its own
<output>mappings publish. Anything else dies with the scope. - Multi-instance internals don't leak. After the MI activity completes only
outputCollectionis in the parent. If you need post-MI counts, derive them from the original input or output collection (see Multi-instance loop). - Parallel branches that both write the same parent variable are last-write-wins. Joins do not merge concurrently; the order is unspecified. Have only one branch own each output, or use distinct names per branch and combine them downstream.
Reserved (engine-injected) names
The engine writes the following names itself. Don't use them for your own variables — your value will be silently overwritten as soon as the engine populates the slot.
| Name | Where | When |
|---|---|---|
loopCounter | MI instance scope; standard-loop activity scope | 1-based iteration index, set per iteration |
numberOfInstances | MI wrapper | Total instances created |
numberOfActiveInstances | MI wrapper | Currently executing |
numberOfCompletedInstances | MI wrapper | Completed normally |
numberOfTerminatedInstances | MI wrapper | Cancelled or interrupted |
errorCode | Error boundary handler scope | When an error event fires |
quantum | Every scope (read-only) | Map exposing engine metadata to FEEL: quantum.processId, quantum.processVersion. Injected at evaluation time so it never persists into stored variables, and skipped on cross-workflow merges. The same values are also returned as typed processId / processVersion fields on the instance state API |
The engine does not validate against accidental collisions. If you set numberOfInstances from your own logic the value will be visible until the next MI counter update overwrites it. Treat the list above as off-limits.
Inspecting variables at runtime
For debugging or operational tooling you can read live variable state via the public API:
| Operation | Engine | REST |
|---|---|---|
| Read root-scope variables | Engine.GetProcessVariables(ctx, workflowID) | GET /processes/{id}/variables |
| Merge variables into root | Engine.UpdateProcessVariables(ctx, workflowID, vars) | PATCH /processes/{id}/variables |
In production code paths, prefer flowing values through I/O mappings rather than reading or mutating them out-of-band — the modelled flow is what makes the process behaviour predictable and replayable.
Limitations
These are deliberate constraints of the model — read them before designing data flow:
- No "local" qualifier. Every variable lives on a scope. To keep something local, write it inside a sub-process and don't include it in that sub-process's output mapping.
- No nested-key variable names. A name is a flat string. Use a context value (
order = {id: 1, total: 99}) and access fields with.in FEEL — don't try to define a variable namedorder.id. - No automatic propagation across scopes. Children writing to existing parent variables is not how it works; everything outward is via output mappings or
propagateAllChildVariables. - Concurrent writes from parallel branches are last-write-wins. Don't have two branches mutate the same key.
- JSON-marshable types only. Wrap or reference unsupported types externally.
- Variable size is bounded by Temporal event size (~10 MB by default). Don't store payloads, files, or full datasets in variables.
Example
A small order process showing the common patterns in one place:
<!-- 1. Process starts with { order: { ... } } in root scope -->
<!-- 2. Service task reads from root, writes a derived value back via output mapping -->
<bpmn:serviceTask id="price" name="Price order">
<bpmn:extensionElements>
<quantum:taskDefinition type="pricer" />
<quantum:ioMapping>
<quantum:input source="=order.items" target="items" />
<quantum:output source="=total" target="orderTotal" />
</quantum:ioMapping>
</bpmn:extensionElements>
</bpmn:serviceTask>
<!-- 3. Multi-instance: process each item; only `processedItems` leaks out -->
<bpmn:serviceTask id="ship" name="Ship items">
<bpmn:extensionElements>
<quantum:taskDefinition type="shipper" />
</bpmn:extensionElements>
<bpmn:multiInstanceLoopCharacteristics>
<bpmn:extensionElements>
<quantum:loopCharacteristics
inputCollection="=order.items"
inputElement="item"
outputCollection="processedItems"
outputElement="=trackingId" />
</bpmn:extensionElements>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:serviceTask>
<!-- 4. Gateway condition reads variables produced upstream -->
<bpmn:sequenceFlow sourceRef="gateway" targetRef="notify">
<bpmn:conditionExpression>=length(processedItems) = length(order.items)</bpmn:conditionExpression>
</bpmn:sequenceFlow>
What ends up in the final root scope: order (from the start payload, untouched), orderTotal (from the pricer's output mapping), processedItems (the MI output collection). The MI counters and loopCounter from inside the shipping task are gone.