BlogComfyUI
How to make a ComfyUI custom node from scratch
Make a working ComfyUI custom node in one Python file: the folder, the V3 schema, inputs, outputs and execute, then how to test, debug and share it.
Esha Senior AI Developer
On this page15 sections
The short version
- A custom node is a folder of Python inside
ComfyUI/custom_nodes/. ComfyUI imports it every time it starts. - In ComfyUI's current node format, V3, one method describes the node (
define_schema) and another does its work (execute). - Images reach your code as a PyTorch tensor shaped [batch, height, width, channels], with every value between 0 and 1.
- Restart ComfyUI after every change to the Python. If the node doesn't appear, the startup log says why.
Sooner or later you'll want a node that doesn't exist. Maybe it's something small, like choosing between three images without rewiring the graph every time. That's the node we built in the video, and it's the one this guide builds, one line at a time.
A custom node doesn't have to be complicated. The whole thing is about fifty lines of Python, and you don't need to understand every one of them. There are only a few parts that matter, and once you've seen them you can build almost any simple node the same way. Every piece of code here has been run on ComfyUI 0.37.1.
What you're building
Image Picker takes three images and has a dropdown. Whichever image you choose comes out of its single output. Here it is running in ComfyUI:

You'll need three things:
- A recent ComfyUI. If you haven't installed it, start with the install guide.
- A text editor. VS Code is what I use in the video, but any editor works.
- A little Python. You should recognise a class and a dictionary. That's about it.
Step 1: Make the folder
Custom nodes live in the custom_nodes folder inside your ComfyUI folder. Where that
is depends on how you installed it:
Desktop app ("ComfyUI" is the install's name)
%LOCALAPPDATA%\Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI\custom_nodes
Windows portable
ComfyUI_windows_portable\ComfyUI\custom_nodes
manual install
ComfyUI/custom_nodes
Inside it, make a new folder for your node pack, and inside that, an empty file called
__init__.py, with two underscores on each side:
custom_nodes/
└── model_chief_image_picker/
└── __init__.py
When ComfyUI starts, it goes through custom_nodes and imports every folder that has
an __init__.py. That file is your node pack's front door.
Step 2: The whole file
Here's the complete node. The next three steps take it apart.
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
class ImagePicker(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="ModelChiefImagePicker",
display_name="Image Picker",
category="Model Chief",
inputs=[
io.Image.Input("image_1"),
io.Image.Input("image_2"),
io.Image.Input("image_3"),
io.Combo.Input(
"pick",
options=["Image 1", "Image 2", "Image 3"],
),
],
outputs=[
io.Image.Output(),
],
)
@classmethod
def execute(
cls,
image_1,
image_2,
image_3,
pick,
) -> io.NodeOutput:
images = {
"Image 1": image_1,
"Image 2": image_2,
"Image 3": image_3,
}
return io.NodeOutput(images[pick])
class ModelChiefExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [
ImagePicker,
]
async def comfy_entrypoint() -> ModelChiefExtension:
return ModelChiefExtension()
It's also on the resources page,
as the file and as a zipped folder you can drop straight into custom_nodes.
Step 3: define_schema describes the node
The first line brings in ComfyUI's node toolkit. comfy_api.latest is the V3 API, and
io holds everything you describe a node with. Then the node itself is a class that
inherits from io.ComfyNode.
define_schema tells ComfyUI what the node looks like: its name, where it lives in
the menus, and what goes in and comes out. It never touches an image. It's a description, and
ComfyUI reads it once at startup to draw the node.
node_idis the node's permanent internal name, and it's what saved workflows use to find it. It has to be unique across every node pack anyone has installed, so put your own prefix on it, likeModelChiefhere. Don't change it later, or old workflows won't find the node.display_nameis what people see on the node and in search. You can change it whenever you like.categoryis its place in the Add Node menu. Use a slash for a submenu, such asModel Chief/images.inputsandoutputsare lists. Each input's first argument is its name, and that name is whatexecutewill receive.
An input can be a socket another node wires into, like io.Image.Input, or a widget
you set on the node itself, like the io.Combo.Input dropdown. These are the types
you'll reach for most:
| Input | What it gives you |
|---|---|
io.Image.Input("image") | A socket for pictures |
io.Mask.Input, io.Latent.Input | Sockets for masks and latents |
io.Model.Input, io.Clip.Input, io.Vae.Input, io.Conditioning.Input | Sockets for the things loaders and text encoders output |
io.Int.Input("steps", default=20, min=1, max=100) | A whole-number box |
io.Float.Input("amount", default=1.0, min=0.0, max=3.0, step=0.05) | A decimal box |
io.String.Input("text", multiline=True) | A text box |
io.Boolean.Input("enabled", default=True) | An on/off switch |
io.Combo.Input("pick", options=[...]) | A dropdown |
Wires only connect between matching types, which is why every socket has one. An IMAGE output will plug into this node's inputs; a LATENT won't.
Step 4: execute does the work
execute runs every time the workflow does. ComfyUI calls it with one argument per
input, named exactly as you named them in the schema, so here it gets image_1,
image_2, image_3 and pick.
The body is ordinary Python. It puts the three images in a dictionary keyed by the dropdown's
options, looks up whichever one is picked, and hands it back wrapped in
io.NodeOutput. Everything you pass to NodeOutput comes out of the
node's outputs, in order.
Both methods are class methods: ComfyUI calls them on the class itself, so
there's no self to keep things on. Everything a run needs arrives as an input.
pick set to "Image 4", and it refused with Value not in list: pick: 'Image 4'
not in ['Image 1', 'Image 2', 'Image 3']. So the dictionary lookup never sees a value it
doesn't have.
Step 5: Register it
The last two blocks tell ComfyUI the node exists. ModelChiefExtension lists every
node in your pack in get_node_list, and comfy_entrypoint is the function
ComfyUI looks for when it imports your folder. Leave comfy_entrypoint out and
ComfyUI skips the whole folder; leave a class out of the list and that node simply never
appears.
A pack can hold as many nodes as you like: write another class and add it to the list. You'll do exactly that in a minute.
Step 6: Restart ComfyUI and find it
Save the file and restart ComfyUI completely. Refreshing the browser isn't enough, because Python code is only read when ComfyUI starts. As it starts, it prints a line for every node pack it imported:
Import times for custom nodes:
0.0 seconds: ...\custom_nodes\model_chief_image_picker
Then double-click an empty part of the canvas and type image picker:

Step 7: Try it
Add three Load Image nodes, wire them into image_1,
image_2 and image_3, and connect the output to a
Preview Image. Run it, change pick, run it again. Only the picker
and the preview run the second time: ComfyUI keeps the loaded images from the first run, since
nothing about them changed.
How images move through a node
The picker never looks inside an image; it only passes one along. The moment you want to change pixels, you need to know what an image is when it reaches your code. It's a PyTorch tensor:
- shaped [batch, height, width, channels], so a single 1024 × 768 picture is
[1, 768, 1024, 3]; - with values from 0.0 to 1.0, not 0 to 255. ComfyUI's Load Image divides by 255 on the way in.
That's enough to write a second node. This one makes an image brighter or darker. Add the class
to the same file, and add it to the list in get_node_list:
class ImageBrightness(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="ModelChiefImageBrightness",
display_name="Image Brightness",
category="Model Chief",
description="Makes an image brighter or darker.",
inputs=[
io.Image.Input("image"),
io.Float.Input(
"amount",
default=1.0,
min=0.0,
max=3.0,
step=0.05,
tooltip="1.0 leaves the image as it is. Lower is darker, higher is brighter.",
),
],
outputs=[
io.Image.Output(),
],
)
@classmethod
def execute(cls, image, amount) -> io.NodeOutput:
# image is a tensor of shape [batch, height, width, channels], values 0 to 1
brighter = image * amount
return io.NodeOutput(brighter.clamp(0.0, 1.0))
return [
ImagePicker,
ImageBrightness,
]
Multiplying the tensor scales every pixel at once, whatever the image's size, and
clamp keeps the result inside 0 to 1, since anything above 1 isn't a valid colour.
The description shows as the node's tooltip and the tooltip on the
input shows when you hover over amount. I checked the output against the same sum
done in NumPy: at 0.6 and at 1.4 the pixels came out within half a level of 255 of the expected
values. Here are the two nodes chained together:

Level up: only compute the image you pick
There's a catch in the picker as written. ComfyUI runs everything wired into a node before the node itself, so if each input came from its own expensive branch, say three separate image generations, all three would run even though only one is used.
Lazy inputs fix that. Mark the inputs lazy=True and add a
check_lazy_status method, which ComfyUI calls first to ask which inputs you actually
need:
inputs=[
io.Image.Input("image_1", lazy=True),
io.Image.Input("image_2", lazy=True),
io.Image.Input("image_3", lazy=True),
io.Combo.Input("pick", options=["Image 1", "Image 2", "Image 3"]),
],
# ...
@classmethod
def check_lazy_status(cls, pick, image_1=None, image_2=None, image_3=None):
images = {"Image 1": ("image_1", image_1), "Image 2": ("image_2", image_2), "Image 3": ("image_3", image_3)}
name, value = images[pick]
return [name] if value is None else []
@classmethod
def execute(cls, pick, image_1=None, image_2=None, image_3=None) -> io.NodeOutput:
images = {"Image 1": image_1, "Image 2": image_2, "Image 3": image_3}
return io.NodeOutput(images[pick])
An input that hasn't been computed yet arrives as None. The method returns the name
of the picked input until it has a value, then an empty list, which tells ComfyUI to go ahead
and run execute. To test it, I wired a node that deliberately fails into one of the
inputs I wasn't picking. The picker as written above ran that branch and failed; the lazy
version never touched it.
When your node doesn't show up
Look at the startup log first. A pack that failed to import is marked in the list of import times, and the real error sits above it:
Cannot import ...\custom_nodes\my_node module for custom nodes: ...
0.0 seconds (IMPORT FAILED): ...\custom_nodes\my_node
- IMPORT FAILED with a SyntaxError or NameError. A typo in the Python. The traceback above the line names the file and the line number.
No module named 'comfy_api'(or'comfy_api.latest'). Your ComfyUI predates the V3 API. Update it.Skip ... due to the lack of NODE_CLASS_MAPPINGS or comfy_entrypoint. The file imported but ComfyUI found no way in:comfy_entrypointis missing or misspelled.- The folder isn't in the list at all. It's in the wrong place, usually one level too deep: unzipping can create
custom_nodes/my_node/my_node/__init__.py. - It imported, but there's no node. The class is missing from the list in
get_node_list. - You changed the code and nothing changed. ComfyUI wasn't fully restarted.
- Two packs clash. Two nodes share a
node_id. That's why yours should carry a prefix.
To switch a pack off without deleting it, add .disabled to the end of its folder
name and restart.
The older format you'll see in other tutorials
Most custom node code you'll find online is written in the original format, V1. It still works: the same Image Picker written that way loaded and ran alongside the V3 one in my tests. The parts just have different names:
| V1 | V3 |
|---|---|
INPUT_TYPES | inputs in io.Schema |
RETURN_TYPES | outputs in io.Schema |
FUNCTION = "any_name" | always execute |
CATEGORY | category |
IS_CHANGED | fingerprint_inputs |
VALIDATE_INPUTS | validate_inputs |
NODE_CLASS_MAPPINGS | get_node_list in a ComfyExtension |
For new nodes, use V3. ComfyUI's own documentation says new node features will only be added to the V3 schema.
Letting an AI write it
In the video I also gave Claude Code this prompt, and it answered with the complete file and tested all three picks:
Create a simple ComfyUI V3 custom node called Image Picker.
Requirements:
- use comfy_api.latest
- 3 IMAGE inputs: image_1, image_2, image_3
- dropdown with Image 1, Image 2, Image 3
- 1 IMAGE output
- return whichever image is selected
- category: Model Chief
- provide one complete __init__.py file
It works because it's specific. It names the format and the exact import, so the answer doesn't drift into the older V1 style, and it spells out every input, the output and the category. Swap in your own requirements and the same shape of prompt makes a different node. Then do what you'd do with any code: read it, and test it before you rely on it. Knowing the parts you've just learned is what makes that quick.
Sharing your node
For a friend, zip the folder. To publish it properly:
- Put it on GitHub. People can then clone it straight into
custom_nodes. - Add a
pyproject.toml.comfy node init, from comfy-cli, writes one with the fields the registry needs: a unique name, a version, your publisher ID and a display name. - Publish to the Comfy Registry. Make a publisher account at registry.comfy.org, create an API key, and run
comfy node publish. From there, people can install your node from the Manager.
Questions people ask
Where do I put a custom node in ComfyUI?
In the custom_nodes folder inside your ComfyUI folder, as a folder with an __init__.py file in it. For the Windows portable build that's ComfyUI_windows_portable\ComfyUI\custom_nodes; for the Desktop app it's inside %LOCALAPPDATA%\Comfy-Desktop\ComfyUI-Installs. Restart ComfyUI afterwards so it imports the new folder.
What is the difference between V1 and V3 ComfyUI nodes?
V1 is the original format, with INPUT_TYPES, RETURN_TYPES, FUNCTION and NODE_CLASS_MAPPINGS. V3 uses comfy_api.latest: a node inherits from io.ComfyNode, describes itself in define_schema, does its work in execute and is registered through a ComfyExtension and comfy_entrypoint. V1 nodes still work, but ComfyUI's documentation says new node features will only be added to V3.
Do I need to restart ComfyUI after changing a custom node?
Yes. ComfyUI reads a node's Python only when it starts, so every change to the Python needs a full restart. Refreshing the browser tab isn't enough. The startup log's "Import times for custom nodes" list confirms the pack loaded.
What format are images in a ComfyUI custom node?
An IMAGE is a PyTorch tensor with the shape [batch, height, width, channels] and float values from 0.0 to 1.0. A single 1024 × 768 RGB picture is [1, 768, 1024, 3]. Return images in the same format, and clamp values to between 0 and 1 after changing them.
Why doesn't my custom node appear in ComfyUI?
Check the startup log. "IMPORT FAILED" next to your folder means the Python raised an error, and the traceback above it says where; a "Skip" warning about comfy_entrypoint means that function is missing. If the folder isn't listed at all, it's in the wrong place or nested one level too deep. If it imported but no node appears, the class is missing from get_node_list.
How do I install someone else's custom node?
The easy way is the Manager: open it, search for the node pack, click Install and restart ComfyUI. By hand, clone or unzip the pack's folder into custom_nodes, install its requirements.txt with the same Python ComfyUI runs on (on the portable build that's python_embeded\python.exe -m pip install -r followed by the path to the file), then restart.
Can I use AI to write a ComfyUI custom node?
Yes. Coding assistants such as Claude Code can write a complete node if the prompt is specific: name the V3 format and comfy_api.latest, and list every input, output and the category. Read the result and test it in ComfyUI before relying on it.
How do I share or publish a ComfyUI custom node?
Put the folder on GitHub so people can clone it into custom_nodes. To list it in the Comfy Registry and the Manager, create a publisher account at registry.comfy.org, generate a pyproject.toml with comfy node init, and publish with comfy node publish using your registry API key.

