Skip to content

Passing Values That Cannot Be Serialized

Some values cannot be turned into data. A diffusers pipeline, a latent tensor, an open file handle, a live driver — there is no JSON for them. If your library's nodes execute in a worker subprocess (see Node Isolation with Workers), parameter values travel between the orchestrator and your worker as JSON, so passing one of these from one of your nodes to the next needs help.

The short version: mark the producing output serializable=False and assign the object to it. The consumer declares nothing and reads the object normally.

class LoadPipeline(ControlNode):
    def __init__(self, **kwargs) -> None:
        super().__init__(**kwargs)
        self.add_parameter(
            Parameter(
                name="pipeline",
                output_type="Pipeline",
                tooltip="The loaded pipeline",
                serializable=False,
                allowed_modes={ParameterMode.OUTPUT},
            )
        )

    def process(self) -> None:
        self.parameter_output_values["pipeline"] = load_pipeline(...)


class Generate(ControlNode):
    def __init__(self, **kwargs) -> None:
        super().__init__(**kwargs)
        # No declaration needed on the consuming side.
        self.add_parameter(Parameter(name="pipeline", input_types=["Pipeline"], tooltip="Pipeline to run"))

    def process(self) -> None:
        pipeline = self.get_parameter_value("pipeline")  # the real object
        ...

What actually happens

The object stays in the process that built it. When the value is about to cross a process boundary the engine substitutes an opaque key — a short string — and sends that instead. When your consuming node reads its parameter, the key becomes the object again.

Three consequences worth internalising:

  • Your node's own dicts always hold the real thing. Assign a pipeline to parameter_output_values["pipeline"] and read it straight back and you get the pipeline, not a key. Nothing is substituted on a write.
  • A graph that never leaves the process never does any of this. If your library declares no execution dependencies, values pass by reference exactly as they always have.
  • Only the producer declares. The key travels down connections to consumers that declare nothing at all, which is why the consuming parameter above is an ordinary Parameter.

What serializable=False means

It has two effects:

  • The value isn't saved. It's left out of saved workflow files, so its node runs again when the workflow reopens.
  • The value stays in the process that made it. On an output of a node that executes in a worker, the engine holds the value and sends a key in its place. Plain data, such as text, numbers, and lists and dicts of them, is still sent as-is.

Only mark an output serializable=False when its value can't be serialized, like a pipeline, a tensor, or a driver.

Reusing an expensive resource across runs

The above covers values flowing between nodes. A resource you want to build once and reuse — a pipeline whose load takes 30 seconds — needs a key you can derive again, so it has its own small API on the node:

def process(self) -> None:
    key = self.local_objects.key_for(self._config_hash())
    pipeline = self.local_objects.get(key)
    if pipeline is None:
        pipeline = build_pipeline(...)
        self.local_objects.put(pipeline, key=self._config_hash(), on_drop=release_vram)
    ...
Call Does
local_objects.put(value, *, key, on_drop=None) holds value under your key, returns the full namespaced key
local_objects.get(key) the object, or None if this process is not holding it
local_objects.key_for(suffix) the full key for a suffix you chose, without putting anything
local_objects.drop(key) releases one object; returns whether it was holding it
local_objects.drop_all() releases everything your library holds here; what a "clear cache" node calls

put under a key you already used releases what was there first, unless it is the same object — so rebuilding under an unchanged hash does not strand the old one.

Freeing what the object holds

Dropping the last Python reference does not free VRAM. Pass on_local_object_drop on the parameter (or on_drop to put) and the engine calls it when the object is released:

Parameter(
    name="pipeline",
    output_type="Pipeline",
    tooltip="The loaded pipeline",
    serializable=False,
    allowed_modes={ParameterMode.OUTPUT},
    on_local_object_drop=lambda pipeline: pipeline.to("cpu"),
)

The hook belongs to the cache, so it runs when the cache lets an object go:

  • your node runs again and the cache takes a new object for that parameter — the one it was holding is released;
  • your node is deleted and nothing else still refers to the object;
  • your library is unloaded, or every library is reloaded;
  • the workflow is closed or cleared.

It runs once per object, even when one object sits on two outputs.

What it does not cover is an object that never reached the cache. If your library declares no execution dependencies there is no process boundary, so nothing is ever cached and the value simply passes by reference the way it always has — nothing for the cache to release, and freeing it is yours to do as it was before. The same is true of an object you overwrite mid-run: only what the parameter holds when the node finishes goes in. Two other cases where the hook will not have run: updating a single library or switching its git ref does not restart its worker today, so objects that worker holds survive into the new code.

What you cannot do

A list or dictionary parameter cannot hold a value. ParameterList and ParameterDictionary build their value from their children, so there is no single object to hold and nowhere to put a release hook. Declaring serializable=False on one still does what it always did — keeps the list out of saved workflows — but it adds no caching.

Output the whole batch on an ordinary Parameter marked serializable=False — a list of tensors is one object as far as holding is concerned, and that works. A ParameterList consuming held values is fine: each row carries its own key, and get_parameter_value on the container gives you the objects. Prefer that over get_parameter_list_value for held values, because the latter flattens anything iterable and takes a list of tensors apart into their rows.

An object cannot leave the process that built it. The cache belongs to the worker, not to your library: one worker can host several libraries and they share it, so a co-hosted library handed a key resolves it fine. What does not work is reading a key from a different process — another worker, or the orchestrator:

Attempted to read the value for parameter 'pipeline' on node 'Generate'. Failed due to: it is held in another process, and an object cannot leave the process that built it. Read it from a node that runs in the same place as the one that made it, or have that node output a saved file instead.

Two libraries share a worker only when neither declares its own execution venv, and that is not something a graph author can see. So if you ship a library that hands objects to a different library's nodes, do not rely on co-hosting — write a file and pass its path or URL. Within your own library you are always in one worker.

Keys you choose through put are namespaced by your library inside the worker, so a co-tenant using the same string for its own cache cannot collide with yours.

An input is never cached. Caching one would mint a key the far side has nothing to resolve against: the object is in the sending process while the node runs elsewhere. Only outputs go in the cache, so an object handed to a worker-bound node as an input value gets whatever the transport makes of it, exactly as before. If you need it over there, have the node that produces it run in the same library, or write a file.

Nothing is held across a reload. Keys refer to memory in a running process.

Errors you may see, and what they mean

Message contains Means
it is no longer available, which happens after the workflow is reloaded or the node that made it is re-run the key was minted in this process and what it named has since been released. Re-run the producer; this is working as intended, not a lost object
held in another process you are reading it from somewhere other than the process that built it — most often from validation or a value hook, which run in the orchestrator rather than in your worker
nothing is connected to it the input is unwired

That second one is a feature. Keys are unique per assignment, so a consumer holding one from a previous run finds it dangling rather than silently resolving to a newer object it was never given.

Saving and metadata

A held value is never written into a saved workflow, and a node carrying one comes back UNRESOLVED so its producer re-runs and replaces it. Workflow metadata and image sidecars report the parameter as omitted rather than recording a key. You do not have to do anything for this; it is the same declaration doing the work.

Checklist

  • Producing output declared serializable=False, with on_local_object_drop if releasing it takes more than dropping a reference.
  • Consuming parameter declares nothing.
  • Producer and consumer run in the same worker, which is automatic within one library.
  • Only outputs whose value can't be serialized are marked serializable=False.
  • If your library declares no execution dependencies, do not rely on the release hook: nothing is cached in-process, so nothing is released.
  • Batches go on an ordinary parameter, not a ParameterList output.