Launch plans, schedules, and fixed inputs
A workflow function is just a definition — it has no execution configuration. The moment you want to run wf(a=3, c="hello") on a schedule, or with some inputs baked in so they can't be changed, you need a launch plan. In flytekit, that's the LaunchPlan class in flytekit/core/launch_plan.py, which wraps a workflow together with default inputs, fixed inputs, a schedule or trigger, notifications, labels, and execution options.
The default launch plan
Every workflow registered with Flyte gets a default launch plan — a plan with no defaults, fixed values, or schedules. You create it explicitly with:
from flytekit import workflow, LaunchPlan
@workflow
def wf(a: int, c: str) -> str:
...
lp = LaunchPlan.get_or_create(workflow=wf)
The default plan inherits whatever default values are defined in the workflow function signature. Internally, LaunchPlan.get_default_launch_plan calls transform_inputs_to_parameters(ctx, workflow.python_interface) to build the parameter map, leaves fixed_inputs as an empty LiteralMap, and stores the signature defaults in the plan's _saved_inputs so they're available when the plan is called.
One important rule: the default launch plan cannot carry any extra configuration. If you call get_or_create with no name but pass default_inputs, schedule, or any other attribute, flytekit raises:
ValueError("Only named launchplans can be created that have other properties. Drop the name if you want to create a default launchplan. Default launchplans cannot have any other associations")
Give the plan a unique name to use any of those features.
Default inputs vs fixed inputs
Named launch plans can parameterize a workflow in two distinct ways:
default_inputs— values the caller can still override at launch time. These become entries in the launch plan'sParameterMap.fixed_inputs— values baked in permanently. They are translated to FlyteLiteralobjects (viatranslate_inputs_to_literals) into aLiteralMap, and theLaunchPlan.__init__constructor actively strips them from the parameter map. In__init__:
# Ensure fixed inputs are not in parameter map
parameter_map = {k: v for k, v in parameters.parameters.items() if k not in fixed_inputs.literals}
So a caller simply cannot supply a new value for a fixed input — it isn't part of the launch plan's interface anymore. (Internally, flytekit/core/array_node.py relies on this too: when a launch plan is a map-over target, its fixed inputs are excluded from the resulting interface.)
A named launch plan looks like this:
from flytekit import LaunchPlan
from flytekit.models.common import Annotations, Labels, RawOutputDataConfig
my_lp = LaunchPlan.get_or_create(
name="my_scheduled_lp",
workflow=wf,
default_inputs={"c": "hello"},
fixed_inputs={"a": 42},
)
Both are plain Python native values — flytekit does the type translation for you. create() also merges fixed inputs into _saved_inputs (default_inputs.update(fixed_inputs)), so when you call the launch plan locally, the fixed values are included in the invocation.
If the same input appears in both default_inputs and fixed_inputs, the fixed value wins: create() removes it from the defaults as part of building the parameter map.
The caching contract — and its sharp edges
All launch plans live in LaunchPlan.CACHE, a class-level Dict[str, LaunchPlan]. Caching exists, per the source comment, "simply because users may get the default launch plan twice for a single Workflow. We don't want to create two defaults, could be confusing."
Two rules follow from this:
create()raisesAssertionErrorimmediately if the name is already in the cache.get_or_create()with an existing name returns the cached plan — but compares every attribute (schedule, notifications, default/fixed inputs, labels, annotations,raw_output_data_config,max_parallelism,security_context,overwrite_cache,auto_activate). Any mismatch raises anAssertionErrortelling you to use a different launch plan name. It also checks the workflow identity: two launch plans with the same name for different workflows are rejected.
Names must be globally unique per project/domain/version — they form part of the primary key on the Admin side.
Schedules and triggers
There are two syntaxes for attaching a schedule to a launch plan.
The classic schedule= argument takes a schedule model directly:
from datetime import timedelta
from flytekit import CronSchedule, FixedRate, LaunchPlan
lp = LaunchPlan.get_or_create(
name="every_min",
workflow=wf,
fixed_inputs={"a": 1},
schedule=CronSchedule(schedule="*/1 * * * *"),
)
lp2 = LaunchPlan.get_or_create(
name="every_10_min",
workflow=wf,
schedule=FixedRate(duration=timedelta(minutes=10)),
)
The newer (alpha) trigger= syntax wraps the schedule in an OnSchedule object (flytekit/core/schedule.py), which implements LaunchPlanTriggerBase:
from flytekit import OnSchedule
lp3 = LaunchPlan.get_or_create(
name="triggered",
workflow=wf,
trigger=OnSchedule(schedule=CronSchedule(schedule="@daily")),
)
OnSchedule.to_flyte_idl() delegates to the wrapped schedule's protobuf serialization.
CronSchedule rules
CronSchedule(schedule=...) accepts either a cron alias — the class validates against _VALID_CRON_ALIASES, which includes "hourly", "@daily", "@weekly", "@monthly", "@yearly", and friends — or any 5-field expression parseable by croniter. Anything else raises ValueError("Schedule is invalid. It must be set to either a cron alias or valid cron expression.").
Do not use the cron_expression parameter — it raises an AssertionError immediately ("cron_expression is deprecated and should not be used. Use schedule instead."). The legacy AWS-style 6-field validation (_validate_expression) still exists in the code: it requires exactly 6 fields and a ? in either day-of-month or day-of-week, and validates the first 5 fields with croniter.
CronSchedule also supports an ISO 8601 offset (validated with the regex _OFFSET_PATTERN = re.compile("([-+]?)P([-+0-9YMWD]+)?(T([-+0-9HMS.,]+)?)?")), and kickoff_time_input_arg, which delivers the scheduled kickoff time as a workflow input:
from datetime import datetime
from flytekit import CronSchedule, workflow
@workflow
def my_wf(kickoff_time: datetime):
...
schedule = CronSchedule(
schedule="*/1 * * * *",
kickoff_time_input_arg="kickoff_time",
)
The docstring adds a caveat: "until Flyte has an atomic clock, there could be a few seconds here and there" — a run scheduled for 3pm UTC may actually start at 15:00:02.
FixedRate rules
FixedRate(duration=timedelta(minutes=10)) translates the duration into the largest fitting unit (day, hour, or minute) in _translate_duration. Sub-minute granularity is rejected outright:
AssertionError("Granularity of less than a minute is not supported for FixedRate schedules. Received: ...")
Any duration whose seconds aren't a whole number of minutes fails this check.
Calling a launch plan locally
LaunchPlan.__call__ lets you invoke a launch plan like a workflow. It accepts keyword arguments only — positional args raise AssertionError("Only Keyword Arguments are supported for launch plan executions"). The call merges the plan's saved inputs (defaults plus fixed inputs) with your kwargs:
inputs = self.saved_inputs
inputs.update(kwargs)
return self.workflow(*args, **inputs)
During compilation (when ctx.compilation_state is set) it instead calls create_and_link_node to wire the launch plan into the graph being built — this is how launch plans participate as nodes inside workflows, handled in flytekit/core/node_creation.py.
The saved_inputs property deliberately returns a copy, because call sites mutate the returned dict before updating it. Note the code's own TODO about custom classes as input types here.
Referencing launch plans that already exist
ReferenceLaunchPlan(ReferenceEntity, LaunchPlan) points at a launch plan already registered on your Flyte installation without making a network call — you supply the expected interface yourself, and registration-time compilation errors if it doesn't match:
from flytekit import reference_launch_plan
@reference_launch_plan(
project="flytesnacks",
domain="development",
name="my_lp",
version="v1",
)
def my_lp_ref(x: int) -> int:
...
Launch plans in dynamic workflows
To run a sub-launch plan from a dynamic workflow, the plan must already be registered on Admin. Pass it as a node dependency hint so flytekit registers it alongside the dynamic task:
launchplan0 = LaunchPlan.get_or_create(workflow=wf)
@dynamic(node_dependency_hints=[launchplan0])
def dynamic_wf():
...
return [launchplan0] * 10
(This pattern is documented on the node_dependency_hints parameter in flytekit/core/task.py.)
Other constructor options
Beyond inputs and schedules, create()/get_or_create() accept notifications, labels, annotations, raw_output_data_config (offloaded data location for S3, etc.), max_parallelism (caps task nodes running in parallel across the workflow — MapTasks count as one unit and have their own concurrency), overwrite_cache, and auto_activate (activates the launch plan on registration; exposed as should_auto_activate).
Two deprecated paths to avoid:
auth_role— passing bothauth_roleandsecurity_contextraisesValueError("Use of AuthRole is deprecated. You cannot specify both AuthRole and SecurityContext"); passing onlyauth_rolesilently converts it into aSecurityContext(run_as=Identity(...)).CronSchedule(cron_expression=...)— raises immediately, as described above.
Finally, clone_with() produces a copy of a launch plan with overridden attributes. Watch one asymmetry: every field uses new or self.field except trigger, which is passed through as given — a new trigger=None fully replaces the original, while a new schedule=None falls back to the original schedule.