Flow Parameters (parameters.yaml)¶
Every flow can declare its tunable inputs in a parameters.yaml file. The
values are loaded automatically when the flow runs and exposed through the
context-aware params accessor — no manual passing, no global
state to thread through your task signatures.
# flows/my_flow/parameters.yaml
model_name: gpt-4o
batch_size: 32
temperature: 0.0
gates:
quality: 3.5
faithfulness: 4.0
from latent.prefect import flow, task, params, logger
@flow("my_flow")
async def my_flow():
logger.info(f"Scoring with {params.model_name} (batch={params.batch_size})")
threshold = params.gates["quality"]
...
Accessing parameters¶
Inside any @flow- or @task-decorated function, read parameters off params.
It is a Mapping, so single keys and the whole config are both reachable:
params.model_name # attribute access — raises if missing
params["model_name"] # item access — raises if missing
params.get("model_name") # returns None if missing
params.get("batch_size", 32) # returns 32 if missing (default)
dict(params) # every parameter, as plain data
{**defaults, **params} # merge into another dict
Only inside a flow context
params resolves against the current flow. Reading it outside a
@flow/@task body raises RuntimeError. Attribute access on a missing
key raises AttributeError and item access raises KeyError; use
params.get(key, default) when a parameter is optional.
The layering model¶
parameters.yaml is not read in isolation — the final config is merged from
several layers. Highest priority wins; nested dicts are deep-merged (so you can
override a single nested key without restating the whole block):
| Priority | Layer | Source |
|---|---|---|
| 1 (highest) | CLI flags, caller kwargs, and non-None flow-signature defaults |
latent run <flow> --param value, my_flow(x=1), def my_flow(x=1) |
| 2 | Flow parameters | flows/<flow>/parameters.yaml |
| 3 | Global defaults | flows/global.yaml |
| 4 (lowest) | Bundled defaults | shipped inside a library flow's package |
The three sources in row 1 are one layer, not three: Prefect applies signature
defaults before the decorator sees them, so all three arrive as keyword
arguments and are indistinguishable. A None default is filtered out and is a
no-op — that is how you let parameters.yaml win.
${VAR} / $VAR environment variables are substituted
across the YAML layers before CLI flags are merged on top, so a value you pass on
the command line reaches params verbatim. The merged result — CLI flags
included — is then validated against the flow's
Pydantic schema, if it declares one.
Flow-signature defaults outrank parameters.yaml
A non-None default in the flow function's signature is materialised before
the flow body runs and merged as a CLI flag, so it wins over
parameters.yaml. Write async def my_flow(sample_size: int | None = None)
and read the value with params.get("sample_size") when parameters.yaml
should be the source of truth.
global.yaml — shared defaults¶
Put values common to every flow in flows/global.yaml. Each flow's own
parameters.yaml overrides them:
# flows/global.yaml
mlflow_experiment: my-project
model_name: gpt-4o-mini # default model for all flows
# flows/my_flow/parameters.yaml
model_name: gpt-4o # this flow overrides the global default
batch_size: 64
At runtime params.model_name == "gpt-4o" and params.mlflow_experiment ==
"my-project".
Environment variables¶
Any string value may contain ${VAR_NAME} or $VAR_NAME; it is replaced with
the environment variable's value at load time. Substitution is recursive (works
inside nested dicts and lists). If the variable is unset, the placeholder is
left as-is and a warning is logged.
# flows/my_flow/parameters.yaml
api_base: ${OPENAI_API_BASE}
data_root: ${LATENT_WORKSPACE_ROOT}/data
prompt_version: $PROMPT_VERSION
Tip
latent run exports LATENT_WORKSPACE_ROOT before loading parameters, so
${LATENT_WORKSPACE_ROOT} is always resolvable when you run via the CLI.
Typed parameters¶
Pass a Pydantic model as config_schema to validate parameters and get typed,
auto-completing attribute access. Validation runs after merging and env
substitution; a bad config fails fast with a clear error.
from pydantic import BaseModel
from latent.prefect import flow, params
class MyConfig(BaseModel):
model_name: str
batch_size: int = 32
temperature: float = 0.0
@flow("my_flow", config_schema=MyConfig)
async def my_flow():
# params now proxies the validated MyConfig instance
reveal_type = params.batch_size # int, validated and coerced
With a schema, params.batch_size returns the validated/coerced value, and an
unknown or mistyped parameter raises a ValueError at flow start rather than
surfacing as a confusing failure deep inside a task.
The schema must cover every top-level key of the merged config¶
Pydantic drops keys a model does not declare, and params.get("key", default)
would then answer with your inline default instead of the configured value. So
a schema that leaves a configured key undeclared fails at flow start, naming it:
Configuration validation failed for flow 'my_flow': MyConfig does not declare
['concurrency', 'mlflow'], so those configured values would be silently discarded.
Remember the merged config spans global.yaml, bundled defaults and CLI flags —
not just your flow's own parameters.yaml. latent scaffold writes mlflow:
into global.yaml, and the sampling helpers read sample_size/sample_seed,
so subclass FlowConfig to pick up the keys latent reads from layers you do not
own:
from latent.prefect import FlowConfig
class MyConfig(FlowConfig):
model_name: str
concurrency: int = 5
Only top-level keys are checked. A nested model still silently drops what it
does not declare — mlflow: {enabled: true, experiment_name: x} validated
against a nested model declaring only enabled loses experiment_name with no
error. Declare nested fields exhaustively, or give the nested model
extra="allow" too.
To keep undeclared keys instead of naming every one, opt into extra="allow";
they stay readable through params:
from pydantic import BaseModel, ConfigDict
class MyConfig(BaseModel):
model_config = ConfigDict(extra="allow")
model_name: str # typed and validated
# mlflow, concurrency, … pass through untyped
extra="forbid" and extra="ignore" are honoured as written: the first makes
Pydantic itself reject undeclared keys, the second opts into dropping them.
Attribute access is typed, mapping access is data
A schema changes what attribute access returns — params.retrieval is
the validated sub-model. The mapping view stays plain data:
params["retrieval"], params.get("retrieval") and {**params} hand back
model_dump() output, so nested sections are dicts with a schema and
without one. A caller that reached a nested section through .get() and
then read an attribute off it (params.get("retrieval").top_k) must switch
to attribute access (params.retrieval.top_k).
Three further differences a schema introduces in the mapping view, each
worth checking before {**defaults, **params}:
- the dump is keyed by field name, so
Field(alias="samples")moves the key fromsamplesto the field's own name; - the dump is complete, not as-configured: a declared field the YAML
never set appears with its schema default, and that default wins the
merge over the caller's fallback —
{"temperature": 0.9, **params}becomes the schema's0.0; - a
Field(exclude=True)field is absent from the mapping view and from attribute access.
get_config() has no such guarantee — it hands back the model itself, so
{**base, **get_config()} stops working the day a schema is added. Merge
through params.
Cross-flow parameters¶
Read a parameter from another flow's parameters.yaml with dot notation via
params.get(). This loads the other flow's merged config on demand:
# Reuse the judge model configured by the scoring flow
judge_model = params.get("scoring_flow.model_name", default="gpt-4o")
# Or the other flow's parameters as a whole
scoring_config = params.flow("scoring_flow")
Cross-flow access works only through params.get("flow.param") and
params.flow("flow") — attribute and item access (params.x) always target the
current flow.
A flow that declares no parameters reads as {}, and an undeclared key yields
the default. A load that genuinely fails — unreadable or malformed
parameters.yaml — raises, so a broken upstream flow cannot masquerade as a
missing key.
Overriding from the CLI¶
latent run forwards extra flags into the parameter system, overriding
parameters.yaml for that run. Underscores and hyphens are interchangeable:
latent run my_flow --model-name gpt-4o --batch-size 64
# inside the flow: params.model_name == "gpt-4o", params.batch_size == 64
Overrides are matched to the flow's declared parameters and made available
through params.get(...). See latent run.
See Also¶
- Data Catalog (
catalog.yaml) — the dataset half of flow config - Prefect Flows & Tasks — the
params/loggeraccessors and@flow/@task - Flow Building & Style — project layout and conventions
- CLI Tools —
latent run,latent list,latent validate