Node versioning β how to change a node without breaking saved workflows
β οΈ Scope β OPT-IN for Koolook nodes, MANDATORY for forks. Backward compatibility is not a default constraint for Koolook-created nodes (the root
k_*.pycustom nodes); for those, ignore everything below and make the cleanest change β rename/drop/reorder freely β unless the maintainer sayscheck backward compatibility. For anything underforks/(upstream ports/wrappers, including the Koolook-original nodes exposed viaSKIP_VERSION_SUFFIX) these rules still apply by default β forks keep full back-compat discipline with no request needed. SeeCLAUDE.mdβ Change management.
This is a hard-won list. ComfyUI's workflow format silently breaks in
several non-obvious ways when you change a node's inputs, outputs, or
class name. The rules below are what to follow whenever you touch a
fork node's INPUT_TYPES / RETURN_TYPES / NODE_CLASS_MAPPINGS,
or a Koolook node's once check backward compatibility has been
requested.
How ComfyUI loads a saved workflow
When the frontend opens a workflow JSON, it reconstructs each node by:
- Looking up the node by its registered ID (the key in
NODE_CLASS_MAPPINGS). If the lookup fails, the node renders as a red "missing" stub and the workflow won't run. - Mapping connections by input/output name. Most cases.
- Mapping widget values by position in the widget array β this is the source of most breakage.
- Filling missing inputs with their default, if they're declared in
INPUT_TYPES["optional"]. Missing required inputs β validation error.
What breaks vs what doesn't
| Change | Breaks saved workflows? | Why |
|---|---|---|
Add a new input to INPUT_TYPES["required"] |
YES | Old workflow has no value for it β validation fails |
Add a new input to INPUT_TYPES["optional"] at the end |
safe | Missing inputs use defaults |
| Add a new widget between existing widgets | YES | Widget array indices shift β values land in wrong widgets |
| Add a new widget at the end | mostly safe | Old workflows just don't have that index β uses default |
| Rename an existing input | YES | Saved value can't map by name |
| Rename the class / node ID | YES | Lookup fails entirely |
| Reorder existing combo options | YES for indexed serialization | Some workflows store combo as index, not string |
| Add new combo option at the end | safe | Existing string values still match |
| Remove an input | soft break | Saved value silently dropped, may lose semantic meaning |
Change RETURN_TYPES order or types |
YES | Downstream connections targeting output[1] now hit wrong type |
| Change a default value | "stealth break" | Old workflows have the old default baked in; new workflows get the new default β silent behavior divergence |
"Field gets messed up" almost always = widget position shift. ComfyUI serializes widgets as
widgets_values: [val0, val1, val2]. Insert a new widget at index 1 β every subsequent value shifts one slot β wrong values land in wrong widgets.
The five rules
- Never rename a registered node ID. When you really must, register both old and new IDs pointing to the same class for a few releases (deprecation alias), then remove the old one with a CHANGELOG note.
- Append new inputs/widgets to the END. Never insert in the middle.
Order in
INPUT_TYPESdict matters because Python preserves insertion order and the frontend uses it. - New inputs go in
INPUT_TYPES["optional"]with a default, not inrequired. Old workflows that don't have the field will use the default; new workflows can wire it up. - Never reorder combo options. Append new options. If you need to remove one, leave it as a hidden alias or deprecation stub for a release first.
- Treat
RETURN_TYPESas immutable. If you need to change outputs, make a new node ID βMyNodeV2. The Comfy ecosystem (KJ Nodes, Was Node Suite, Crystools) does this constantly.
When you can't avoid a breaking change
| Pattern | Example |
|---|---|
| Suffix-version a new node | EasyAIPipeline β EasyAIPipelineV2. Both register, both work. |
| Mark old as deprecated in display name | Old: Easy AI Pipeline (deprecated, use V2) β display name change is fine, ID is what matters |
| Document migration in CHANGELOG | "Old EasyAIPipeline retained; new pipeline work should use EasyAIPipelineV2 which adds X/Y/Z" |
| Keep deprecated nodes for β₯2 minor versions before removing |
This is exactly the pattern used for EasyResize β EasyResize_Koolook
in v0.1.6 (see ../../CHANGELOG.md). Both IDs
register, both load, the bare-name one's display says "deprecated", and
the bare-name one will be removed in a future major release once the
deprecation has had time to propagate.
Concrete pattern β the rename + alias
# In whichever k_*.py file holds the class:
class MyNode:
""" ... existing implementation, unchanged ... """
# ...
# Register BOTH IDs against the same class:
NODE_CLASS_MAPPINGS = {
"MyNode_Koolook": MyNode, # new canonical ID
"MyNode": MyNode, # legacy alias (deprecated)
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MyNode_Koolook": "My Node (Koolook)",
"MyNode": "My Node (deprecated, use 'My Node (Koolook)')",
}
That's it. Saved workflows that reference MyNode keep loading. New
workflows pick MyNode_Koolook from the search. The bare-name display
nudges users to switch.
Concrete pattern β adding a new optional input
Suppose EasyAIPipeline currently has these inputs:
"required": {
"shot_duration": ("INT", {...}),
"seed": ("INT", {...}),
"base_path": ("STRING", {...}),
"shot_name": ("STRING", {...}),
"ai_method": ("STRING", {...}),
"version": ("INT", {...}),
},
You want to add a new pass_name field. Do not add it to required
between existing fields β that breaks every saved workflow. Instead:
"required": { ... unchanged ... },
"optional": {
"pass_name": ("STRING", {"default": "", "tooltip": "..."}),
},
- Old workflows that don't have
pass_nameuse the default"". - New workflows that do have it use the value the user sets.
- Output behavior must remain backward-compatible when
pass_name=""(i.e. equivalent to the pre-change behavior). Ifpass_name=""would change the output filename, you need aMyNodeV2instead.
Concrete pattern β output change
Suppose EasyAIPipeline currently returns (output_path,) and you want
to add a (output_path, output_dir) tuple. That's a RETURN_TYPES
change β downstream connections targeting output[1] would hit the new
output unexpectedly (or just exist where they didn't before).
Don't modify the existing class. Make a new one:
class EasyAIPipeline_V2(EasyAIPipeline):
"""Same as EasyAIPipeline, plus emits the resolved output directory."""
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("output_path", "output_dir")
# ... reuse or override the execute method as needed ...
NODE_CLASS_MAPPINGS = {
"EasyAIPipeline": EasyAIPipeline, # unchanged
"EasyAIPipeline_V2": EasyAIPipeline_V2, # new
}
Anti-patterns to refuse β for forks, or after check backward compatibility
These are anti-patterns for any fork node, and for a Koolook node only once the maintainer has asked to preserve backward compatibility. For a Koolook node with no such request, every one of them is the correct clean change β append-vs-insert is the lone exception below.
- β "I'll just add the field in the middle for clarity, the dict order
doesn't matter." It does. Append. (This one is worth following even
without a back-compat request β a mid-list widget insertion silently
scrambles the
widgets_valuesof any workflow already on disk, including your own dev graphs, for zero benefit. Appending is free.) - β "I'll rename the input to be clearer." For a fork node, or any node under a back-compat request: add a new optional input with the clearer name and deprecate the old one in display only. For a Koolook node with no request, just rename it.
- β "It's a small change, no need to bump the node version." For a fork node, or under a back-compat request, a surface change that could break a saved workflow β new node ID. For a Koolook node with no request, change in place and note it in the CHANGELOG.
- β "Nobody's using the old node yet." For a fork node, or under a back-compat request, you can't assume that. For a Koolook node with no request you do assume it β clean change, move on.
Tooling β what we have, what's still missing
- β
Workflow-fixture smoke test β
tools/preflight_release.pycheckworkflowswalkstests/workflows/*.json, extracts node IDs by both heuristic and AST-extracted set, and reports any reference that no longer resolves. Thepreflight-releaseskill in.claude/skills/runs all four checks before a release. - β³
node-api-changeagent skill β a check that would run before any edit toINPUT_TYPES/RETURN_TYPES/ class names, diff against the rules above, and refuse unsafe changes. Same shape aslicense-pre-check. Not built yet; scoped for the next time someone changes a node's input/output surface.