ComfyUI Workflow JSON vs PNG Metadata: Which One to Ship
ComfyUI workflow JSON vs PNG metadata: what each format holds, why tEXt chunks vanish on re-encode, and how to extract and store the graph.
A graph that reproduced cleanly last month comes back from a collaborator as a PNG. You drag it onto the canvas and get either the right node layout with the wrong widget values, or nothing at all, because the file passed through a chat client that resized it. That is the working form of the comfyui workflow json vs png metadata question: not which format is tidier, but which one still holds your graph after it crossed a messaging app, an asset manager and a CDN. The two carry different payloads, fail differently, and only one behaves like a durable artifact.
What ComfyUI actually writes into an image
ComfyUI does not embed one blob. It embeds two, under separate keys. The official metadata documentation defines workflow as “the complete workflow graph, including nodes, links, and layout information” and prompt as “the API prompt used to execute the workflow,” containing the nodes and inputs required for execution.
workflow is the canvas: node positions, sizes, colors, groups, reroutes, collapsed state, muted nodes, and every widget value as the editor holds it. prompt is what the backend received after the frontend resolved that canvas, so it excludes anything muted or disconnected. Load an image back and ComfyUI prioritizes the workflow field when both are present.
Where those keys live depends on the container. PNG uses tEXt chunks. Animated WebP writes EXIF tags shaped like workflow:{JSON} and prompt:{JSON}. MP4 and WebM use container metadata tags; .latent and .safetensors outputs use safetensors metadata. Same two keys, four extraction paths, which is the first thing that bites a script written against PNG only.
The mechanism is visible in the SaveImage node source. It builds a PngInfo() only when args.disable_metadata is false, adds prompt from the execution payload, iterates extra_pnginfo adding each key as its own chunk, then saves with pnginfo=metadata at compress_level=4. The workflow key exists only because the browser frontend populated extra_pnginfo. A headless POST /prompt that omits it yields images carrying prompt and nothing else, which is the file that later loads as half a workflow.
Workflow JSON and API JSON are not the same file
People say “the JSON” and mean one of two exports. The browser’s save produces the full workflow graph, layout included. File > Export (API) produces the API format, which per the API format documentation drops positions, colors, groups and node sizes, and keeps numeric node IDs, class_type, an inputs dictionary, and a _meta title. Wiring is expressed as ["4", 0], meaning output index 0 of node 4.
That maps directly onto the PNG keys. The workflow chunk is the save format. The prompt chunk is the API format. So a PNG is a superset of both .json exports, and an API-format .json is the one file you cannot open in the editor and get your layout back.
Which you want depends on the consumer. Automation wants API format: it is what the queue endpoint accepts, and it stays stable when someone drags a node twenty pixels left. Humans want the save format, because a 60-node graph with no groups or positions is unreadable. Archives want both. If you are reasoning about which nodes actually ran rather than which ones are on screen, the pull-based execution model explains why the two counts differ.
The property that matters: survival across a re-encode
Reproducibility is the headline metric, but the operational one underneath it is survivability: the odds the graph is still attached after N hops. PNG gives you no guarantee there.
tEXt, zTXt and iTXt are ancillary chunks in the W3C PNG specification. Ancillary means a decoder may safely ignore them. Any tool that decodes to a pixel buffer and re-encodes writes a new PNG with the chunks the encoder was told to write, which by default is none. In Pillow, text chunks are read into Image.text on open, but writing them back requires explicitly passing a PngInfo instance as pnginfo=. Nothing carries across a thumbnail job, a format conversion, or a strip-metadata step in an upload pipeline unless somebody wrote that line.
The provenance world already conceded this. The C2PA FAQ acknowledges that manifests can be separated from the asset, which is why the standard added soft bindings such as watermarking and fingerprinting to recover credentials after removal. ComfyUI has no equivalent recovery path, and its metadata documentation says so plainly: embedded metadata “is not a digital signature. It does not prove who created or modified a file.”
Treat the .json as the artifact of record and the PNG chunk as an opportunistic copy. That is the same split a model registry enforces between the run record and the output file, and it is worth adopting for the same reason: the record has to outlive the artifact.
Wiring it up: extract, verify, store
Do not trust a PNG until something has parsed it. This sweeps an output tree, writes sidecars for both blobs, and reports strips instead of skipping them silently:
import json, sys
from pathlib import Path
from PIL import Image
KEYS = ("workflow", "prompt")
def extract(png_path: Path) -> dict:
with Image.open(png_path) as im:
if im.format != "PNG":
raise ValueError(f"{png_path}: not a PNG ({im.format})")
chunks = dict(im.text) # tEXt, zTXt and iTXt
blobs = {}
for key in KEYS:
raw = chunks.get(key)
if raw is None:
continue
try:
blobs[key] = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"{png_path}: {key} chunk is not valid JSON: {exc}") from exc
return blobs
def sidecar(png_path: Path) -> None:
blobs = extract(png_path)
if not blobs:
print(f"STRIPPED {png_path}", file=sys.stderr)
return
for key, graph in blobs.items():
target = png_path.with_suffix(f".{key}.json")
target.write_text(json.dumps(graph, indent=2, ensure_ascii=False))
node_count = len(graph.get("nodes", graph))
print(f"{target} nodes={node_count}")
for path in sorted(Path("output").rglob("*.png")):
sidecar(path)
The graph.get("nodes", graph) fallback covers both shapes: the save format keeps a nodes list, while the API format is a flat dictionary keyed by node ID, so its length is the node count.
What you’ll see when it works, and when it doesn’t
Healthy output prints two sidecars per image, with the workflow node count greater than or equal to the prompt count. Equal counts mean nothing was muted or bypassed; a lower prompt count is normal on graphs with disabled branches.
STRIPPED on files from outside your pipeline is expected, not a script bug. STRIPPED on files straight out of output/ means one of three things: the server runs with --disable-metadata, the Desktop setting “Disable saving prompt metadata in files” is on, or a custom save node replaced SaveImage and never implemented chunk writing. Custom savers with WebP or JPEG options are the usual culprit.
A file with prompt but no workflow is the headless-submission signature described above. It will queue and run; it will not restore a canvas.
Caveats
Chunk size is bounded. Pillow limits an individual compressed chunk to MAX_TEXT_CHUNK, one megabyte by default, and all text chunks combined to MAX_TEXT_MEMORY, 64 MB by default. A reader that hits either ceiling raises rather than returning a partial graph.
Encoding is the quieter trap. tEXt is Latin-1 by specification and iTXt is UTF-8. Pillow’s PngInfo.add_text catches the UnicodeError and falls back to add_itxt when a value will not encode as Latin-1, so a prompt containing CJK text or an emoji lands in a different chunk type. An extractor that reads tEXt only reports those files as stripped.
Metadata is also a disclosure surface. The blobs carry full prompt text, LoRA and checkpoint filenames, and often absolute local paths from loader nodes; publishing an image publishes all of it. Treat an inbound workflow JSON as executable input rather than a document, too: it names node classes your instance will try to resolve and install, which is the supply-chain shape worth threat-modelling before you drag a stranger’s file onto a shared canvas.
Last, node counts are a cheap check, not a correctness check. Two graphs can carry identical counts and different seeds, schedulers or model hashes. If reproducibility is the goal, diff the parsed prompt blob field by field and pin it in version control. The image is a convenience copy.
Sources
Related
How ComfyUI Graphs Execute and Where VRAM Goes
Node graph execution order, result caching, VRAM pressure across the three failure stages, and custom node dependency management in ComfyUI.
How to Install ComfyUI Manager: A Setup Guide
Install or enable ComfyUI Manager on Desktop, Windows Portable, Linux and macOS, fix missing menus, and verify custom nodes before changing workflows.
ComfyUI GPU Requirements: How Much VRAM You Need
Published weight sizes for SD 1.5, SDXL and Flux mapped to real VRAM tiers, plus the system RAM, disk and runtime flags that lower the floor.