Workflow Conditional Branching#
Dify supports two conditional branch node types: if-else (condition-driven) and question-classifier (LLM-driven). Both are implemented in the graphon library and integrated into Dify via DifyNodeFactory.create_node() . The core architecture treats these as exclusive branchers β they activate exactly one outgoing handle per run β in contrast to fan-out nodes that fire all edges. This exclusivity is the root of several downstream considerations: variable availability, streaming unblocking, and pause-resume state.
If-Else and Question-Classifier Nodes#
If-Else uses IfElseNodeData with a cases list; each case has a case_id handle, and the implicit ELSE branch uses the handle "false" . Conditions support a rich operator set: contains, not contains, start with, end with, is, is not, empty, not empty, =, β , >, <, β₯, β€, null, not null. Logical and/or combines multiple conditions per case.
Question-Classifier uses QuestionClassifierNodeData with a list of classes (each having an id); outputs class_id and class_name. It requires a full LLM stack (model instance, credentials provider, template renderer) injected at construction time . Branching is LLM-driven rather than rule-based.
Handle routing: Outgoing edges specify a sourceHandle matching a case_id or class id. The _repair_branch_edge_handles() method re-homes any edges with unspecified handles to the next available branch handle in declaration order.
Variable Aggregation at Convergence Points#
The Variable Aggregator node (api/core/workflow/nodes/variable_aggregator/, recently moved to api/dify_graph/nodes/variable_aggregator/) is the standard mechanism for merging outputs from multiple branches before they converge .
Two aggregation modes, controlled by VariableAggregatorNodeData:
| Mode | Behavior |
|---|---|
| Simple (default) | Iterates variable selectors; returns the first non-None value under key "output" |
Grouped (group_enabled=True) | Per-group, returns first non-None variable under the group's group_name key |
Because skipped-branch variables are absent from the variable pool, the "first found" strategy naturally handles partial branch execution without explicit null-checks.
aggregate_all mode (added in PR #22200): when aggregate_all=True, the node collects all resolved inputs into a list β useful for merging results from multiple parallel Knowledge Retrieval nodes.
Duplicate End node outputs: v1.15.0 introduced a publish-time check in use-checklist.ts (getDuplicateEndOutputMessages()) that blocked workflows where two End nodes used the same output variable name. This was overly strict β in a conditional workflow, only one End node executes per run. PR #38488 removes this check.
Pre-Rendering Variable Validation and Cross-Branch References#
The pre-render validation problem (v1.15.0, issue #38655): Dify validates variable availability before passing values to Jinja2 templates. If a conditional branch is skipped, its output variables are absent from the variable pool. When a downstream node references one of these variables β even safely behind a Jinja2 {% if var is defined %} guard β the pre-render check throws "Variable not found" before Jinja2 ever runs . The workaround is to route optional variables through a Variable Aggregator node, or restructure to avoid cross-branch variable references in downstream templates.
Advisory warning system (PR #37131): A non-blocking validator (api/services/workflow_variable_reference_validator.py) performs static data-flow analysis at publish time :
- Exclusive branchers (if-else, question-classifier, HITL, nodes with
error_strategy = FAIL_BRANCH) activate only their selected handle. - Fan-out nodes fire all outgoing edges.
- The validator flags any reference where a concrete execution path exists on which the consumer runs but the producer does not. It never flags Variable Aggregator inputs (which are explicitly designed to handle absent variables).
- The warning is non-blocking: it surfaces as a toast notification on publish, accommodating existing graphs with latent cross-branch references on rarely-hit paths.
Note: As of the research date, PR #37131 had not yet been merged into the main branch.
SSE Stream Delivery in Branched Workflows#
Streaming tokens from Answer nodes in branched workflows is gated by ResponseStreamFilter (from graphon.filters). The filter builds a paths_map of blocking edges that must be traversed before each Answer node may emit tokens. As edges are taken, they are removed from the map; when a path becomes empty, the Answer node is unlocked.
Branch-merge streaming failure (addressed in PR #22771): In complex workflows where multiple if-else branches converge to a shared join node β LLM β Answer, Answer nodes incorrectly blocked streaming because static dependency analysis could not model mutually exclusive branches at runtime. The fix introduced a hybrid strategy in graph_engine.py:
_has_complex_branch_merge_topology()detects convergence patterns via backward traversal.- For complex topologies, an
AnswerStreamProcessorreceives runtimenode_run_statefor dynamic path tracing rather than relying on static analysis alone.
The event pipeline for streaming is: graphon emits a raw chunk β ResponseStreamFilter passes it through as QueueTextChunkEvent β _handle_text_chunk_event() in AdvancedChatAppGenerateTaskPipeline appends to WorkflowTaskState.answer β MessageCycleManager.message_to_stream_response() wraps it as MessageStreamResponse (SSE message event) β delivered to client.
Pause-Resume State Persistence Across Branch Boundaries#
Root cause of issue #38614: When a workflow pauses at a Human Input node and resumes, iter_dify_graph_engine_events() in workflow_entry.py previously instantiated a fresh ResponseStreamFilter. If an upstream if-else or question-classifier edge was traversed before the pause, that edge was not re-emitted on resume β the new filter had no record of it, so the downstream Answer node's path never emptied, permanently blocking token streaming. The chat UI received no reply even though node_finished/workflow_finished contained correct output.
Affected versions: Dify 1.15.0 (graphon v0.5.x). Fixed in PR #38540.
Fix (PR #38540): PauseStatePersistenceLayer now calls ResponseStreamFilter.dumps() at pause and WorkflowResumptionContext.get_response_stream_filter() restores the filter on resume. The serialized state includes paths_map, active_session, waiting_sessions, pending_sessions, stream_buffers, stream_positions, closed_streams, and node_execution_ids. Backward compatible: the new field is optional, so runs paused before the fix resume with fresh-filter behavior for that one stale run.
Workaround (pre-fix): Place Human Input nodes before any conditional branching in the workflow graph.
Two independently-managed state components survive a pause boundary:
GraphRuntimeStateβ variable pool, LLM usage counters, execution metadata.ResponseStreamFilterβ streaming gate state (edge path tracking).
Both are serialized into WorkflowResumptionContext (see pause_state_persist_layer.py#L38-L55) and persisted via APIWorkflowRunRepository.create_workflow_pause().
Key Source Files#
| File | Purpose |
|---|---|
api/dify_graph/nodes/variable_aggregator/ (formerly api/core/workflow/nodes/variable_aggregator/) | Variable Aggregator node β first-found and aggregate-all modes |
api/core/workflow/generator/runner.py | Branch handle repair, node output variable mapping, workflow generator |
api/core/app/layers/pause_state_persist_layer.py | Serializes GraphRuntimeState + ResponseStreamFilter on pause |
api/core/workflow/workflow_entry.py | iter_dify_graph_engine_events() β accepts restored filter for resumed runs |
api/core/app/apps/advanced_chat/generate_task_pipeline.py | Event dispatch, pause/resume message persistence, text chunk handling |
api/services/workflow_variable_reference_validator.py | Advisory cross-branch variable reference validator (PR #37131) |
web/app/components/workflow/hooks/use-checklist.ts | Publish-time workflow checks including (removed) duplicate End output validation |