On this page
- Your Nodes Need to Talk to Each Other — Without Knowing Each Other Exists
- What You Need in Your Toolkit Before Reading This
- The Luggage Conveyor Belt: Building a Stack That Grows Through the Graph
- Implementing the Chainable Stack Pattern, Piece by Piece
- Chunk 1: Defining a Custom ComfyUI Type and the Base Class Contract
- Chunk 2: The Stack Accumulation Method
- Chunk 3: Concrete Subclass — Minimal but Complete
- Chunk 4: The Cartesian Combination Engine
- Chunk 5: The Linear Zip With Cycling
- Exponential vs. Linear: Why Mode Choice Is a Product Decision
- What Breaks the Chain and How the Design Survives
- You Can Now Design Extensible Chainable Node APIs in ComfyUI
Your Nodes Need to Talk to Each Other — Without Knowing Each Other Exists
The Problem
ComfyUI is a directed graph of nodes. Each node has typed inputs and outputs. That type system is what makes the canvas work: you can only connect a LATENT output to a LATENT input. But what happens when you need to aggregate data across a variable number of nodes?
Imagine you want to let users chain five parameter nodes together, each defining one variable in an A/B test. You could give the WorkflowIterator node five explicit inputs — param_1, param_2, param_3, param_4, param_5. But that forces you to pick a maximum. What if a user needs eight? What if they need two? You’re either too rigid or too wasteful.
The naive solutions — fixed-arity inputs, a list widget, a JSON blob — all push complexity onto the user. There is a better way.
The Solution
This tutorial covers two interrelated modules: nodes/parameter_input.py and core/combination_engine.py. Together they implement a linked-list accumulator pattern using ComfyUI’s custom type system. Each parameter node consumes a stack from its predecessor and emits a larger stack to its successor. The WorkflowIterator at the end receives the fully accumulated stack and sends it to the combination engine.
You’ll understand how to define a custom ComfyUI data type, design a base class that implements a stack accumulation pattern, and write the combinatorial logic (Cartesian product and linear zip with cycling) that transforms that stack into executable iteration plans.
What You Need in Your Toolkit Before Reading This
Knowledge Prerequisites
- Python
@classmethodand class inheritance - How ComfyUI’s
INPUT_TYPES,RETURN_TYPES, andFUNCTIONwork at a basic level - What a Python
listofdicts looks like - Basic understanding of
itertools.product(we’ll explain it, but prior exposure helps)
Environment
Python >= 3.9
ComfyUI (any recent version) # For running nodes
itertools # Standard library — no install needed
pyproject.toml declares:
name = "comfyui-workflow-iterator"
version = "1.0.0"
dependencies = ["Pillow>=9.0.0"]
The combination engine (combination_engine.py) has zero dependencies beyond itertools — you can run and test it with a plain Python interpreter.
The Luggage Conveyor Belt: Building a Stack That Grows Through the Graph
Architecture Diagram
flowchart LR
A["WIParameterInt\n(steps: 10-30,5)"] -->|"WI_PARAM_STACK\n[{steps def}]"| B["WIParameterFloat\n(cfg: 5-9, 0.5)"]
B -->|"WI_PARAM_STACK\n[{steps}, {cfg}]"| C["WIParameterCombo\n(sampler: euler,dpm)"]
C -->|"WI_PARAM_STACK\n[{steps},{cfg},{sampler}]"| D["WorkflowIterator"]
D --> E{Mode?}
E -->|"matrix"| F["combination_engine.cartesian()\n→ 5×9×2 = 90 combos"]
E -->|"linear"| G["combination_engine.linear_zip()\n→ max(5,9,2) = 9 combos"]
F --> H["Queue 90 prompts"]
G --> I["Queue 9 prompts"]
The Analogy
Think of this as an airport luggage conveyor belt. Each check-in desk (parameter node) places its bag (parameter definition) onto the belt. The belt carries all previous bags too — each desk receives the existing belt and returns a longer belt. The final collection point (WorkflowIterator) receives all the bags at once. No desk needs to know about any other desk; it just adds its own bag and passes the belt forward.
The custom type WI_PARAM_STACK is the belt. It is a Python list of dicts. ComfyUI treats it as an opaque typed connection — but in Python, it is simply a list you append to.
Implementing the Chainable Stack Pattern, Piece by Piece
Chunk 1: Defining a Custom ComfyUI Type and the Base Class Contract
Before any node can be written, you need to define the shared contract. The base class WIParameterBase does this.
CATEGORY = "Workflow Iterator/Parameters"
class WIParameterBase:
"""Base class with shared functionality for parameter nodes."""
# A custom type name — just a string. ComfyUI enforces type matching
# at the connection level using this string as the key.
RETURN_TYPES = ("WI_PARAM_STACK",)
RETURN_NAMES = ("param_stack",)
CATEGORY = CATEGORY
FUNCTION = "define_parameter" # Name of the method ComfyUI calls
PARAM_TYPE = "STRING" # Override in subclasses
@classmethod
def IS_CHANGED(cls, **kwargs):
# Returning a different value each call forces ComfyUI to re-run
# this node on every queue submission, even if inputs look identical.
# Essential for "random:5" seeds to regenerate each run.
import time
return time.time()
🔵 Deep Dive: IS_CHANGED is a ComfyUI optimization hook. By default, if a node’s inputs haven’t changed, ComfyUI skips re-executing it and uses the cached output. For deterministic parameters that’s fine — but for random:5 seeds, you need fresh values each run. Returning time.time() guarantees the value is always “changed.”
Chunk 2: The Stack Accumulation Method
This is the core of the pattern. The define_parameter method receives whatever stack existed before it and returns a larger stack.
def define_parameter(
self,
parameter_name,
values,
target_node_title,
target_widget_name,
enabled,
param_stack_in=None, # Optional: None if this is the first node
unique_id=None,
):
# If there's an upstream stack, copy it. Otherwise start fresh.
# list() creates a shallow copy — we don't want to mutate the
# upstream node's output, which could cause issues if ComfyUI
# caches it.
stack = list(param_stack_in) if param_stack_in else []
if enabled:
# Append this node's definition to the stack.
# The dict captures everything the combination engine needs
# to apply this parameter to a target node later.
stack.append(
{
"name": parameter_name,
"type": self.PARAM_TYPE, # Injected by subclass
"values_raw": values, # Unparsed string
"target_node_title": target_node_title,
"target_widget_name": target_widget_name,
"node_id": unique_id,
}
)
# ComfyUI expects a tuple of return values, even for one output.
return (stack,)
The enabled flag means a disabled node is invisible to the stack — it passes the incoming stack through unchanged. This is the transparent proxy sub-pattern: the node participates in the chain but contributes nothing.
Chunk 3: Concrete Subclass — Minimal but Complete
The subclasses are almost trivially simple because the base class handles all the logic:
class WIParameterInt(WIParameterBase):
PARAM_TYPE = "INT" # This single line is the entire subclass contract
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"parameter_name": ("STRING", {"default": "steps"}),
"values": ("STRING", {"default": "10-30, 5",
"multiline": True}),
"target_node_title": ("STRING", {"default": "KSampler"}),
"target_widget_name":("STRING", {"default": "steps"}),
"enabled": ("BOOLEAN", {"default": True}),
},
"optional": {
# Optional input: if absent (first node), param_stack_in
# defaults to None in define_parameter.
"param_stack_in": ("WI_PARAM_STACK",),
},
"hidden": {
# UNIQUE_ID is injected by ComfyUI — the node's canvas ID.
"unique_id": "UNIQUE_ID",
},
}
The five type subclasses (WIParameterInt, WIParameterFloat, WIParameterString, WIParameterCombo, WIParameterSeed) differ only in their PARAM_TYPE class attribute and their INPUT_TYPES defaults. All execution logic lives in the base class.
Chunk 4: The Cartesian Combination Engine
Once the stack reaches the WorkflowIterator, it goes to the combination engine. Here is the cartesian function — the mathematical Cartesian product of all parameter value lists:
import itertools
def cartesian(parsed_params: list[dict]) -> list[list[tuple]]:
# Drop any parameters whose value lists are empty (parse errors, etc.)
active_params = [p for p in parsed_params if p.get("parsed_values")]
if not active_params:
return []
# Extract just the value lists — this is what itertools.product needs.
# e.g., [[10, 20], [5.0, 7.0, 9.0]] → product → (10,5), (10,7), ...
value_lists = [p["parsed_values"] for p in active_params]
names = [p["name"] for p in active_params]
target_infos = [
{
"target_node_title": p["target_node_title"],
"target_widget_name": p["target_widget_name"],
}
for p in active_params
]
combinations = []
# itertools.product(*value_lists) generates every combination.
# For two lists of length 2 and 3: (2×3) = 6 tuples.
for combo_values in itertools.product(*value_lists):
combination = [
# Each element: (display name, value, where to inject it)
(names[i], combo_values[i], target_infos[i])
for i in range(len(active_params))
]
combinations.append(combination)
return combinations
Chunk 5: The Linear Zip With Cycling
The Cartesian product grows exponentially. Three parameters with 10 values each = 1,000 renders. Linear zip pairs them index-by-index, capped at the length of the longest list:
def linear_zip(parsed_params: list[dict]) -> list[list[tuple]]:
active_params = [p for p in parsed_params if p.get("parsed_values")]
if not active_params:
return []
# Find the longest list — this determines total iteration count.
max_len = max(len(p["parsed_values"]) for p in active_params)
combinations = []
for i in range(max_len):
combination = []
for j, param in enumerate(active_params):
values = param["parsed_values"]
# Modulo cycling: if values has 2 items and i=3,
# 3 % 2 = 1 → use values[1]. Short lists wrap around.
value = values[i % len(values)]
combination.append((
param["name"],
value,
{
"target_node_title": param["target_node_title"],
"target_widget_name": param["target_widget_name"],
}
))
combinations.append(combination)
return combinations
Example: steps=[10,20,30], cfg=[5,7] → linear_zip produces (10,5), (20,7), (30,5). The cfg list cycles back to 5 for the third iteration.
Exponential vs. Linear: Why Mode Choice Is a Product Decision
Cartesian Complexity
For k parameters each with n values: the total combinations = n^k. Three parameters × 10 values = 1,000 renders. Five parameters × 10 values = 100,000 renders. This is O(n^k) space and time in the worst case.
itertools.product is lazy in CPython — it generates one tuple at a time from a generator. But the repo materializes it into a list immediately (so it can be counted and indexed). This means the full combination list lives in memory. For 100,000 combinations this is still only ~10 MB — negligible. The real cost is GPU time.
Linear Zip Complexity
Always O(max(n_i)) — linear in the longest list. Three parameters with 10, 8, and 6 values → 10 renders. This is the correct choice when you want a curated comparison, not an exhaustive grid.
🔵 Deep Dive: itertools.product is implemented in C inside CPython. The Python-level call overhead is minimal. It uses a tuple of iterables internally and advances them in a right-to-left carry pattern — exactly like counting in mixed-radix number systems. Each call to __next__ is O(k) where k is the number of iterables.
What Breaks the Chain and How the Design Survives
Cyclic Connections
ComfyUI’s graph validation prevents true cycles. But what about a user accidentally connecting a node’s own output back to its input? The _trace_param_chain function in iteration_state.py uses a visited: set — it will never revisit a node it has already processed, breaking any cycle safely.
Type Mismatches at Connection Time
If a user tries to connect a WI_PARAM_STACK output to a LATENT input, ComfyUI will reject the connection at the canvas level before any Python runs. The custom type string acts as a compile-time guard.
Empty Stacks
If every parameter node is disabled, the stack passed to WorkflowIterator is []. The combination engine returns [] immediately. The IterationStateManager detects this and logs "no enabled parameters — running workflow once." The workflow executes exactly once, as if the iterator nodes weren’t there. Graceful degradation with zero user friction.
🔴 Danger: The list(param_stack_in) shallow copy in define_parameter is critical. Without it, every node in the chain would mutate the same list object. If ComfyUI ever caches and reuses outputs (which it does for unchanged nodes), one execution could corrupt another’s stack. Always copy mutable data received as input before appending to it.
You Can Now Design Extensible Chainable Node APIs in ComfyUI
The specific skills gained from this tutorial:
-
Custom ComfyUI types: A string identifier in
RETURN_TYPES/INPUT_TYPEScreates a typed connection with canvas-level enforcement. You now know how to define and use them. -
The accumulator chain pattern: An optional input that passes through and is extended — this is how you build variable-arity APIs in node-graph systems without modifying the consumer node.
-
Cartesian vs. zip combination strategies: You understand the exponential vs. linear trade-off and can choose the right one for a given use case.
-
Base class inheritance for node families: Centralizing shared logic in a base class while subclasses only declare their data type — the Open/Closed principle applied to ComfyUI node design.