Variable Resolution and Template Substitution#
Variable resolution in Dify workflows revolves around the VariablePool (from the external graphon package, v0.6.0), which acts as the single shared store for all runtime variable values. Every node reads and writes through this pool, and references between nodes use the {{#node_id.variable_name#}} template syntax.
Key source files:
api/core/workflow/variable_pool_initializer.pyβadd_variables_to_pool()andadd_node_inputs_to_pool()helpersapi/core/workflow/system_variables.pyβSystemVariableKeyenum,build_bootstrap_variables(), andbuild_system_variables()api/core/workflow/node_factory.pyβ wires each node type to its dependencies, including the variable poolgraphon.runtime.VariablePool(external) β coreadd(),get(),convert_template(), andconvert_template_escape()methodsapi/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.pyβ authoritative tests for HTTP node template behavior
Template Syntax#
The placeholder format is {{#<node_id>.<variable_name>#}}. Internally, the template engine parses this with a regex that accepts only alphanumeric characters and underscores in node IDs:
\{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\}\}
Critical caveat: Node IDs containing hyphens (e.g., {{#node-1.text#}}) silently fail to match this pattern. The placeholder is left as-is in the output rather than raising an error.
Variables are stored and retrieved using selector tuples: (node_id, variable_name). For example, variable_pool.add(["pre_node_id", "number"], 42) stores 42 under the selector ("pre_node_id", "number"), which is then resolved when a template containing {{#pre_node_id.number#}} is processed .
convert_template() returns an object with three output properties:
.textβ plain text rendering.logβ log-friendly rendering (used for model input logging).markdownβ markdown rendering (used for Human Input form content)
VariablePool Lifecycle#
The pool is bootstrapped once at workflow start and then mutated in-place as each node executes.
Bootstrap phase β build_bootstrap_variables() assembles initial variables from four namespaces, each keyed by a dedicated sentinel node ID:
| Namespace | Sentinel node ID constant | Contents |
|---|---|---|
| System | SYSTEM_VARIABLE_NODE_ID | query, files, conversation_id, user_id, workflow_run_id, etc. |
| Environment | ENVIRONMENT_VARIABLE_NODE_ID | Workflow-level env vars |
| Conversation | CONVERSATION_VARIABLE_NODE_ID | Persisted conversation state |
| RAG pipeline | RAG_PIPELINE_VARIABLE_NODE_ID | Knowledge base pipeline inputs |
User inputs (from the START node) are then written via add_node_inputs_to_pool(), which stores each input under the root node ID and any compatible aliases.
Execution phase β The pool instance lives in GraphRuntimeState and is shared across all nodes . As each node completes, it writes its outputs back with variable_pool.add((node_id, key), value). Downstream nodes resolve those values when their own templates are processed.
Certain LLM-compatible nodes (LLM, QUESTION_CLASSIFIER, PARAMETER_EXTRACTOR) that use memory require CONVERSATION_ID to be available at construction time. get_node_creation_preload_selectors() handles pre-loading those selectors before DifyNodeFactory.create_node() runs.
Template Substitution Across Node Types#
DifyNodeFactory.create_node() wires each node type to the dependencies it needs for template resolution. All nodes receive graph_runtime_state (containing the pool), but higher-level rendering helpers vary:
| Node type | Template rendering | Key extras |
|---|---|---|
| LLM | jinja2_template_renderer + retriever_attachment_loader | Jinja2 for prompt templates; retriever loader for knowledge-base context variables |
| Template Transform | jinja2_template_renderer | Pure Jinja2 template output |
| HTTP Request | graphon.nodes.http_request.executor.Executor with variable_pool | URL, headers, params, and body all substituted by the Executor |
| Human Input | DifyHITLCallback.render_form_content_before_submission() via convert_template() | Returns .markdown; result stored as rendered_content in the DB |
| QUESTION_CLASSIFIER | No jinja2_template_renderer | Uses pool directly for model prompt |
| PARAMETER_EXTRACTOR | No jinja2_template_renderer | Same as classifier |
| CODE | code_executor + code_limits | Variables injected as code inputs, not template-rendered |
HTTP Request Node: JSON Body Escaping#
The HTTP Request Executor resolves templates in URLs, headers, params, and body fields. Body type handling differs:
form-data/x-www-form-urlencoded:convert_template()is called; substituted values become strings .json: Usesconvert_template_escape(), which additionally escapes"β\"and\nβ\\nin string values before callingjson.loads(). Non-string (numeric, object) segments are left unescaped . This prevents JSON parse failures when LLM outputs or user text contain line breaks or quotes.
Type-aware substitution in JSON bodies :
- Numbers remain unquoted:
{"count": {{#node.count#}}}β{"count": 42} - Strings that appear unquoted in the template are auto-quoted via
json_repair - Objects/dicts expand inline
The json_repair library (v0.60.1) is used as a fallback to fix structurally broken JSON that results from substitution. It also handles the edge case where UUID strings substituted without quotes would be truncated by naΓ―ve repair logic β see the UUID test case.
Common Failure Modes#
LLM node receives model config instead of template output
Symptom: the upstream Template node renders correctly, but the LLM receives the node's model config object instead of the template output. This points to the VariablePool not containing the upstream node's output when the LLM runs β check that the Template node is truly upstream (connected by an edge) and that no parallel-branch issue causes the LLM to be instantiated before the Template node writes its output.
Human Intervention node shows raw {{#...#}} after page refresh
Symptom: after a page refresh during a pending HITL pause, the form shows the raw placeholder like {{#1778488854176.text#}} instead of the resolved value. Root cause is likely one of: (a) render_form_content_before_submission() failed to call convert_template() at pause time (VariablePool not yet populated), or (b) the API response returns the raw form_content field instead of rendered_content. The frontend does not resolve variables client-side and depends entirely on the pre-rendered value from the backend.
Variable Not Found on branch convergence
Symptom: when a conditional branch is skipped, its output variables are undefined. Downstream nodes that reference those variables throw a "Variable Not Found" error and freeze the workflow. The pre-rendering validation checks variable availability before Jinja2 evaluates. Workaround: use a Variable Aggregator node per variable β it silently ignores skipped branches and always provides a resolved output variable.
Silent substitution failure for hyphenated node IDs
The {{#...#}} regex only allows [a-zA-Z0-9_] in the node ID segment. References to nodes with hyphens in their IDs (e.g., {{#my-node.output#}}) will not be substituted and will pass through as raw text with no error raised.