Workflow composition and nodes
The @workflow decorator in flytekit is the primary way to define a Flyte workflow. It takes a plain Python function and produces a PythonFunctionWorkflow object that can be run locally, compiled to a DAG, and registered with a Flyte cluster. Understanding how compilation works — and how the resulting Node objects connect — is key to using flytekit effectively.
The @workflow Decorator and Compilation
Here's the simplest possible workflow, taken from the flytekit README:
from flytekit import task, workflow
@task(cache=True, cache_version="1", retries=3)
def sum(x: int, y: int) -> int:
return x + y
@task(cache=True, cache_version="1", retries=3)
def square(z: int) -> int:
return z * z
@workflow
def my_workflow(x: int, y: int) -> int:
return sum(x=square(z=x), y=square(z=y))
When Python imports this module, @workflow creates a PythonFunctionWorkflow instance and stores the original function as self._workflow_function. The function body is not run at import time. It's run later, during compilation, to discover the DAG structure.
Compilation happens the first time you access .nodes, .output_bindings, or call the workflow. Inside PythonFunctionWorkflow.compile() (in flytekit/core/workflow.py), flytekit:
- Calls
construct_input_promises()to seed each declared input as aPromisepointing atGLOBAL_START_NODE. - Runs
self._workflow_function(**input_kwargs)inside aCompilationStatecontext — a context-local accumulator for nodes. - Each task or workflow call in the body invokes
create_and_link_node()(fromflytekit/core/promise.py), which creates aNodeand registers it with theCompilationState. - After the function returns,
compile()callsbinding_from_python_std()on the return value to produceoutput_bindings. - Sets
self.compiled = Trueso subsequent calls skip recompilation.
The critical implication: the workflow function body is run once to build the DAG, not to compute anything. Task calls during compilation return Promise objects, not real values. Code like if t1_output > 5: raises an error because t1_output is a Promise, not an integer. Similarly, range(t1_output) fails for the same reason.
@workflow
def bad_wf(n: int) -> int:
result = my_task(a=n)
# This would fail at compile time — result is a Promise, not an int!
for i in range(result):
...
Nodes and Promises: The Two Core Abstractions
Every entity call inside a workflow body produces two things: a Node and one or more Promises.
Nodes
A Node (defined in flytekit/core/node.py) represents one vertex in the DAG. It holds:
id— auto-generated string like"n0","n1"(DNS-ified); overridable viawith_overrides(node_name=...)metadata— aNodeMetadatacarrying the display name, timeout, retries, and cache settingsbindings— aList[Binding]describing each input to this node, where that input comes fromupstream_nodes— aList[Node]for explicit dependency ordering (separate from data flow)flyte_entity— the underlyingPythonTask,WorkflowBase, orLaunchPlan
Promises
When a task is called during compilation, create_and_link_node() returns a Promise (or a named tuple of Promise objects). Each Promise wraps a NodeOutput(node=<Node>, var="output_name"), pointing to a specific output variable on a specific upstream node.
When you pass that Promise as input to a downstream task, binding_from_python_std() inspects it, finds the upstream NodeOutput, and stores a Binding(var="input_name", binding=BindingData(promise=NodeOutput(...))) on the downstream Node. This is how data flow is encoded in the DAG: not by copying values, but by recording references.
Workflow inputs: GLOBAL_START_NODE
Workflow inputs are handled by construct_input_promises(), which creates a Promise for every declared input:
def construct_input_promises(inputs: List[str]) -> Dict[str, Promise]:
return {
input_name: Promise(var=input_name, val=NodeOutput(node=GLOBAL_START_NODE, var=input_name))
for input_name in inputs
}
GLOBAL_START_NODE is a module-level singleton Node with id="-" that serves as the notional source of all workflow inputs. During serialization, get_serializable_workflow() filters out nodes with id == GLOBAL_INPUT_NODE_ID so this sentinel never appears in the compiled template.
Connecting Sub-workflows
Calling another @workflow inside your workflow body is straightforward — the called workflow becomes a sub-workflow node:
@task
def add_5(a: int) -> int:
return a + 5
@workflow
def simple_wf() -> int:
return add_5(a=1)
@workflow
def my_wf_example(a: int) -> typing.Tuple[int, int]:
x = add_5(a=a)
z = add_5(a=x) # x is a Promise from the first call
d = simple_wf() # simple_wf becomes a WorkflowNode in the compiled DAG
e = conditional("bool").if_(a == 5).then(add_5(a=d)).else_().then(add_5(a=z))
return x, e
Named tuple outputs from tasks or sub-workflows can be unpacked directly or accessed by attribute:
@workflow
def wf(b: int) -> nt:
out = subwf(a=b) # out is a named tuple of Promises
return t1(a=out.named1) # access individual outputs by name
Workflow Decorator Parameters
@workflow accepts several keyword arguments that customize behavior. The two most used are failure_policy and interruptible:
from flytekit.core.workflow import WorkflowFailurePolicy
@workflow(
interruptible=True,
failure_policy=WorkflowFailurePolicy.FAIL_AFTER_EXECUTABLE_NODES_COMPLETE,
)
def wf(a: int) -> typing.Tuple[str, str]:
x, y = t1(a=a)
_, v = t1(a=x)
return y, v
interruptible=True propagates to WorkflowMetadataDefaults and marks all tasks in the workflow as runnable on preemptible (spot) instances by default. failure_policy controls what WorkflowMetadata.on_failure is set to:
WorkflowFailurePolicy.FAIL_IMMEDIATELY(default) — the workflow fails as soon as any node fails.WorkflowFailurePolicy.FAIL_AFTER_EXECUTABLE_NODES_COMPLETE— remaining runnable nodes continue executing; the workflow is marked failed only after all are done.
Failure Handlers
Pass on_failure= to register a task or workflow that runs when the workflow fails. The failure handler receives the same inputs as the workflow (plus an optional FlyteError parameter for extra error context):
from flytekit.types.error import FlyteError
@task
def clean_up(name: str, err: typing.Optional[FlyteError] = None):
print(f"Deleting cluster {name} due to {err}")
@workflow(on_failure=clean_up)
def wf(name: str = "flyteorg"):
c = create_cluster(name=name)
t = t1(a=1, b="2")
d = delete_cluster(name=name)
c >> t >> d
The failure handler's inputs must be a superset of the workflow's inputs. Any extra parameters the handler declares beyond the workflow's inputs must be Optional; otherwise flytekit raises FlyteFailureNodeInputMismatchException. During compilation, PythonFunctionWorkflow._validate_add_on_failure_handler() enforces this check and builds a separate _failure_node that is excluded from the main nodes list.
Explicit Node Creation with create_node()
Calling a task inside a workflow body implicitly creates a node and returns a Promise. That's enough when tasks produce outputs that feed downstream tasks. But two situations require the explicit create_node() function from flytekit/core/node_creation.py:
1. Tasks with no outputs. When a task returns nothing, calling it produces a VoidPromise. You can't assign ordering through data flow, so you need a Node reference for the >> operator:
from flytekit.core.node_creation import create_node
@workflow
def empty_wf():
t2_node = create_node(t2)
t3_node = create_node(t3)
t3_node >> t2_node # t3 must complete before t2 starts
2. Accessing outputs by string key. create_node() sets node._outputs to a dict, enabling both attribute access (node.output_name) and dict access (node.outputs["output_name"]). Without create_node(), the .outputs property raises AssertionError.
@workflow
def my_wf(a: str) -> (str, typing.List[str]):
t1_node = create_node(t1, a=a)
dyn_node = create_node(my_subwf, a=3)
return t1_node.o0, dyn_node.o0 # positional output names
Dependency Ordering with >>
The >> operator (defined as Node.__rshift__) calls runs_before(), which appends self to other._upstream_nodes. This adds upstream_node_ids to the serialized node without creating any data binding. You can chain it:
@workflow
def wf1(x: int):
task_a(x=x) >> task_b(x=x) >> task_c(x=x)
Because task calls inside a workflow return Promise objects, >> is also defined on Promise — flytekit delegates to the underlying node automatically, so you don't always need create_node() just to express ordering.
Per-Node Overrides with with_overrides()
Node.with_overrides() lets you customize node-level settings after the node is created, without changing the task definition. Call it on the return value of a task call (which is a Promise that delegates to its node) or on a Node from create_node():
@workflow
def my_wf(a: str) -> str:
s = t1(a=a).with_overrides(timeout=datetime.timedelta(seconds=100))
s2 = t2(a=s).with_overrides(retries=3)
return s2
Override resource requests and limits independently:
@workflow
def my_wf(a: typing.List[str]) -> typing.List[str]:
mappy = map_task(t1)
map_node = mappy(a=a).with_overrides(
requests=Resources(cpu="1", mem="100", ephemeral_storage="500Mi")
)
return map_node
The full set of overridable properties includes: node_name (display ID), aliases (output variable aliasing), requests, limits, resources (combined shorthand), timeout (int seconds or timedelta), retries, interruptible, name (display name), task_config, container_image, accelerator, cache (bool or Cache object), shared_memory, and pod_template.
A few constraints to keep in mind:
resources=cannot be combined withrequests=orlimits=in the same call.- When overriding
cache=Cache(...), theCacheobject must haveversionset —cache.versioncannot beNone. timeout=Noneexplicitly clears a previously set timeout (sets it totimedelta()), while omittingtimeoutentirely leaves the existing value unchanged. This distinction is implemented via theNode.TIMEOUT_OVERRIDE_SENTINELsentinel object.
Programmatic Workflow Construction with ImperativeWorkflow
@workflow requires you to express the DAG as a Python function body. ImperativeWorkflow (also aliased as Workflow when imported from flytekit.core.workflow) lets you build the same DAG programmatically — useful for generated pipelines, backfill workflows, or dynamic code-generation scenarios.
from flytekit.core.workflow import ImperativeWorkflow as Workflow
wb = Workflow(name="my_workflow")
wb.add_workflow_input("in1", str)
node = wb.add_entity(t1, a=wb.inputs["in1"])
wb.add_entity(t2)
wb.add_workflow_output("from_n0t1", node.outputs["o0"])
assert wb(in1="hello") == "hello world"
This is equivalent to:
nt = typing.NamedTuple("wf_output", [("from_n0t1", str)])
@workflow
def my_workflow(in1: str) -> nt:
x = t1(a=in1)
t2()
return nt(x)
ImperativeWorkflow maintains its own CompilationState from the moment of construction. Each call to add_entity() (or its convenience aliases add_task(), add_subwf(), add_launch_plan()) runs create_node() in the context of that state, building up the node list incrementally. Sub-workflows and launch plans work the same way:
wb2 = ImperativeWorkflow(name="parent.imperative")
p_in1 = wb2.add_workflow_input("p_in1", str)
p_node0 = wb2.add_subwf(wb, in1=p_in1) # wb becomes a WorkflowNode
wb2.add_workflow_output("parent_wf_output", p_node0.from_n0t1, str)
add_workflow_input() returns a Promise pointing at GLOBAL_START_NODE and tracks it as unbound until it's consumed by add_entity(). The ready() method returns True only when the node list is non-empty and no declared inputs remain unbound.
A real-world example: flytekit's backfill generator (flytekit/remote/backfill.py) uses ImperativeWorkflow to build a sequence of launch plan nodes, using runs_before() to enforce serial execution:
wf = ImperativeWorkflow(name=f"backfill-{for_lp.name}", failure_policy=failure_policy)
prev_node = None
while True:
next_start_date = date_iter.get_next()
...
next_node = wf.add_launch_plan(for_lp, **inputs)
next_node = next_node.with_overrides(
name=f"b-{next_start_date}", retries=per_node_retries, timeout=per_node_timeout
)
if not parallel and prev_node:
prev_node.runs_before(next_node)
prev_node = next_node
From DAG to Serialized Template
When get_serializable() (from flytekit/tools/translator.py) is called on a workflow, it accesses workflow.nodes and workflow.output_bindings — triggering compile() on a PythonFunctionWorkflow if it hasn't been called yet. Then get_serializable_node() converts each Node to a workflow_model.Node, carrying:
- A
task_node,workflow_node, orbranch_nodediscriminator (determined by the type ofnode.flyte_entity) - The
bindingslist (input connections) upstream_node_ids(fromnode.upstream_nodes)- Node metadata (name, timeout, retries, cache settings)
The resulting WorkflowTemplate is what gets registered with the Flyte platform. The workflow function itself is not run again on the cluster — only each task's execute() method runs remotely.