Task authoring and execution
Declaring a task with @task
When a Python function should become a Flyte task, annotate its inputs and return value and decorate it with @task. The decorator is the normal construction path for a PythonFunctionTask; the annotations become the task's typed interface rather than being used only by a static type checker.
from flytekit import task
@task
def my_task(x: int, y: dict[str, str]) -> str:
...
For a plugin-specific task, pass its configuration and task options to the decorator:
@task(task_config=Spark(), retries=3)
def my_task(x: int, y: dict[str, str]) -> str:
...
The examples above are in the task decorator's source documentation. task.task builds TaskMetadata, selects the task implementation for the supplied configuration, and returns a task object while preserving the function wrapper. Coroutine functions are routed to AsyncPythonFunctionTask; ordinary annotated functions use PythonFunctionTask.
The task abstraction ladder
Flytekit separates the Flyte task model from Python-specific invocation:
Taskinbase_task.pyis the IDL-oriented base. It stores the task type, name, typed Flyte interface, metadata, task-type version, security context, and documentation. Constructing a task also registers it inFlyteEntities.entities.PythonTaskextendsTaskwith a Python-nativeInterface, Python input/output types, workflow-node compilation, and the literal/native conversion pipeline. It is the extension base for tasks that have Python interfaces but do not necessarily execute a user function.PythonFunctionTaskextends the Python auto-container task path and bindsPythonTask.executeto a user callable. It derives its interface withtransform_function_to_interface, reads the function's docstring throughDocstring, removes anyignore_input_vars, and derives the default name from the function's module.PythonInstanceTaskis the alternative for a platform-defined implementation. It is an abstract base for tasks without a user-defined function body; a subclass suppliesexecute, while the instance remains addressable by its module and variable name for task loading.
You normally do not instantiate Task directly: dispatch_execute, pre_execute, and execute are abstract there. PythonTask supplies the practical dispatch implementation, and PythonFunctionTask supplies the user-function implementation.
Configure task metadata
Use task options on @task, or construct TaskMetadata when a task wrapper accepts metadata directly. The metadata object carries retries, timeout, cache settings, interruptibility, deprecation text, pod-template name, deck generation, and eager state into Flyte's task model.
from flytekit.core.base_task import TaskMetadata
metadata = TaskMetadata(
retries=2,
timeout=300,
interruptible=True,
)
TaskMetadata.__post_init__ interprets an integer timeout as seconds and replaces it with a datetime.timedelta. It rejects a non-empty timeout that is neither an integer nor a datetime.timedelta. Caching has explicit validation:
TaskMetadata(cache=True, cache_version="v1")
cache=True without a non-empty cache_version raises ValueError. cache_serialize=True and cache_ignore_input_vars=("x",) are also rejected unless cache=True. The decorator's current API prefers a Cache object; the older cache arguments are deprecated, and the decorator rejects mixing a Cache object with those old arguments.
TaskMetadata.to_taskmetadata_model() converts these values to Flyte's task model. It wraps retries in a RetryStrategy, records the Flytekit SDK runtime and version, and passes through timeout, interruptibility, cache version and ignored inputs, deprecation text, deck generation, pod-template, and eager fields.
Python-task options
PythonTask accepts a Python Interface, environment, and deck settings in addition to the common task arguments. Its constructor converts the Python interface into a typed Flyte interface and stores the task configuration for plugin-specific handling. If the interface contains a parsed docstring, it populates or updates the task's Documentation with the short and long descriptions.
Deck output is disabled by default. Choose exactly one of enable_deck or the deprecated disable_deck; passing both raises ValueError. When decks are enabled, deck_fields is checked against DeckField, and the selected fields are used to write input/output decks. PythonFunctionTask additionally writes source-code and dependency decks before delegating to PythonTask's deck writer.
PythonAutoContainerTask, the parent used by PythonFunctionTask, exposes container_image, resources, environment, and a task resolver. During serialization, the configured environment is combined with serialization settings and an explicit image or ImageSpec is resolved against the configured image defaults.
What happens when you call a task?
A task call is not a direct call to the original Python function. Task.__call__ delegates to flyte_entity_call_handler, which chooses behavior from the current Flyte execution context. In workflow compilation, the call contributes a node. In local execution, it runs the task and returns native-looking task results backed by Promise objects.
The local path in Task.local_execute is:
native values / Promises
│
▼
translate_inputs_to_literals
│
▼
LiteralMap ── optional LocalTaskCache lookup
│
▼
sandbox_execute → dispatch_execute
│
▼
output LiteralMap
│
▼
Promise(s) or VoidPromise
Task.local_execute translates native values, promises, lists, and dictionaries into literals using the task interface. If both TaskMetadata.cache and LocalConfig.auto().cache_enabled are true, it checks LocalTaskCache using the task name, cache version, input literal map, and configured ignored inputs. cache_overwrite bypasses a hit. A miss executes through sandbox_execute, then stores the resulting literal map.
The method checks that the number of returned literals equals the number of declared outputs. A task with no declared outputs returns VoidPromise(self.name); otherwise it creates Promise objects and packages them with create_task_output.
The Python dispatch pipeline
PythonTask.dispatch_execute performs the conversion and execution work for both local and hosted execution:
new_user_params = self.pre_execute(ctx.user_space_params)
native_inputs = self._literal_map_to_python_input(input_literal_map, exec_ctx)
with timeit("Execute user level code"):
native_outputs = self.execute(**native_inputs)
native_outputs = self.post_execute(new_user_params, native_outputs)
if isinstance(native_outputs, (_literal_models.LiteralMap, _dynamic_job.DynamicJobSpec)):
return native_outputs
literals_map, native_outputs_as_map = run_sync(
self._output_to_literal_map, native_outputs, exec_ctx
)
return literals_map
The implementation in base_task.py first calls pre_execute, allowing a subclass to modify execution parameters before input conversion. _literal_map_to_python_input uses TypeEngine.literal_map_to_kwargs and the task's Python input types. execute then receives ordinary Python keyword arguments. post_execute can clean up or alter the result; its default implementation returns the result unchanged.
For ordinary outputs, _output_to_literal_map maps declared output names to returned values and calls TypeEngine.async_to_literal for each value. It handles zero outputs and the special one-output NamedTuple convention. A tuple used as an individual declared output raises TypeError. If a result is already a LiteralMap or a DynamicJobSpec, dispatch_execute returns it without attempting ordinary output conversion.
Local and hosted failures are handled differently. During local execution, input-conversion and user-function exceptions are re-raised with task context in their messages. During remote execution, user-code failures become FlyteUserRuntimeException, while conversion failures become FlyteNonRecoverableSystemException. IgnoreOutputs is a marker exception for flows such as distributed or peer-to-peer algorithms; the dispatch documentation says it is bubbled to the caller layer rather than caught by PythonTask.
Unit-test task calls
To replace a Python task's execute method temporarily, use task_mock from testing.py:
from flytekit import task
from flytekit.core.testing import task_mock
@task
def t1(i: int) -> int:
pass
with task_mock(t1) as m:
m.side_effect = lambda x: x
t1(10)
# The mock is valid only within this context
The context manager accepts a PythonTask (as well as workflow and reference entities), installs a MagicMock around execute, and restores the original method when the context exits.
Serialization and task rehydration
A hosted execution starts from a serialized task template, so the worker needs a way to reconstruct the Python task object. TaskResolverMixin defines that contract through location, name, loader_args(settings, task), load_task(loader_args), and get_all_tasks; task_name can optionally provide a custom serialized name.
PythonAutoContainerTask.get_default_command places the resolver location and loader arguments in the container command:
container_args = [
"pyflyte-execute",
"--inputs", "{{.input}}",
"--output-prefix", "{{.outputPrefix}}",
"--raw-output-data-prefix", "{{.rawOutputDataPrefix}}",
"--checkpoint-path", "{{.checkpointOutputPrefix}}",
"--prev-checkpoint", "{{.prevCheckpointPrefix}}",
"--resolver", self.task_resolver.location,
"--", *self.task_resolver.loader_args(settings, self),
]
The default resolver uses the task's module and name: it imports the module and looks up the task attribute. This is why a normal PythonFunctionTask must be accessible at module level. With the default resolver, PythonFunctionTask rejects nested or local functions unless they are test functions or a module-level function has been correctly wrapped with functools.wraps or functools.update_wrapper.
A custom resolver can use another identifier scheme. For example, ClassStorageTaskResolver stores tasks in an in-memory mapping and serializes the mapping index:
def load_task(self, loader_args: List[str]) -> PythonAutoContainerTask:
if len(loader_args) != 1:
raise RuntimeError(...)
idx = int(loader_args[0])
return self.mapping[idx]
def loader_args(self, settings, t):
if t not in self.mapping:
raise ValueError("no such task")
return [f"{self.mapping.index(t)}"]
The two methods must be inverse operations for the serialized task to be rehydrated. A compilation context can override an explicitly supplied resolver; PythonAutoContainerTask uses compilation_state.task_resolver when present, otherwise the supplied resolver or default_task_resolver.
Dynamic, asynchronous, and eager tasks
Dynamic tasks
Use dynamic, which is defined as task.task partially applied with PythonFunctionTask.ExecutionBehavior.DYNAMIC, when the function body should use native Python control flow to create a runtime workflow:
from flytekit import dynamic
@dynamic
def my_dynamic_subwf(a: int) -> (list[str], int):
s = []
for i in range(a):
s.append(t1(a=i))
return s, 5
A dynamic function can also express task dependencies with native inputs:
@dynamic
def my_dynamic_subwf(a: int, b: int) -> int:
x = t1(a=a)
return t2(b=b, x=x)
PythonFunctionTask.execute routes dynamic tasks to dynamic_execute. In local execution, dynamic_execute creates or reuses a PythonFunctionWorkflow and executes it locally. In TASK_EXECUTION mode, it calls compile_into_workflow and returns a DynamicJobSpec for Flyte to run. In LOCAL_TASK_EXECUTION mode it directly invokes the function. Missing or unsupported execution state raises ValueError.
Pass node_dependency_hints only for dynamic tasks. Supplying it to a static task raises ValueError, because static workflow dependencies are discovered automatically. Dynamic compilation currently rejects reference tasks inside the generated workflow. The source documentation also cautions that a loop can produce very large generated workflows and recommends keeping dynamic workflows under fifty tasks.
Asynchronous tasks
For a coroutine function, the decorator selects AsyncPythonFunctionTask. Its __call__ uses async_flyte_entity_call_handler, and async_execute awaits the user function in default execution mode. Async dynamic execution is deliberately unsupported: the dynamic branch raises NotImplementedError.
Eager workflows
Use @eager when Python async code should call Flyte entities eagerly rather than compile the function into a workflow specification:
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"
EagerAsyncPythonFunctionTask removes any caller-provided execution mode and forces PythonFunctionTask.ExecutionBehavior.EAGER. It also sets TaskMetadata.is_eager=True. Local execution switches to EAGER_LOCAL_EXECUTION; backend execution uses EAGER_EXECUTION and a Controller worker queue. If no worker queue exists, the task constructs a remote from the execution context, installs SIGINT and SIGTERM handlers, and uses the current execution ID for its tag. _F_EE_ROOT is read to preserve the root eager execution tag for nested eager work.
The eager source example notes that FlyteRemote client credentials may be required for client-credentials authentication unless using a sandbox demo cluster. Backend eager execution requires an execution ID in the context. Eager and dynamic modes do not mix.
For failures, get_as_workflow adds an EagerFailureHandlerTask. Its fixed EagerFailureTaskResolver serializes as ['eager', 'failure', 'handler']. The handler is remote-only: it obtains the current project, domain, and execution name, finds active child executions tagged eager-exec, terminates them, and polls until none remain. It returns the input literal map unchanged and its execute method raises because it is expected to run through remote dispatch_execute.
Edge cases when extending or composing tasks
ignore_input_varsremoves names from the generatedPythonFunctionTaskinterface. Those values are not ordinary Flyte inputs and are intended for client-side injection.- A no-output task returns
VoidPromise; output count must otherwise match the declared interface. - Output conversion rejects a tuple as an individual output, while preserving the special handling for a one-element
NamedTupleinterface. PythonTaskrejects both deck flags together, warns thatdisable_deckis deprecated, defaults decks off, and emptiesdeck_fieldswhen decks are disabled.- Map and array-node wrappers accept only supported
PythonFunctionTask/PythonInstanceTaskforms and currently limit the wrapped task to zero or one output; dynamic and eager Python function tasks are rejected byArrayNodeMapTask. kwtypesis not an execution mechanism. It creates an insertion-orderedOrderedDict[str, Type]used for structured schema annotations and reference-entity interfaces, for examplekwtypes(a=str, b=int).
The extracted core source tree contains the task, dynamic, eager, map, resolver, and task-mocking examples in module docstrings and embedded source examples rather than separate test/example files. Those examples are the authoritative usage patterns for the APIs shown here.