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 | Carried as an exact decimal end to end, not a float. 1.10 stays 1.10 rather than rounding to a binary double, so money and quantities survive round-trips through mappings and workers |
| 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 against the element's scope chain plus its input-mapped names (plus the worker's returned variables, on a task), 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 |
Using both mapping halves on one element
Inputs and outputs are independent, and declaring inputs does not hide the rest of the scope from your output expressions:
- Inputs gate what the element hands out. A worker, user task or DMN decision receives the input-mapped names and only those. An input target with the same name as an outer variable shadows it inside the element, and the outer value is left untouched.
- Outputs still see everything. An output
sourceis evaluated against the element's scope chain with the input targets layered on top, so it can read either an input target or an ordinary scope variable. On a collision the input target wins.
<quantum:ioMapping>
<quantum:input source="=order.id" target="orderId" /> <!-- the worker sees only orderId -->
<quantum:output source="=region" target="shipRegion" /> <!-- still reads region from scope -->
</quantum:ioMapping>
Throwing events are the exception to the output half - see Throw payloads.
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.
- A cancelled scope fills gaps but never overwrites. When a branch or handler scope is torn down mid-flight (an interrupting boundary fired, a terminate end event was reached), its variables are merged outward only under names the surrounding scope has never seen. Names already visible there keep their current value. Its work was abandoned while the rest of the instance kept running, so restoring its stale snapshot over a value something else has since written would undo live work. A scope that completes normally still overwrites as usual.
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.