Task authoring and execution
Declaring a Task
Flyte tasks are just typed Python functions. The smallest working example is:
from flytekit import task
@task
def my_task(x: int, y: typing.Dict[str, str]) -> str:
...
When you apply @task (defined in flytekit/core/task.py), flytekit inspects the function's type annotations to infer an input/output Interface, builds a TaskMetadata from the decorator arguments, looks up a registered task plugin by the type of task_config, and instantiates that plugin class — by default PythonFunctionTask from flytekit/core/python_function_task.py. Coroutine functions are automatically routed to AsyncPythonFunctionTask instead.
@task(task_config=Spark(), retries=3)
def my_task(x: int, y: typing.Dict[str, str]) -> str:
...
If you pass an unrecognized keyword, the decorator fails fast: raise ValueError(f"Unrecognized argument(s) for task: {kwargs.keys()}"). After construction, update_wrapper(task_instance, decorated_fn) copies the wrapped function's metadata onto the returned task object, so my_task behaves like the original function from Python's perspective.
One thing worth knowing: task construction has a side effect. Task.__init__ appends every instance to FlyteEntities.entities, which is how flytekit later finds all tasks in a module for serialization.
Configuring behavior with TaskMetadata
TaskMetadata (in flytekit/core/base_task.py) is the dataclass that carries execution semantics: cache, cache_serialize, cache_version, cache_ignore_input_vars, interruptible, deprecated, retries, timeout, pod_template_name, generates_deck, and is_eager. Its __post_init__ enforces the rules so misconfiguration fails at definition time, not mid-run:
timeoutmay be anint(seconds, converted todatetime.timedelta) or atimedelta; anything else truthy raisesValueError.cache=Truerequires a non-emptycache_version.cache_serialize=Trueorcache_ignore_input_varsrequirecache=True.
The modern authoring path is the Cache object. When you pass cache=True without a cache_version, the decorator builds Cache(serialize=..., ignored_inputs=...) and computes a version from the function and container settings via cache.get_version(VersionParameters(...)). Mixing a Cache object with the deprecated cache_serialize/cache_version/cache_ignore_input_vars arguments raises ValueError.
Caching locally
Task.local_execute also honors caching in your local runs: if metadata.cache is set and caching is enabled in the local config, it consults LocalTaskCache.get(...) and stores outputs with LocalTaskCache.set(...), with cache_overwrite bypassing the read.
The Task Abstraction Hierarchy
flytekit's task classes form a layered hierarchy, each layer adding one capability:
Task base_task.py — FlyteIDL-oriented base: metadata, interface,
│ registration, __call__, local_execute
└─ PythonTask base_task.py — adds a native Python Interface, task_config,
│ environment, decks, compile()
└─ PythonAutoContainerTask — container/image/command serialization
├─ PythonFunctionTask python_function_task.py — wraps a user function
│ └─ AsyncPythonFunctionTask — coroutine tasks
│ └─ EagerAsyncPythonFunctionTask — behind @eager
└─ PythonInstanceTask — tasks with no user function body
Task is the layer closest to the FlyteIDL TaskTemplate. It stores the task_type, name, TypedInterface, TaskMetadata, task_type_version, security context, and docs, and defines the execution contract via three abstract methods: pre_execute, execute, and dispatch_execute. Calling a task (t1(a=5)) goes through Task.__call__, which delegates to flyte_entity_call_handler — the same entry point that distinguishes "being invoked inside a workflow during compilation" from "being run for real."
PythonTask adds the native Python interface on top. Its constructor accepts a task_config (the plugin-specific configuration object), environment variables, and deck settings; note that disable_deck is deprecated and that supplying both disable_deck and enable_deck raises ValueError. Deck fields are validated against the DeckField enum, and docstrings become Documentation automatically. get_input_types() returns the native {name: type} mapping, and compile() calls create_and_link_node to attach the task as a node when a workflow is being built.
How a Task Actually Runs
The local execution path in Task.local_execute shows the full pipeline:
- Inputs → literals.
translate_inputs_to_literalsconverts incoming values (which may be raw Python values,Promiseobjects from upstream tasks, or nested lists/dicts of either) into aLiteralMap, using the task's Flyte interface and native types. On failure, the error is re-raised with task context:Failed to convert inputs of task '{self.name}'. - Optional local cache (described above).
sandbox_execute, which builds a sandboxedExecutionParameters(with_task_sandbox()) and callsdispatch_execute— the method also used at real runtime.- Outputs → Promises. The resulting
LiteralMapis re-wrapped one output per declared interface output asPromise(var, literal), or as a singleVoidPromise(self.name)when the task declares no outputs. A length mismatch raisesAssertionError.
Inside PythonTask.dispatch_execute — the method invoked both locally and remotely on the cluster — the flow is:
pre_execute(user_params)runs (the default returns params unchanged; plugins like Spark override it to set up a session before type conversion).self._literal_map_to_python_input(...)converts the inputLiteralMapback to Python native kwargs viaTypeEngine.literal_map_to_kwargs.self.execute(**native_inputs)runs your function. Locally, the original exception is re-raised with a message likeError encountered while executing '<task name>'; remotely, it's wrapped inFlyteUserRuntimeException.post_execute(...)runs (a no-op by default; it's also whereIgnoreOutputs— the exception you can raise to signal outputs may be safely discarded, e.g. in distributed training — gets bubbled up)._output_to_literal_mapconverts native outputs back throughTypeEngine.async_to_literal, attaching any output metadata (partitions, etc.). If the raw result is already aLiteralMapor aDynamicJobSpec— as happens with dynamic tasks — it's returned as-is without conversion._write_decksemits the HTML decks ifenable_deckis set and the run is local.
Dynamic Tasks
What if the shape of your workflow isn't known until runtime — for example, you need for i in range(a) where a is an input? Ordinary workflows can't do that because they only run at compile time. @dynamic solves it:
@dynamic
def my_dynamic_subwf(a: int) -> (typing.List[str], int):
s = []
for i in range(a):
s.append(t1(a=i))
return s, 5
dynamic in flytekit/core/dynamic_workflow_task.py is literally functools.partial(task.task, execution_mode=PythonFunctionTask.ExecutionBehavior.DYNAMIC). At execution time, PythonFunctionTask.execute routes to dynamic_execute, which builds and caches a PythonFunctionWorkflow from your function (compile_into_workflow):
- Remotely (execution state
TASK_EXECUTION), the generated workflow is serialized and returned as aDynamicJobSpeccontaining the discovered task templates, nodes, outputs, and subworkflows. The Flyte engine then runs it like a subworkflow. A caveat:ReferenceTaskinside a dynamic task raisesValueError("Reference tasks are currently unsupported within dynamic tasks"), because resolving reference tasks requires a network call to flyteadmin. - Locally, the generated workflow is executed directly and outputs are converted to a
LiteralMap, mimicking a workflow's local execute.
Keep dynamic workflows small: the module docstring warns that a loop can easily produce thousands of nodes and recommends staying under roughly fifty tasks, using the map task for large identical runs.
If your dynamic body invokes a launch plan (which must be registered on flyteadmin before it can run), pass hints so it gets registered alongside the dynamic task:
@workflow
def workflow0():
...
launchplan0 = LaunchPlan.get_or_create(workflow0)
@dynamic(node_dependency_hints=[launchplan0])
def launch_dynamically():
return [launchplan0]*10
Passing node_dependency_hints to a static task raises ValueError — flyte finds node dependencies automatically there.
The default resolver also can't rehydrate nested or local functions, so PythonFunctionTask rejects them with ValueError("TaskFunction cannot be a nested/inner or local function..."). Test modules beginning with test_ are exempt, and custom decorators are fine if they use functools.wraps/functools.update_wrapper.
Async and Eager Tasks
If the decorated function is a coroutine, @task instantiates AsyncPythonFunctionTask. Its __call__ is await async_flyte_entity_call_handler(self, ...), and async_execute simply awaits the underlying function in DEFAULT mode. Dynamic mode is deliberately unsupported there and raises NotImplementedError — eager and dynamic don't mix.
EagerAsyncPythonFunctionTask (what @eager produces) goes further: instead of building a static graph, Python itself becomes the scheduler — every nested entity invocation creates an execution on the Flyte cluster. The constructor forces TaskMetadata(is_eager=True) and ExecutionBehavior.EAGER, deleting any execution_mode you supplied. A minimal runnable example:
from flytekit import task, eager
@task
def add_one(x: int) -> int:
return x + 1
@task
def double(x: int) -> int:
return x * 2
@eager
async def eager_workflow(x: int) -> int:
out = add_one(x=x)
return double(x=out)
if __name__ == "__main__":
import asyncio
result = asyncio.run(eager_workflow(x=1))
print(f"Result: {result}") # "Result: 4"
For a real backend run, point @eager at a remote:
@eager(
remote=FlyteRemote(config=Config.auto(config_file="config.yaml")),
client_secret_group="my_client_secret_group",
client_secret_key="my_client_secret_key",
)
async def eager_workflow(x: int) -> int:
out = await add_one(x)
return await double(out)
Mechanically, remote eager execution builds a Controller backed by FlyteRemote (obtained via get_plugin().get_remote(...)), installs a signal handler for SIGINT/SIGTERM on the main thread, and puts the worker queue into the FlyteContext. Nested entity calls enqueue work and await results; afterward render_html() produces an "Eager Executions" deck, and an EagerException causes the task to raise FlyteNonRecoverableSystemException so the run doesn't show as succeeded.
Two supporting pieces handle cleanup. EagerFailureTaskResolver is a TaskResolverMixin whose load_task always reconstructs an EagerFailureHandlerTask and whose loader args are fixed to ["eager", "failure", "handler"]. EagerFailureHandlerTask.dispatch_execute (remote-only) repeatedly lists unfinished executions tagged eager-exec matching the parent's execution name and terminates them — an on-failure handler wired in by get_as_workflow, which wraps the eager task in an ImperativeWorkflow with add_on_failure_handler(cleanup).
Custom Tasks and Resolvers
Two extension paths avoid writing a Python function body altogether:
-
PythonInstanceTaskis for platform-defined tasks with no user function — you overrideexecute. Use it asx = MyInstanceTask(name="x", ...)and then callx(a=5)per its declared interface. The class exists specifically so the module loader rehydrates the right instance at runtime by capturing the module name and variable name. -
TaskResolverMixindefines how a containerized task is serialized and rehydrated. The docstring shows what a serialized task's container command looks like:pyflyte-execute --inputs s3://path/inputs.pb --output-prefix s3://outputs/location \
--raw-output-data-prefix /tmp/data \
--resolver flytekit.core.python_auto_container.default_task_resolver \
-- \
task-module repo_root.workflows.example task-name t1After the boilerplate arguments, flytekit appends the resolver's
locationfollowed by whateverloader_args(settings, t)returns. The default resolver emits the task's module and name, and at runtimeload_taskdoesimportlib.import_moduleon the module and looks up the task by that key. Implement your own resolver (providinglocation,name,load_task,loader_args, and optionallyget_all_tasks/task_name) to support tasks that don't live at module level — notebooks, for instance.
Plugin registration is the other half of custom tasks: TaskPlugins.register_pythontask_plugin(config_type, plugin_type) associates a task_config type with a PythonFunctionTask subclass. @task(task_config=MyConfig()) then instantiates your plugin; registering the same config type twice with different plugin classes raises TypeError, and async functions require the plugin to derive from AsyncPythonFunctionTask.
Wrapping Tasks: Map Task Restrictions
map_task wraps an existing task to run it over a list, and it's picky about what it accepts:
@task
def my_mappable_task(a: int) -> typing.Optional[str]:
return str(a)
@workflow
def my_wf(x: typing.List[int]) -> typing.List[typing.Optional[str]]:
return map_task(
my_mappable_task,
metadata=TaskMetadata(retries=1),
concurrency=10,
min_success_ratio=0.75,
)(a=x).with_overrides(requests=Resources(cpu="10M"))
Only default-mode PythonFunctionTask or PythonInstanceTask are accepted — @dynamic and @eager tasks are rejected — and the underlying task may declare at most one output.
Quick Limitations Reference
- Caching combos are validated at construction (
TaskMetadata.__post_init__): nocache_version, no serialize/ignore-inputs. timeout=0is treated as unset; integer timeouts becometimedeltaseconds.disable_deckis deprecated; specifying it andenable_deckraisesValueError. Decks are off by default.- Untyped outputs require
pickle_untyped=True, which the source explicitly flags as not recommended for production. - Nested functions fail with the default resolver; use module-level definitions,
functools.wraps, or a customTaskResolverMixin. - No
ReferenceTaskinside dynamic tasks; no conditionals in eager tasks (use Pythonifinstead). - When you need a programmatic interface without a Python function — e.g. for reference entities — use
kwtypes:kwtypes(a=str, b=int)returns an ordered mapping usable asinputs=kwtypes(a=str, b=int)inget_reference_entity(...).