Workflow composition, failure handlers, and nodes
Introduction to Workflow Composition
A Flyte workflow is a directed acyclic graph (DAG) of tasks where edges are defined by data flow — the output of one task becomes the input of another. When you decorate a function with @workflow, flytekit reads the function body at serialization time (not at remote execution time) to discover how tasks connect.
This distinction matters: the workflow function body is evaluated once when flytekit compiles the DAG structure, not every time the workflow runs on the Flyte platform. This means any plain Python code inside the body — list construction, loops, conditionals — runs during local testing and compilation, but will not execute on the remote Flyte cluster. Only calls to Flyte entities (tasks, sub-workflows, launch plans) become part of the compiled graph.
The @workflow Decorator
Declare a workflow by decorating a function with @workflow:
from flytekit import workflow, task
from flytekit.core.workflow import WorkflowFailurePolicy
@task
def t1(a: int) -> int:
return a + 2
@task
def t2(b: str) -> str:
return f"hello {b}"
@workflow
def my_wf(a: int, b: str = "world") -> (int, str):
x = t1(a=a)
y = t2(b=b)
return x, y
The decorator accepts these optional parameters:
| Parameter | Type | Default | Purpose |
|---|---|---|---|
failure_policy | WorkflowFailurePolicy | FAIL_IMMEDIATELY | Whether to fail immediately or let remaining runnable nodes complete |
interruptible | bool | False | Default interruptibility for all tasks in this workflow |
on_failure | WorkflowBase or Task | None | A handler invoked when the workflow fails |
docs | Documentation | None | Extracted from the function docstring if not provided |
pickle_untyped | bool | False | Bypass type checking (not recommended) |
default_options | Options | None | Default labels/annotations for the generated launch plan |
In this example, my_wf calls t1 and t2 and returns both results. The call t1(a=a) does not return an integer — it returns a Promise object (explained below). The DAG structure emerges from which outputs feed into which inputs.
Task Outputs and Promises
When you write x = t1(a=a) inside a workflow, x is not an int — it is a Promise object that represents the future output of the t1 node. Flytekit uses Promise to bridge two modes:
Compilation Mode vs. Local Execution Mode
During compilation (when flytekit serializes the workflow), Promise.is_ready is False and the Promise.ref property points to a NodeOutput that references the producing node. During local execution, Promise.is_ready is True and Promise.val holds the actual Literal value.
# Inside a workflow body
p = t1(a=5) # p is a Promise, not an int
print(p.is_ready) # False during compilation, True during local execution
Accessing Nested Outputs
Promise supports attribute chaining and indexing to access nested outputs:
@task
def get_user() -> UserData:
return UserData(name="alice", scores=[95, 87])
@workflow
def analyze():
u = get_user()
# Attribute access
name = u.name # new Promise with attr_path ["name"]
# Index access
first_score = u.scores[0] # new Promise with attr_path ["scores", 0]
Each access calls Promise._append_attr(key), which creates a new Promise with an extended attr_path (and a new NodeOutput with an extended attribute path for remote execution). The original promise is not modified, so you can use it in multiple places.
The VoidPromise
Tasks that declare no outputs return a VoidPromise instead:
@task
def log_message(msg: str):
print(msg)
@workflow
def wf():
result = log_message(msg="hello") # result is a VoidPromise
VoidPromise rejects nearly all operations — comparison, arithmetic, boolean testing — with an AssertionError message like "Task log_message returns nothing, NoneType return cannot be used". It only supports with_overrides() and __rshift__() for ordering.
Promise Comparisons for Conditionals
Promises support comparison operators (==, !=, >, <, >=, <=) that return ComparisonExpression objects instead of booleans. Combine expressions with & (AND) and | (OR) to form ConjunctionExpression objects:
@workflow
def wf(x: int):
# Comparison produces a ComparisonExpression, not a boolean
expr = (promise_output > 5) & (promise_output < 10)
# But THIS raises ValueError:
# if promise_output: # ValueError!
Python's and/or keywords trigger __bool__, and both Promise.__bool__ and ComparisonExpression.__bool__ raise ValueError with instructions to use &/| instead. Use conditional() from flytekit.core.condition for workflow branching.
with_overrides on Promises
Calling with_overrides() on a Promise delegates to the underlying Node.with_overrides(). This lets you chain overrides directly on task calls:
x = t1(a=5).with_overrides(retries=3, timeout=timedelta(minutes=30))
The Node Class and create_node()
The Node class (flytekit.core.node.Node) is the internal representation of a DAG vertex. Each node stores an id, metadata (NodeMetadata), bindings (input bindings), upstream_nodes (dependency list), and flyte_entity (the task or workflow).
When you call a task normally (t1(a=5)), flytekit creates a node automatically. But some patterns require explicit node creation via create_node():
Explicit Dependency Ordering
Tasks that don't consume each other's outputs have no data-dependency edge, so flytekit would be free to run them in any order. Use create_node() plus runs_before() (or the >> operator) to enforce sequencing:
from flytekit.core.node_creation import create_node
@task
def t2():
...
@task
def t3():
...
@workflow
def empty_wf():
t2_node = create_node(t2)
t3_node = create_node(t3)
t3_node.runs_before(t2_node)
# Equivalent: t3_node >> t2_node
Note that runs_before modifies the other node's _upstream_nodes list, not self:
# From Node.runs_before source
def runs_before(self, other: Node):
if self not in other._upstream_nodes:
other._upstream_nodes.append(self)
Accessing Outputs from create_node
When a task returns outputs, create_node attaches each output as both a named attribute and a dictionary entry on the returned Node:
@task
def t1(a: str) -> str:
return a + " world"
@task
def t2(a: int) -> (int, str): # two outputs
return a + 1, str(a)
@workflow
def my_wf(a: str) -> (str, int, str):
t1_node = create_node(t1, a=a)
t2_node = create_node(t2, a=3)
# Access by attribute name
return t1_node.o0, t2_node.o0, t2_node.o1
# Also accessible via dict:
# t1_node.outputs["o0"]
# t2_node.outputs["o0"], t2_node.outputs["o1"]
For single-output tasks, the output name is o0 by default. For named-tuple outputs, the names from the NamedTuple declaration are used. If a node already has an attribute matching the output name, create_node raises FlyteAssertion.
The >> Operator on Nodes
The right-shift operator is syntactic sugar for runs_before:
t3_node >> t2_node # same as t3_node.runs_before(t2_node)
When used on Promise or VoidPromise, the operator chains the underlying nodes:
@workflow
def wf():
c = create_cluster(name=name)
t = t1(a=1, b="2")
d = delete_cluster(name=name)
c >> t >> d # c runs before t, t runs before d
create_node Requires Keyword Arguments
Passing positional arguments raises FlyteAssertion:
# This fails:
create_node(t1, "hello") # FlyteAssertion: Only keyword args are supported
# This works:
create_node(t1, a="hello")
Node.outputs Raises on Non-create_node Nodes
The outputs property checks whether _outputs has been populated. Only nodes created via create_node() have this set. Calling node.outputs on a node created through a normal task call raises AssertionError.
Per-Node Overrides Deep Dive
The Node.with_overrides() method mutates the node in-place and returns self. It accepts these parameters:
| Parameter | Type | Description |
|---|---|---|
node_name | str | Custom node ID (DNS-sanitized via _dnsify) |
aliases | Dict[str, str] | Rename output variables for the compiled workflow |
resources | Resources | CPU/memory requests and limits (preferred API) |
requests / limits | Resources | Deprecated — use resources instead |
timeout | int or timedelta | Execution timeout (None unsets it, 0 means no timeout) |
retries | int | Number of retry attempts |
interruptible | bool | Whether the task can be interrupted |
cache | bool or Cache | Enable caching with optional Cache(version=..., serialize=...) |
container_image | str | Override the container image |
accelerator | BaseAccelerator | GPU/accelerator requirement |
shared_memory | bool or str | Shared memory configuration |
pod_template | PodTemplate | Custom pod specification |
Resource Conflicts
The resources parameter cannot be combined with limits or requests. Doing so raises ValueError:
# Raises ValueError
node.with_overrides(resources=Resources(cpu="2"), limits=Resources(cpu="1"))
# Correct
node.with_overrides(resources=Resources(cpu="2", mem="1Gi"))
Cache Override Rules
Two forms are supported:
# Form 1: boolean True (uses default Cache settings)
node.with_overrides(cache=True)
# Form 2: Cache object with explicit version
node.with_overrides(cache=Cache(version="v1", serialize=False))
The Cache object must specify a version. Setting cache=True without cache_version enables caching with default policy. Mixing the Cache object with the deprecated cache_serialize or cache_version keyword arguments raises ValueError.
Name Sanitization
Node IDs pass through _dnsify() to ensure DNS compliance. A custom name set via node_name is sanitized automatically.
on_failure Handlers
When a workflow raises an exception, you can specify a handler task (or sub-workflow) to run for cleanup or notification. The handler must accept every workflow input and optionally an err: Optional[FlyteError] parameter.
Decorated Workflow with on_failure
from flytekit import task, workflow
from flytekit.types.error.error import FlyteError
import typing
@task
def clean_up(name: str, err: typing.Optional[FlyteError] = None):
print(f"Deleting cluster {name} due to {err}")
@task
def create_cluster(name: str):
print(f"Creating cluster: {name}")
@task
def delete_cluster(name: str, err: typing.Optional[FlyteError] = None):
print(f"Deleting cluster {name}")
print(err)
@task
def t1(a: int, b: str):
print(f"{a} {b}")
raise ValueError("cluster setup failed")
@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
When t1 raises, WorkflowBase.__call__ catches the exception (see workflow.py line 317):
try:
return flyte_entity_call_handler(self, *args, **input_kwargs)
except Exception as exc:
if self.on_failure:
if self.on_failure.python_interface and "err" in self.on_failure.python_interface.inputs:
id = self.failure_node.id if self.failure_node else ""
input_kwargs["err"] = FlyteError(failed_node_id=id, message=str(exc))
self.on_failure(**input_kwargs)
raise exc
The FlyteError object is populated with the failed node's ID and the exception message. The handler receives all original workflow inputs plus err, then the exception is re-raised.
Signature Validation
During compilation, _validate_add_on_failure_handler checks two invariants (lines 800-811):
- Workflow inputs must be a subset of the handler's inputs. If the handler is missing even one workflow input,
FlyteFailureNodeInputMismatchExceptionis raised. - Any additional handler inputs beyond the workflow's inputs must be
Optional. This enforces that the only extra data the handler can receive is optional — primarily theerrparameter.
# This validates:
if (failure_node_inputs | workflow_inputs) != failure_node_inputs:
raise FlyteFailureNodeInputMismatchException(...)
additional_keys = failure_node_inputs.keys() - workflow_inputs.keys()
for k in additional_keys:
if not is_optional_type(failure_node_inputs[k]):
raise FlyteFailureNodeInputMismatchException(...)
Imperative Workflow with on_failure
For ImperativeWorkflow, call add_on_failure_handler():
from flytekit.core.workflow import ImperativeWorkflow
from flytekit.core.python_function_task import EagerFailureHandlerTask
wb = ImperativeWorkflow(name="my.workflow.a")
in1 = wb.add_workflow_input("in1", int)
wb.add_workflow_input("in2", int)
in3 = wb.add_workflow_input("in3", int)
node = wb.add_entity(t1, a={"a": [in1, wb.inputs["in2"]], "b": [wb.inputs["in2"], in3]})
wb.add_workflow_output("from_n0t1", node.outputs["o0"])
wb.add_entity(t2)
failure_task = EagerFailureHandlerTask(name="sample-failure-task", inputs=wb.python_interface.inputs)
wb.add_on_failure_handler(failure_task)
Internally, add_on_failure_handler performs the same signature validation, creates a node via create_node, then pops it from the compilation state so it doesn't appear in the main workflow graph. The node's ID is set to the constant DEFAULT_FAILURE_NODE_ID.
ImperativeWorkflow
ImperativeWorkflow (flytekit.core.workflow.ImperativeWorkflow) is a programmatic alternative to the @workflow decorator. Use it when you need to construct workflows dynamically rather than declaring them as function bodies.
Basic Construction
from flytekit.core.workflow import Workflow # alias for ImperativeWorkflow
from flytekit import task
import typing
@task
def t1(a: str) -> str:
return a + " world"
@task
def t2():
print("side effect")
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"])
# Local execution
result = wb(in1="hello") # "hello world"
This is identical on the backend to:
nt = typing.NamedTuple("wf_output", [("from_n0t1", str)])
@workflow
def my_workflow(in1: str) -> nt:
x = t1(a=in1)
t2()
return nt(x)
API Overview
| Method | Description |
|---|---|
add_workflow_input(name, python_type) | Declare a workflow-level input. Returns a Promise also stored in self.inputs[name]. |
add_entity(entity, **kwargs) | Add a task, sub-workflow, or launch plan node. Returns the Node. |
add_workflow_output(name, value) | Declare a workflow-level output binding. |
add_on_failure_handler(entity) | Set a failure handler (validated, then stored separately). |
add_subwf(name) | Add a nested workflow. |
inputs | Dict[str, Promise] — the declared input promises. |
Unbound Input Detection
ImperativeWorkflow tracks which declared inputs have been consumed via _unbound_inputs. If you declare an input but never pass it to any entity, flytekit raises an error early (rather than waiting for Admin's compile-time check).
Gotchas and Common Mistakes
Workflow Body is Compile-Only
Plain Python code inside @workflow runs only during local testing. It does not execute on the remote Flyte cluster.
# BAD: This print and conditional won't run remotely
@workflow
def bad_wf(x: int):
print("hello") # only during local execution
if x > 5: # only during local execution
y = t1(a=x)
Use conditional() from flytekit.core.condition for runtime branching, and implement side effects in tasks.
Promises Are Not Python Values
len(task_output)raisesValueErrorrange(task_output)raisesValueErrorif task_output:raisesValueError(usetask_output.is_true()or comparison)and/oron promises raisesValueError(use&/|instead)
Node.outputs is Not Always Available
Calling node.outputs on a node created through a normal task call (not create_node) raises AssertionError. Only nodes produced by create_node() have the _outputs dict populated.
create_node Accepts Only Keyword Arguments
create_node(t1, "hello") # FlyteAssertion
create_node(t1, a="hello") # Correct
on_failure Handler Signature is Strictly Enforced
The handler must accept every workflow input. Any additional parameters beyond the workflow's inputs must be Optional. A missing required parameter raises FlyteFailureNodeInputMismatchException.
With Overrides Mutates the Node
Node.with_overrides() modifies attributes in-place and returns self. If you hold multiple references to the same Node, all references see the changes.
Cache Override Requires a Version
node.with_overrides(cache=Cache(version="v1")) # OK
node.with_overrides(cache=Cache()) # ValueError: must specify cache version
Node Names Are DNS-Sanitized
Custom node names pass through _dnsify(), which ensures the name complies with Kubernetes DNS subdomain rules. The sanitized name is what flies to the platform, not necessarily the string you provided.