Structured output and XCom¶
Set output_type to a Pydantic BaseModel subclass. The LLM is instructed
to return structured data, and the model instance is pushed to XCom unchanged
so downstream tasks can type-hint the class directly
(def downstream(result: MyModel)) and use attribute access (result.field).
Inside a list of types ([MyModel, int]) the model is pushed as a dict instead.
The declared output_type (and any BaseModel reachable from
Union/Optional/list[...] types) is registered for XCom deserialization by
the worker when it loads the Dag, before any task runs – so no edit to
[core] allowed_deserialization_classes is needed. The Pydantic class must be
defined at module scope and bound to an attribute matching its __name__;
classes nested inside a function or @dag-decorated body, parameterized
generics, and dynamically-built classes whose __name__ does not match the
attribute they are bound to cannot be re-imported, so they are skipped with a
warning at worker startup and the value fails to deserialize at the consumer.
A list of types (output_type=[MyModel, int]) registers no class, so the operator pushes the
model as a dict even when serialize_output is False.
# Pydantic output classes must be defined at module scope so they survive
# XCom serialization (their qualname is used to re-import them downstream).
class Entities(BaseModel):
"""Named entities extracted from a text."""
names: list[str]
locations: list[str]
@dag(tags=["example"])
def example_llm_operator_structured():
LLMOperator(
task_id="extract_entities",
prompt="Extract all named entities from the article.",
llm_conn_id="pydanticai_default",
system_prompt="Extract named entities.",
output_type=Entities,
)
Registration covers downstream tasks in the same Dag: every worker walks the
loaded Dag’s tasks at startup and registers each declared class, so it also works
for mapped producers (.expand(...)) and for workers that load Dags from a
cache that bypasses operator construction.
The worker finds no class to register when output_type is a list of output
types such as [Summary, int], an output marker such as
ToolOutput(Summary), an output function, a TypeAliasType or a
NewType. For these output types, LLMOperator, @task.llm,
LLMFileAnalysisOperator, AgentOperator and @task.agent push
each model in the output as a JSON-compatible dict, including models inside a
list, tuple or dict. A model is pushed as a dict even if its class is listed in
[core] allowed_deserialization_classes, or if another task in the same Dag
declares the class as its output_type. A downstream task therefore reads
result["field"], not result.field.
In a JSON-compatible dict, values such as datetime, date, UUID and
Decimal arrive as strings, tuples and sets arrive as lists, and Enum
members arrive as their values.
A model at the top level is still dumped with plain model_dump() when
serialize_output=True is set or on Airflow versions before 3.3. Either
condition is enough. A plain Enum member (one that does not also subclass
str or int), an HttpUrl or a Path in that model then stays a
Python object. The default XCom backend cannot serialize these objects, so the
XCom push fails with TypeError.
Upgrading from common.ai 1.0.0 to 1.1.0 can change the type that a downstream task receives:
On Airflow 3.3 and later, common.ai 1.0.0 pushed each model in the output as a model instance when
output_typewas one of the output types listed above. It dumped a top-level model with plainmodel_dump()only whenserialize_output=Truewas set or in HITL review. A downstream task loaded the instance when the class was listed in[core] allowed_deserialization_classesor when another task in the same Dag declared the class as itsoutput_type. That downstream task now gets a dict, andresult.fieldraisesAttributeError.In HITL review on Airflow 3.3 and later without
serialize_output=True, common.ai 1.0.0 dumped a top-level model of these output types with plainmodel_dump()after the reviewer approved the model. Values such asdatetime,date,timedelta,UUIDandDecimalin that model now arrive as strings, for example"PT1M30S"for atimedelta. Tuples and sets in that model now arrive as lists.Before Airflow 3.3, common.ai 1.0.0 pushed a model inside a list, tuple or dict as a model instance, for every
output_type. A downstream task that loaded the instance because the class was listed in[core] allowed_deserialization_classesnow gets a dict.
The Airflow UI’s XCom viewer renders Pydantic instances via the
stringify path, which produces a representation like
my_module.MyModel@version=1(field=value,...) without consulting the
allow-list. It is not pretty (no field-by-field rendering today), but the value
shows up; no configuration is required.
The remaining gap is cross-Dag xcom_pull – a task in a different Dag
that pulls this XCom only parses its own Dag file, not the producer’s, so the
class is not auto-registered. Add the class qualified name to
[core] allowed_deserialization_classes (or a glob that matches it) to make
that pattern work.
When a model arrives as a dict¶
LLMOperator, @task.llm, LLMFileAnalysisOperator, AgentOperator and @task.agent
decide in the same way whether a model is pushed as an instance or dumped. Set
serialize_output=True when a downstream consumer needs the dict shape (for example to forward
it to a system that expects JSON-style payloads).
With
serialize_output=True, an output that is itself a model is dumped withmodel_dump(), so field types such asdateandDecimalare kept.A model inside a list, tuple, set or dict is not dumped when the worker registers its class (
output_type=list[MyModel]on Airflow 3.3 and later). The XCom serializer pushes it as an instance, with or withoutserialize_output, unless the output also holds a model whose class the worker does not register (see the next bullets).On Airflow 3.3 and later, a model whose class the worker cannot register is dumped to JSON values when it is itself the output and
serialize_outputisFalse. Withserialize_output=Trueit is dumped withmodel_dump()as the first bullet describes. The worker registers the classes it finds by readingoutput_typeand finds none in a list of types such asoutput_type=[MyModel, int].The operator dumps the whole output with
to_jsonable_python, using field names rather than aliases (also for a model that setsserialize_by_alias=True), as soon as it holds such a model in a list, tuple, set or dict, or as a set member or a dict key. AdateorDecimalnext to the model becomes a string, a set becomes a list, and a model used as a dict key becomes its string form.Before Airflow 3.3 the worker registers no class. A model that is itself the output is then dumped with
model_dump()whether or notserialize_outputis set, and an output that holds a model in a list, tuple, set or dict is dumped as the previous bullet describes, for everyoutput_type.
In a dump to JSON values, HttpUrl, Path and Enum fields become plain JSON values. A model
that is itself the output with serialize_output=True keeps its Python field values instead, so an
HttpUrl, a Path or a plain Enum member in it stays an object that the default XCom backend
cannot serialize.