Skip to main content

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.

ScopeCreated by
RootProcess start. Holds the process's initial variables and is what the engine returns when the instance completes.
Sub-processEach embedded, event, or ad-hoc sub-process instance.
Call activity childThe 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 wrapperAny activity with multiInstanceLoopCharacteristics. Holds the MI counters.
MI instanceOne per inner iteration. Holds loopCounter and inputElement.
Boundary handlerCreated when a boundary event fires. Receives event payload (e.g. errorCode and any thrown error variables).
Event sub-process instanceEach 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 typeJSON formNotes
numbernumberCarried 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
stringstringUTF-8
booleantrue / false
nullnull
listarrayMixed element types allowed
contextobjectNest freely, access nested fields with . in FEEL (order.shipping.address)
date / time / dateTime / durationISO-8601 stringParsed 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​

SourceLands inNotes
Process start payloadRootPassed via REST POST /processes/{id}/start body
quantum:ioMapping <input> on an activity / sub-processThe activity's own scopeSource FEEL is evaluated against the outer scope, result stored under target
Service task / external worker / user task / business-rule task resultThe activity's scopeThen propagated outward per the activity's I/O mapping
Multi-instance inputElementEach MI instance scopeBound to the per-iteration item from inputCollection
Multi-instance countersMI wrapper / instance scopeEngine-injected - see Reserved names
Message subscription payloadSubscribing event's scopeVia the event's output mapping
Signal payloadSubscribing event's scopeVia the event's output mapping
Error / escalation thrown into a boundaryBoundary handler scopeIncludes errorCode for error events, plus any variables attached to the thrown error
REST: PATCH /processes/{id}/variablesRoot scope of the named instanceUse 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.

MechanismEffect
quantum:ioMapping <output> on an activity / sub-processEvaluates 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 + outputElementEach 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 completionRoot-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 source is 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 outputCollection is 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.

NameWhereWhen
loopCounterMI instance scope, standard-loop activity scope1-based iteration index, set per iteration
numberOfInstancesMI wrapperTotal instances created
numberOfActiveInstancesMI wrapperCurrently executing
numberOfCompletedInstancesMI wrapperCompleted normally
numberOfTerminatedInstancesMI wrapperCancelled or interrupted
errorCodeError boundary handler scopeWhen an error event fires
quantumEvery 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:

OperationEngineREST
Read root-scope variablesEngine.GetProcessVariables(ctx, workflowID)GET /processes/{id}/variables
Merge variables into rootEngine.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.

Captured variables​

The engine keeps a copy of each instance's root variables outside the workflow, so an instance can be read without querying it. The copy is what Finding instances by variable searches.

A capture is written when:

  • the instance starts, from its start variables. This covers starts from the API, a message, a signal, a timer and a call activity, so an instance is searchable from birth even if it is paused before it ever runs,
  • the instance parks with its root variables changed since the last capture,
  • the instance reaches a terminal state, from its final variables.

An instance started by a call activity is seeded from its mapped inputs before the child workflow exists, so a child that is paused or cancelled before it ever runs is searchable too. A call activity with input mappings seeds the mapped values, and one without input mappings seeds the calling scope's variables unchanged.

Three properties follow from that, and they apply to every reader of the captured copy.

Root scope only. A variable that lives on a sub-process, an event sub-process or a multi-instance body is not in the capture. A search expression cannot see it. To make one searchable, carry it out through the sub-process's output mapping so it lands on the root scope.

As of the last park. A capture happens when the instance parks, which is when it reaches a wait state such as a service task, user task, timer or message catch. A value written by a node that is still executing appears after the instance parks. Every row that displays captured variables carries the capture timestamp, so the age of the values is visible.

Changed root variables only. An instance that writes no root variables after it starts keeps its start-time capture for its whole life. A loop that writes the same variable on 64 iterations and then parks once produces one capture.

The captured copy is deleted with the instance by the retention pass, see History retention and archival.

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 named order.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.