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
numbernumberIEEE-754 double precision
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 in the inner scope, 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

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.

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.

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.