English
English
Appearance
English
English
Appearance
The Loop node is a control node in the workflow system used for batch processing data. It retrieves an array of data from a data source and then executes a set of child nodes (i.e., the loop body) for each item in the array in turn, enabling automation scenarios such as batch notifications, batch archiving, and batch external API calls.
On the workflow canvas, the Loop node is displayed as a container. The child nodes inside it form the node chain that is executed during each iteration.
The Loop node is a container-type node. Its core structure is as follows:
loop_body_nodes): the nested child node chain inside the Loop node, executed sequentially during each iteration.The two have a parent-child relationship. The Loop node holds the list of loop body child nodes through the config.loop_body_nodes field. Child nodes are sorted via the order field and form an execution chain through the next_nodes field.
The following node types can be added inside a loop body (the condition branch node is not included):
| Node Type | Name | Description |
|---|---|---|
webhook | Webhook | Call an external HTTP endpoint |
send_email | Send Email | Send an email to specified recipients |
notify | Internal Message | Send an in-app message to specified people |
update_record | Update Record | Update records in a data table |
create_record | Create Record | Create new records in a target data table |
find_records | Find Records | Query records in a data table |
loop | Loop | Nested loop (supports up to 3 levels of nesting) |
Note: The
condition(conditional branch) node is not allowed inside a loop body. If you try to add one, the system will reject it and return a validation error when the workflow is created or updated.
| Constraint | Limit | Description |
|---|---|---|
| Maximum loop nodes per workflow | 5 | Includes all loop nodes inside nested loop bodies |
| Maximum nesting depth | 3 | Loop nodes can be nested up to 3 levels |
| Loop execution mode | sequential | Only sequential execution is currently supported; parallel execution is not supported |
Loop body nodes can use template variables in their configuration to reference loop context data. Template variables use double curly brace syntax:
{{variable_path}}For example:
{{loop.current_data}}
{{loop.current_data.field_abc123}}
{{loop.round}}Template variable paths use dot separators. The system resolves them layer by layer through the _resolve_path() method:
..dict, use .get(part) to retrieve the value.list/tuple and the path part is numeric, retrieve by index.None, the entire expression returns None.Examples:
{{loop.current_data.field_id}} → context["loop"]["current_data"]["field_id"]
{{record.name}} → context["record"]["name"]
{{trigger.record.status}} → context["trigger"]["record"]["status"]The rendering rules of the render_template() method are as follows:
{{...}} expression (full match), the resolved raw value is returned directly (type preserved).{{...}} expressions mixed with plain text, they are replaced one by one and concatenated as strings (None becomes an empty string).The Loop node determines the data source to iterate through the data_source configuration item. The following four types are supported:
Iterate over all records returned by a Find Records node. The current_data of each iteration is the complete record dictionary (containing all field values).
{
"type": "find_records_all",
"node_id": "<find_records node ID>"
}records array in the output of the specified find_records node.current_data type: dict (complete record; keys are field IDs and values are field values).{{loop.current_data.field_id}}).Extract the value of a specified field from the Find Records results and flatten it. Applicable to field types such as member, group, attachment, and link; automatically deduplicates list items that have an id attribute.
{
"type": "find_records_column",
"node_id": "<find_records node ID>",
"field_id": "<field ID>"
}find_records node result list.id are automatically deduplicated.current_data type: depends on the field type (e.g., a member field is a dictionary {id, name, email}).Retrieve the value of a specified field from the trigger record. If the field value is a list, it is returned directly; otherwise it is wrapped as a single-element list.
{
"type": "trigger_field",
"field_id": "<field ID>"
}record object in the trigger event.[value] single-element list.Read a JSON array from the return result of a Webhook node.
{
"type": "webhook_array",
"node_id": "<webhook node ID>"
}{node_id}_result.json.array path in the context.current_data type: depends on the data structure returned by the Webhook.The complete execution flow of the Loop node is as follows:
1. Loop node starts execution
│
2. Parse data_source and obtain data_array
│
3. If data_array is empty
├─ empty_result_action = "skip" → log and return {next_nodes}, continue main chain
└─ empty_result_action = "error" → raise an exception
│
4. Compute total = min(len(data_array), max_iterations)
│
5. Create execution log (status: running)
│
6. Iterate index = 0, 1, ..., total-1 in order
│
├─ a. Save the outer loop_context (used for nested loop recovery)
├─ b. Set the current loop_context = {
│ current_data: data_array[index],
│ index: index,
│ round: index + 1,
│ total: total
│ }, write to instance.context
├─ c. Execute the loop body node chain
│ └─ If a node raises an exception:
│ ├─ error_handling = "skip" → log failure and continue to next iteration
│ └─ error_handling = "terminate" → mark early termination and raise exception upward
└─ d. Restore the outer loop_context (guaranteed by finally)
│
7. Update execution log (status: success/error, record statistics)
│
8. Return {next_nodes: node.next_nodes} and continue the main chainThe output result after execution contains the following statistical fields:
| Field | Type | Description |
|---|---|---|
total_iterations | int | Total number of iterations actually executed |
success_count | int | Number of successful iterations |
failure_count | int | Number of failed iterations |
early_terminated | bool | Whether the loop terminated early |
skipped_reason | string | Reason for skipping (only present when data is empty; value is data_array_empty) |
The complete configuration parameters of the Loop node are as follows:
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
data_source.type | string | "find_records_all" | find_records_all, find_records_column, trigger_field, webhook_array | Data source type |
data_source.node_id | string | - | UUID of the referenced node | ID of the upstream node referenced by the data source (required for find_records_all / find_records_column / webhook_array) |
data_source.field_id | string | - | UUID of the field | Target field ID to extract (required for find_records_column) |
data_source.trigger_field_id | string | - | UUID of the field | Trigger record field ID (used for trigger_field; compatible with field_id) |
max_iterations | number | 100 | 1 - 1000 | Maximum number of iterations per execution |
error_handling | string | "skip" | skip, terminate | How to handle errors during loop body execution |
empty_result_action | string | "skip" | skip, error | How to handle an empty data array |
loop_body_nodes | array | [] | - | List of loop body child nodes (cannot be empty) |
loop_mode | string | "sequential" | sequential | Loop execution mode (only sequential is currently supported) |
skip (skip current and continue): When an iteration of the loop body raises an exception, skip the remaining nodes of that iteration, record a failure count, and continue with the next iteration. After the loop ends, it returns to the main chain normally.terminate (terminate flow): When an iteration raises an exception, mark early_terminated = True, raise the exception upward, and terminate the entire workflow instance.skip (skip loop): When the data array is empty, record a diagnostic log (containing skipped_reason: "data_array_empty"), return {next_nodes}, and continue the main chain.error (report error): When the data array is empty, raise ValueError('Loop data source is empty') and terminate the workflow instance.A Loop node can be nested inside another loop body to form a multi-level loop structure. System constraints are as follows:
Loop nesting depth cannot exceed 3 levels.Nested loop context isolation mechanism:
loop_context to a temporary variable.loop_context and execute the inner loop body.loop_context in the finally block.The total number of Loop nodes in a single workflow (including those inside nested loop bodies) cannot exceed 5. When the limit is exceeded, creating or updating the workflow will return the error message: A single workflow can contain at most 5 loop nodes.
During each iteration, the system sets the loop_context field in the workflow instance context (instance.context) with the following structure:
{
"current_data": "<data item of the current iteration>",
"index": 0,
"round": 1,
"total": 10
}| Field | Type | Description |
|---|---|---|
current_data | Any | Data item of the current iteration (type depends on the data source) |
index | int | Index of the current iteration (starting from 0) |
round | int | Round number of the current iteration (starting from 1, i.e., index + 1) |
total | int | Total number of iterations for this loop |
Scenario: Find all tasks with status "In Progress", extract the member list from the owner field, and send email notifications one by one.
Flow:
Trigger → Find Records (in-progress tasks) → Loop (find_records_column, owner field) → Send EmailKey points:
find_records_column as the data source and specify a member-type field.find_records_column automatically deduplicates member lists to prevent the same person from receiving multiple emails.{{loop.current_data.name}} and {{loop.current_data.email}} in the email template to reference recipient information.Scenario: Find all requirements with status "Launched" from the requirements progress table and copy them to the archive table one by one.
Flow:
Trigger → Find Records (launched requirements) → Loop (find_records_all) → Create RecordKey points:
find_records_all as the data source; the current_data of each iteration is a complete record dictionary.{{loop.current_data.field_id}} in the field mapping of the Create Record node to reference source record values field by field.Scenario: The outer loop iterates over active employees, and the inner loop iterates over the tasks assigned to each employee. Create a detail record for each employee-task combination.
Flow:
Trigger
→ Find Records (active employees)
→ Outer Loop (find_records_all, employees)
→ Find Records (tasks of this employee)
→ Inner Loop (find_records_all, tasks)
→ Create RecordKey points:
{{loop.current_data}}, and the outer context is automatically restored after the inner loop ends.Scenario: Find records that meet the conditions and call an external API one by one to synchronize data.
Flow:
Trigger → Find Records → Loop (find_records_all) → WebhookKey points:
{{loop.current_data}} and {{loop.round}}.loop_context into the rendering context.When a new record is added to the task table, find all tasks with status "In Progress", extract the members from the owner field of these tasks, and send an email reminder to each unique member.
{
"name": "任务提醒工作流",
"nodes_config": [
{
"node_type": "trigger",
"name": "记录创建触发",
"config": {},
"order": 0
},
{
"node_type": "find_records",
"name": "查找进行中任务",
"config": {
"target_table_id": "tbl_tasks",
"conditions": [
{
"field_id": "fld_status",
"operator": "equals",
"value": "进行中"
}
],
"conjunction": "and",
"result_variable": "records"
},
"order": 1
},
{
"node_type": "loop",
"name": "按负责人循环",
"config": {
"loop_mode": "sequential",
"data_source": {
"type": "find_records_column",
"node_id": "<find_records 节点 ID>",
"field_id": "fld_member"
},
"max_iterations": 100,
"error_handling": "skip",
"empty_result_action": "skip",
"loop_body_nodes": [
{
"id": "body-send-email-1",
"node_type": "send_email",
"name": "发送任务提醒",
"config": {
"to_email": "{{loop.current_data.email}}",
"to_name": "{{loop.current_data.name}}",
"template_key": "task_reminder",
"template_data": {
"user_name": "{{loop.current_data.name}}",
"task_count": "{{loop.total}}"
}
},
"order": 0,
"next_nodes": []
}
]
},
"order": 2,
"next_nodes": []
}
]
}find_records node executes, querying records with status "In Progress", and stores the result in instance.context["records"].loop node executes: find_records_column, extracting the value of the fld_member field from the record list.id field), yielding 3 unique members.data_array = [{id:"u1", name:"张三", email:"zhang@example.com"}, {id:"u2", name:"李四", email:"li@example.com"}, {id:"u3", name:"王五", email:"wang@example.com"}].loop_context and executing the send_email node each time.{next_nodes: []}, and the workflow completes.Execution log record:
{
"total_iterations": 3,
"success_count": 3,
"failure_count": 0,
"early_terminated": false
}Email sending result: Zhang San, Li Si, and Wang Wu each receive 1 task reminder email.
Find all requirement records with status "Launched" from the requirements progress table, copy them to the archive table one by one, and map fields such as requirement name, owner, and source.
{
"name": "需求归档工作流",
"nodes_config": [
{
"node_type": "trigger",
"name": "触发",
"config": {},
"order": 0
},
{
"node_type": "find_records",
"name": "查找已上线需求",
"config": {
"target_table_id": "tbl_progress",
"conditions": [
{
"field_id": "fld_req_status",
"operator": "equals",
"value": "已上线"
}
],
"conjunction": "and",
"result_variable": "records"
},
"order": 1
},
{
"node_type": "loop",
"name": "逐条归档",
"config": {
"loop_mode": "sequential",
"data_source": {
"type": "find_records_all",
"node_id": "<find_records 节点 ID>"
},
"max_iterations": 100,
"error_handling": "skip",
"empty_result_action": "skip",
"loop_body_nodes": [
{
"id": "body-create-1",
"node_type": "create_record",
"name": "创建归档记录",
"config": {
"target_table_id": "tbl_archive",
"field_mappings": [
{
"target_field_id": "fld_arc_name",
"source_field_id": "",
"value_template": "{{loop.current_data.fld_req_name}}"
},
{
"target_field_id": "fld_arc_owner",
"source_field_id": "",
"value_template": "{{loop.current_data.fld_req_owner}}"
},
{
"target_field_id": "fld_arc_source",
"source_field_id": "",
"value_template": "需求进度表"
}
]
},
"order": 0,
"next_nodes": []
}
]
},
"order": 2,
"next_nodes": []
}
]
}find_records node executes, querying 5 requirement records with status "Launched".loop node executes: find_records_all, using the record array directly.data_array contains 5 complete record dictionaries.loop_context = {current_data: records[0], index: 0, round: 1, total: 5}. {{loop.current_data.fld_req_name}}, owner from {{loop.current_data.fld_req_owner}}, source fixed to "需求进度表".Execution log record:
{
"total_iterations": 5,
"success_count": 5,
"failure_count": 0,
"early_terminated": false
}5 new records are added to the archive table, with field values corresponding to the requirements in the progress table one by one.
Find records waiting to be synchronized and call an external API one by one to push data to a third-party system. The Webhook request body needs to include the current record data and loop round information.
{
"name": "数据同步工作流",
"nodes_config": [
{
"node_type": "trigger",
"name": "触发",
"config": {},
"order": 0
},
{
"node_type": "find_records",
"name": "查找待同步记录",
"config": {
"target_table_id": "tbl_orders",
"conditions": [
{
"field_id": "fld_sync_status",
"operator": "equals",
"value": "待同步"
}
],
"conjunction": "and",
"result_variable": "records"
},
"order": 1
},
{
"node_type": "loop",
"name": "逐条推送",
"config": {
"loop_mode": "sequential",
"data_source": {
"type": "find_records_all",
"node_id": "<find_records 节点 ID>"
},
"max_iterations": 50,
"error_handling": "skip",
"empty_result_action": "skip",
"loop_body_nodes": [
{
"id": "body-webhook-1",
"node_type": "webhook",
"name": "推送至外部系统",
"config": {
"inline_webhook": {
"url": "https://api.example.com/orders/sync",
"method": "POST",
"headers": [
{"key": "Content-Type", "value": "application/json"},
{"key": "X-Auth-Token", "value": "secret-token"}
],
"body_template": "{\"order_id\": \"{{loop.current_data.fld_order_id}}\", \"amount\": {{loop.current_data.fld_amount}}, \"sync_round\": {{loop.round}}, \"sync_index\": {{loop.index}}, \"total_batches\": {{loop.total}}}"
}
},
"order": 0,
"next_nodes": []
}
]
},
"order": 2,
"next_nodes": []
}
]
}find_records node executes and finds 3 records waiting to be synchronized.loop node executes: find_records_all; data_array contains 3 records.loop_context into the rendering context to ensure template variables such as {{loop.current_data}} are available.index=0, round=1): send a POST request whose body contains the current record data and round information.index=1, round=2): process the next record.index=2, round=3): process the last record.Round 1 Webhook request body (after template rendering):
{
"order_id": "ORD-2026-001",
"amount": 1500.00,
"sync_round": 1,
"sync_index": 0,
"total_batches": 3
}Execution log record:
{
"total_iterations": 3,
"success_count": 3,
"failure_count": 0,
"early_terminated": false
}Symptom: After the workflow reaches the Loop node, the loop body child nodes are not executed, and total_iterations in the log is 0.
Common cause: The data source type is misconfigured. For example, you expect to iterate over all records but use the find_records_column type, which only extracts the value of a specified field. If the extracted field value is empty (no data or wrong field ID), data_array will be empty.
Solution:
data_source.type to find_records_all.find_records_column, confirm that field_id is correct and the corresponding field actually has values.skipped_reason field in the diagnostic log to confirm whether it is data_array_empty.Symptom: The Loop node log contains skipped_reason: "data_array_empty" in output_result.
Common causes:
find_records node did not find any records.data_source.node_id does not exist or is incorrect.data_source.field_id does not exist in the record or its value is empty.trigger_field type is empty.Solution:
find_records node are too strict.node_id and field_id.empty_result_action to "skip" to skip the loop and continue the main chain.Symptom: In the Webhook request body or Create Record field values, {{loop.current_data.xxx}} is not replaced and the original template string remains.
Common causes:
loop_context does not exist.loop_context is not correctly passed into the rendering context.Solution:
loop_body_nodes of a Loop node.LoopVarInserter component). If it is not displayed, the current node is not inside a loop body.loop_context is included in instance.context.Symptom: In a nested loop, after the inner loop executes, the outer loop's template variables resolve to inner data.
Common cause: This issue has been resolved in the current implementation through the context recovery mechanism in the finally block.
Mechanism description:
The _execute_loop_body() method saves the outer loop_context before entering the loop body and restores it in the finally block:
context = instance.context or {}
outer_loop_context = context.get('loop_context') # Save outer context
context['loop_context'] = { ... } # Set inner context
try:
# Execute loop body
finally:
context['loop_context'] = outer_loop_context # Restore outer contextIf context overwriting still occurs, it may be because an exception was swallowed causing the finally block not to execute correctly. Check the exception handling chain.
Symptom: The actual data volume is larger than max_iterations, and some data is not processed.
Common cause: The default value of max_iterations is 100. If the array returned by the data source is longer than this value, the loop stops after reaching the upper limit.
Solution:
max_iterations according to the actual data volume (range 1-1000).max_iterations to the [1, 1000] range; values outside this range are automatically truncated.Symptom: A validation error is returned when creating or updating the workflow: Conditional branch nodes are not supported inside a loop body.
Common cause: A node of type condition was added to loop_body_nodes.
Solution:
condition node from the loop body.| Variable | Type | Description | Applicable Data Source |
|---|---|---|---|
{{loop.current_data}} | Any | Complete data item of the current iteration | All types |
{{loop.current_data.field_id}} | Any | Specified field value of the current record (field drilling) | Only find_records_all |
{{loop.index}} | int | Current iteration index (starting from 0) | All types |
{{loop.round}} | int | Current iteration round (starting from 1) | All types |
{{loop.total}} | int | Total number of iterations for this loop | All types |
{{record}} | dict | Complete field values of the trigger record | All types |
{{record.field_id}} | Any | Specified field value of the trigger record | All types |
{{event}} | dict | Trigger event data | All types |
{{workflow}} | dict | Current workflow information | All types |
{{instance}} | dict | Current workflow instance information | All types |
Field drilling note:
{{loop.current_data.field_id}}is only available when the data source isfind_records_all, because in this casecurrent_datais a complete record dictionary containing all fields. For other data source types (find_records_column,trigger_field,webhook_array),current_datais a single value and does not support drilling. The frontendLoopVarInsertercomponent automatically controls the display of field drilling options based on the data source type.
Both loop bodies and template variables (such as {{loop.current_data.<field_id>}} and {{node_outputs.<node_id>.result}}) use node IDs or field IDs as reference keys. The configuration UI already displays these IDs in multiple places and supports click-to-copy, so there is no need to capture them via network inspection or by inspecting the DOM.
node_xxx) next to the node type label, rendered in small monospace text.Node Name (Node ID), so you can directly copy the ID of an upstream node such as find_records or webhook and paste it into data_source.node_id.fld_xxx) to the right of the field name, rendered in small monospace text.{{loop.current_data.<field_id>}}.Field Name (Field ID), making it easy to copy directly.Below the template input boxes of loop body child nodes (Update Record, Create Record, Send Email, Webhook, etc.), the UI lists the reference syntax available in the current context (such as {{record.<field_id>}}, {{node_outputs.<node_id>.result}}, {{loop.current_data.<field_id>}}). Click any entry to copy the full syntax, then paste it into the template and replace the placeholder with a real ID.