Type System and Type Engine
Every Python value that crosses a task boundary in Flyte — an int passed as input, a List[str] returned as output, a dataclass threaded through a workflow — must be serialized to Flyte's wire format (Literal) on one side and deserialized back on the other. Flytekit's type engine is the machinery that performs these conversions, and its design lets you extend it for your own types.
TypeEngine: The Central Registry
TypeEngine (in flytekit/core/type_engine.py) is the dispatch hub. It maintains a class-level dictionary _REGISTRY that maps Python types to TypeTransformer instances. Every transformer that ships with flytekit — and every transformer added by a plugin — ends up in this registry.
The three most-called methods are:
TypeEngine.to_literal_type(python_type)— converts a Python type annotation (e.g.,List[int]) to a FlyteLiteralTypeTypeEngine.to_literal(ctx, python_val, python_type, expected)— serializes a runtime value to aLiteralTypeEngine.to_python_value(ctx, lv, expected_python_type)— deserializes aLiteralback to a Python value
from flytekit.core.type_engine import TypeEngine
from flytekit.core.context_manager import FlyteContext
import typing
ctx = FlyteContext.current_context()
# Convert a Python type to a Flyte LiteralType
lt = TypeEngine.to_literal_type(int)
# lt.simple == SimpleType.INTEGER
# Nested generics work the same way
lt = TypeEngine.to_literal_type(typing.Dict[str, typing.List[typing.Dict[str, int]]])
# lt.map_value_type.collection_type.map_value_type.simple == SimpleType.INTEGER
Transformer Lookup Order
TypeEngine.get_transformer(python_type) implements a layered lookup:
- Direct registry match — checks
_REGISTRY[python_type]exactly - Annotated unwrapping — if the type is
Annotated[T, ...]and any annotation is itself aTypeTransformerinstance, that transformer is returned directly (bypassing the registry entirely) - Generic origin match — for
List[int], checks_REGISTRY[list] - MRO traversal — walks the class hierarchy with
inspect.getmro()to find a transformer for any base class - Dataclass fallback — if
dataclasses.is_dataclass(python_type)is true, returnsDataclassTransformer(stored separately asTypeEngine._DATACLASS_TRANSFORMER, not in_REGISTRY) - Pickle fallback — anything unrecognized goes through
FlytePickleTransformerwith a warning
from flytekit.core.type_engine import TypeEngine, ListTransformer, DictTransformer, SimpleTransformer
import typing, os
assert type(TypeEngine.get_transformer(typing.List[int])) == ListTransformer
assert type(TypeEngine.get_transformer(list)) == ListTransformer
assert type(TypeEngine.get_transformer(typing.Dict[str, int])) == DictTransformer
assert type(TypeEngine.get_transformer(dict)) == DictTransformer
assert type(TypeEngine.get_transformer(int)) == SimpleTransformer
assert type(TypeEngine.get_transformer(os.PathLike)) != SimpleTransformer # FlyteFilePathTransformer
Lazy-Loaded Optional Transformers
When get_transformer is first called, TypeEngine.lazy_import_transformers() runs — but only once, protected by a threading.Lock. This method checks which optional libraries are already imported and registers their transformers on demand:
@classmethod
def lazy_import_transformers(cls):
with cls.lazy_import_lock:
if is_imported("tensorflow"):
from flytekit.extras import tensorflow # registers TensorFlowModelTransformer
if is_imported("torch"):
from flytekit.extras import pytorch # registers PyTorchTensorTransformer
if is_imported("pydantic"):
from flytekit.extras import pydantic_transformer # registers PydanticTransformer
# ... pandas, numpy, PIL, sklearn, StructuredDataset
This means importing torch before calling any type engine method is sufficient — the PyTorch transformer registers itself automatically.
TypeTransformer: The Base Contract
Every type transformer subclasses the abstract TypeTransformer[T] class. Three methods are mandatory:
from flytekit.core.type_engine import TypeTransformer
from flytekit.models.types import LiteralType
from flytekit.models.literals import Literal
class TypeTransformer(typing.Generic[T]):
@abstractmethod
def get_literal_type(self, t: Type[T]) -> LiteralType:
"""Python type → Flyte LiteralType (used at compile/registration time)"""
@abstractmethod
def to_literal(self, ctx, python_val, python_type, expected) -> Literal:
"""Python value → Flyte Literal (serialization)"""
@abstractmethod
def to_python_value(self, ctx, lv, expected_python_type) -> Optional[T]:
"""Flyte Literal → Python value (deserialization)"""
Optional overrides add capability:
assert_type(t, v)— validates a runtime value before serialization; enabled/disabled bytype_assertions_enabledguess_python_type(literal_type)— reverse-maps aLiteralTypeto a Python type; used when type information is not available at call timefrom_binary_idl(binary_idl_object, expected_python_type)— handles the MessagePackBinaryscalar format; the base class implementation uses mashumaro'sMessagePackDecoderto_html(ctx, python_val, expected_python_type)— returns an HTML string for Flyte Deck rendering
AsyncTypeTransformer
Types that require I/O — file uploads, dataset writes — use AsyncTypeTransformer[T], which replaces to_literal and to_python_value with async counterparts:
class AsyncTypeTransformer(TypeTransformer[T]):
@abstractmethod
async def async_to_literal(self, ctx, python_val, python_type, expected) -> Literal: ...
@abstractmethod
async def async_to_python_value(self, ctx, lv, expected_python_type) -> Optional[T]: ...
The synchronous to_literal and to_python_value methods on AsyncTypeTransformer exist as wrappers — they call the async versions through loop_manager.synced(), allowing synchronous callers to invoke async-capable transformers. ListTransformer, DictTransformer, UnionTransformer, FlyteFile, FlyteDirectory, and StructuredDataset all use this pattern.
Primitive Types: SimpleTransformer
For scalars, flytekit uses SimpleTransformer — a concrete subclass that takes lambda functions instead of requiring a full class definition. All the built-in primitive transformers are SimpleTransformer instances:
# From flytekit/core/type_engine.py
IntTransformer = SimpleTransformer(
"int",
int,
_type_models.LiteralType(simple=_type_models.SimpleType.INTEGER),
lambda x: Literal(scalar=Scalar(primitive=Primitive(integer=x))),
_handle_flyte_console_float_input_to_int,
)
FloatTransformer = SimpleTransformer(
"float",
float,
_type_models.LiteralType(simple=_type_models.SimpleType.FLOAT),
lambda x: Literal(scalar=Scalar(primitive=Primitive(float_value=x))),
_check_and_covert_float,
)
StrTransformer = SimpleTransformer(
"str",
str,
_type_models.LiteralType(simple=_type_models.SimpleType.STRING),
lambda x: Literal(scalar=Scalar(primitive=Primitive(string_value=x))),
lambda x: x.scalar.primitive.string_value,
)
DatetimeTransformer = SimpleTransformer(
"datetime",
datetime.datetime,
_type_models.LiteralType(simple=_type_models.SimpleType.DATETIME),
lambda x: Literal(scalar=Scalar(primitive=Primitive(datetime=x))),
lambda x: x.scalar.primitive.datetime,
)
DateTransformer maps datetime.date to SimpleType.DATETIME by combining the date with datetime.time.min on serialization and calling .date() on deserialization. TimedeltaTransformer maps to SimpleType.DURATION.
All of these are registered at module import time by _register_default_type_transformers(), which is called at the bottom of type_engine.py.
One notable detail in IntTransformer: the deserialization lambda is _handle_flyte_console_float_input_to_int instead of a simple lambda x: x.scalar.primitive.integer. Flyte Console is written in JavaScript, which has a single number type, so integer values can arrive as floats. The helper handles this conversion with a logged warning about potential precision loss.
Container Transformers
Lists
ListTransformer maps typing.List[T] to Flyte's LiteralCollection. Since list serialization can involve many items — each potentially requiring a remote file operation — it implements AsyncTypeTransformer and processes items in batches:
# Batch size is controlled by the _F_TE_MAX_COROS environment variable (default 10)
_TYPE_ENGINE_COROS_BATCH_SIZE = int(os.environ.get("_F_TE_MAX_COROS", "10"))
During serialization, it calls TypeEngine.async_to_literal for each element and gathers them with _run_coros_in_chunks. During deserialization, it reads the element type from expected_python_type via get_sub_type():
ctx = FlyteContext.current_context()
# Deserializing a LiteralCollection back to a Python list
from flytekit.models.literals import Literal, LiteralCollection, Scalar, Primitive
l0 = Literal(scalar=Scalar(primitive=Primitive(integer=3)))
l1 = Literal(scalar=Scalar(primitive=Primitive(integer=4)))
lit = Literal(collection=LiteralCollection(literals=[l0, l1]))
result = TypeEngine.to_python_value(ctx, lit, typing.List[int])
# result == [3, 4]
ListTransformer.get_sub_type(t) extracts the element type from List[T]; a bare list or unparameterized List without __args__ results in get_sub_type_or_none() returning None and ultimately a ValueError.
Dicts
DictTransformer takes two distinct paths depending on the key type:
Dict[str, T]→ FlyteMapType(LiteralMap), where each key is a string and each value is aLiteralfor typeTDict[int, T],dict, or untyped dict → FlyteSTRUCTwith MessagePack Binary IDL encoding
# Dict[str, T] produces a map type
lt = TypeEngine.to_literal_type(typing.Dict[str, int])
assert lt.map_value_type.simple == SimpleType.INTEGER
# dict or Dict[int, str] produces STRUCT
lt = TypeEngine.to_literal_type(dict)
assert lt.simple == SimpleType.STRUCT
For map-typed dicts, keys must be strings during serialization — a non-string key raises ValueError("Flyte MapType expects all keys to be strings"). The STRUCT path uses mashumaro's MessagePackEncoder.
You can annotate a dict with allow_pickle=True to fall back to FlytePickle when MessagePack encoding fails:
from flytekit import kwtypes
from typing_extensions import Annotated
# Falls back to pickle if dict is not MessagePack-serializable
MyDict = Annotated[dict, kwtypes(allow_pickle=True)]
Unions and Optional
UnionTransformer handles typing.Union[T1, T2, ...] (and Optional[T], which is Union[T, None]). The Python 3.10+ union syntax str | int is also supported by registering the transformer for types.UnionType.
Serialization tries each variant's transformer in order, collecting the first success into a Literal with the matched type stored as a tag:
pt = typing.Union[str, int]
lt = TypeEngine.to_literal_type(pt)
# lt.union_type.variants == [
# LiteralType(simple=STRING, structure=TypeStructure(tag="str")),
# LiteralType(simple=INTEGER, structure=TypeStructure(tag="int")),
# ]
ctx = FlyteContext.current_context()
lv = TypeEngine.to_literal(ctx, 3, pt, lt)
# lv.scalar.union.stored_type.structure.tag == "int"
# lv.scalar.union.value.scalar.primitive.integer == 3
v = TypeEngine.to_python_value(ctx, lv, pt)
# v == 3
During deserialization, UnionTransformer reads stored_type.structure.tag from the literal and skips transformers whose name doesn't match, making reverse-mapping unambiguous.
Ambiguity errors: If two transformers both successfully serialize a value (e.g., a plain Literal with no union wrapper passed to Union[int, MyInt] where both have SimpleType.INTEGER), UnionTransformer raises TypeError("Ambiguous choice of variant for union type"). The tag stored during serialization prevents this during deserialization, but it can appear if you construct a bare literal manually and try to deserialize it into a Union type.
DataclassTransformer
The DataclassTransformer handles Python @dataclass types. It's the most feature-rich transformer in flytekit and is stored separately from _REGISTRY as TypeEngine._DATACLASS_TRANSFORMER — it's applied as a fallback after MRO traversal, so user-registered transformers for dataclass-like objects take precedence.
Serialization Format
Since flytekit 1.14.0, the default serialization format is MessagePack Binary IDL using mashumaro's MessagePackEncoder:
@dataclasses.dataclass
class MyData:
x: int
y: str
# Serializes to Literal(scalar=Scalar(binary=Binary(value=<msgpack bytes>, tag="msgpack")))
The old format (JSON → protobuf Struct) is still available via the FLYTE_USE_OLD_DC_FORMAT environment variable:
FLYTE_USE_OLD_DC_FORMAT=true pyflyte run my_workflow.py wf
Two mashumaro mixin protocols are supported:
DataClassJSONMixin(frommashumaro.mixins.json) — usespython_val.to_json()/expected_python_type.from_json(), allowing mashumaro-level customization of serialization hooks- Plain dataclasses — uses mashumaro's
MessagePackEncoder/MessagePackDecoderobjects cached per type inself._msgpack_encoderandself._msgpack_decoderdicts
JSON Schema Extraction
get_literal_type() attempts to extract a JSON Schema from the dataclass definition and stores it as LiteralType.metadata. This metadata powers autocomplete in the Flyte Console. It first tries mashumaro's build_json_schema, then falls back to marshmallow-jsonschema for classes mixing in DataClassJsonMixin (from dataclasses_json):
# The resulting LiteralType carries schema metadata for UI/CLI tooling
lt = TypeEngine.to_literal_type(MyData)
assert lt.simple == SimpleType.STRUCT
assert lt.metadata is not None # JSON Schema dict
Nested Flyte Types
The _make_dataclass_serializable() method walks the dataclass fields before encoding and converts string paths to FlyteFile or FlyteDirectory objects. This means a dataclass field of type FlyteFile can be assigned a plain string path ("s3://my-bucket/file.txt"), and flytekit will silently convert it — but this behavior is deprecated and will be removed in a future version. Assign FlyteFile("s3://my-bucket/file.txt") directly.
Int Preservation After JSON Round-Trip
Protobuf Struct (used in the old format) does not support explicit integer types — all numbers are upcast to double. _fix_dataclass_int() walks the dataclass tree after JSON deserialization and recasts float values back to int wherever the field type annotation says int:
@dataclasses.dataclass
class InnerStruct(DataClassJsonMixin):
a: int
b: typing.Optional[str]
c: typing.List[int]
ctx = FlyteContext.current_context()
tf = DataclassTransformer()
o = InnerStruct(a=5, b=None, c=[1, 2, 3])
lv = tf.to_literal(ctx, o, InnerStruct, tf.get_literal_type(InnerStruct))
ot = tf.to_python_value(ctx, lv=lv, expected_python_type=InnerStruct)
assert ot == o # int fields preserved correctly
The new MessagePack format avoids this issue entirely since MessagePack preserves integer types natively.
EnumTransformer
EnumTransformer handles enum.Enum subclasses, serializing to a Primitive(string_value=...). Only string-valued enums are supported. Attempting to use an integer-valued enum raises TypeTransformerFailedError:
from enum import Enum
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
lt = TypeEngine.to_literal_type(Color)
# lt.enum_type.values == ["red", "green", "blue"]
ctx = FlyteContext.current_context()
lv = TypeEngine.to_literal(ctx, Color.RED, Color, lt)
# lv.scalar.primitive.string_value == "red"
v = TypeEngine.to_python_value(ctx, lv, Color)
# v == Color.RED
# This raises TypeTransformerFailedError:
class BadEnum(Enum):
A = 1 # integer value — not supported
Multi-inheritance enums like class Color(str, Enum) are correctly routed to EnumTransformer rather than StrTransformer. This works because TypeEngine._get_transformer() checks issubclass(python_type, enum.Enum) before performing the MRO traversal that would otherwise find str first:
class MultiInheritanceColor(str, Enum):
RED = auto()
assert isinstance(TypeEngine.get_transformer(MultiInheritanceColor), EnumTransformer)
ProtobufTransformer
ProtobufTransformer handles google.protobuf.Message subclasses. It serializes protobuf messages to Flyte's Scalar.generic (a protobuf Struct) and embeds the full module.classname as type metadata under the key "pb_type":
from flyteidl.core import errors_pb2
pb = errors_pb2.ContainerError(code="code", message="message")
lt = TypeEngine.to_literal_type(errors_pb2.ContainerError)
# lt.simple == SimpleType.STRUCT
# lt.metadata["pb_type"] == "flyteidl.core.errors_pb2.ContainerError"
lv = TypeEngine.to_literal(ctx, pb, errors_pb2.ContainerError, lt)
recovered = TypeEngine.to_python_value(ctx, lv, errors_pb2.ContainerError)
assert recovered == pb
guess_python_type() uses load_type_from_tag() to reconstruct the class from the "pb_type" metadata string, enabling round-trip type inference without the original Python type annotation.
Restricted Types
tuple, typing.Tuple, and NamedTuple are registered with RestrictedTypeTransformer via TypeEngine.register_restricted_type(). Any attempt to use them as a task input or output — calling get_literal_type(), to_literal(), or to_python_value() — raises RestrictedTypeError. Task functions can return NamedTuple at the workflow level, but NamedTuple cannot be nested inside another type.
LiteralsResolver
When working with execution results through FlyteRemote, the raw output is a LiteralMap — a dict-like structure of str → Literal. LiteralsResolver wraps this map and provides lazy, type-aware conversion:
from flytekit.core.type_engine import LiteralsResolver
from flytekit.models.literals import Literal, LiteralCollection, Scalar, Primitive
import typing
# Simple usage with explicit type hints
lit_dict = {
"result": Literal(
collection=LiteralCollection(literals=[
Literal(scalar=Scalar(primitive=Primitive(integer=1))),
Literal(scalar=Scalar(primitive=Primitive(integer=2))),
])
)
}
lr = LiteralsResolver(lit_dict)
out = lr.get("result", typing.List[int])
# out == [1, 2]
If you provide a variable_map (the VariableMap from a task's interface), LiteralsResolver can call TypeEngine.guess_python_type() to infer types when no explicit hint is given:
lr = LiteralsResolver(lm, variable_map=variable_map, ctx=ctx)
# [] operator triggers guess_python_type() from variable_map
result = lr["my_list"] # guesses List[int] from the LiteralType
# Explicit type overrides guessing
result = lr.get("my_list", as_type=typing.List[int])
Converted values are cached in _native_values, so repeated access to the same key doesn't re-invoke the type engine. Use update_type_hints() to add explicit type annotations before accessing — useful when you want column-specific StructuredDataset types rather than the unconstrained guessed version.
Extending the Type System
To add support for a new Python type, subclass TypeTransformer and register it with TypeEngine.register(). The PydanticTransformer in flytekit/extras/pydantic_transformer/transformer.py is a complete, production example:
import json, msgpack
from pydantic import BaseModel
from flytekit.core.type_engine import TypeEngine, TypeTransformer, TypeTransformerFailedError
from flytekit.models.literals import Binary, Literal, Scalar
from flytekit.models.types import LiteralType, TypeStructure
from flytekit.models import types
from flytekit.models.annotation import TypeAnnotation as TypeAnnotationModel
from flytekit.core.constants import CACHE_KEY_METADATA, MESSAGEPACK, SERIALIZATION_FORMAT
class PydanticTransformer(TypeTransformer[BaseModel]):
def __init__(self):
super().__init__("Pydantic Transformer", BaseModel, enable_type_assertions=False)
def get_literal_type(self, t: Type[BaseModel]) -> LiteralType:
schema = t.model_json_schema()
literal_type = {}
for name, python_type in t.__annotations__.items():
try:
literal_type[name] = TypeEngine.to_literal_type(python_type)
except Exception:
pass
ts = TypeStructure(tag="", dataclass_type=literal_type)
return types.LiteralType(
simple=types.SimpleType.STRUCT,
metadata=schema,
structure=ts,
annotation=TypeAnnotationModel({CACHE_KEY_METADATA: {SERIALIZATION_FORMAT: MESSAGEPACK}}),
)
def to_literal(self, ctx, python_val, python_type, expected):
json_str = python_val.model_dump_json()
dict_obj = json.loads(json_str)
msgpack_bytes = msgpack.dumps(dict_obj)
return Literal(scalar=Scalar(binary=Binary(value=msgpack_bytes, tag=MESSAGEPACK)))
def to_python_value(self, ctx, lv, expected_python_type):
if lv and lv.scalar and lv.scalar.binary is not None:
return self.from_binary_idl(lv.scalar.binary, expected_python_type)
json_str = _json_format.MessageToJson(lv.scalar.generic)
return expected_python_type.model_validate_json(json_str, strict=False)
TypeEngine.register(PydanticTransformer())
A few things to note from this example:
enable_type_assertions=Falsedisables the base classisinstancecheck, which would fail becauseBaseModelsubclasses don't matchisinstance(v, BaseModel)the way plain classes doget_literal_type()delegates field types toTypeEngine.to_literal_type()recursively, so nested Flyte types are represented correctlyto_python_value()handles both the new Binary IDL path (viafrom_binary_idl()) and the old protobuf Struct path for backward compatibility with Flyte Console inputs
Inline Transformers with Annotated
Rather than registering globally, you can attach a TypeTransformer instance directly as an annotation. TypeEngine.get_transformer() extracts it from get_args(python_type) before consulting the registry:
from typing_extensions import Annotated
class JsonTypeTransformer(TypeTransformer[T]):
def get_literal_type(self, t):
return LiteralType(simple=SimpleType.STRING)
def to_literal(self, ctx, python_val, python_type, expected):
return Literal(scalar=Scalar(primitive=Primitive(string_value=json.dumps(python_val))))
def to_python_value(self, ctx, lv, expected_python_type):
return json.loads(lv.scalar.primitive.string_value)
# Attach per-type, no global registration needed
MyJsonDict = Annotated[typing.Dict[str, int], JsonTypeTransformer(name="json[dict]", t=dict)]
assert TypeEngine.get_transformer(MyJsonDict) is the_transformer_instance
This pattern is useful when you want different serialization behavior for the same underlying Python type depending on context (e.g., JSON encoding vs. MessagePack encoding for the same dict).
Using SimpleTransformer for Quick Registration
For simple mappings that don't need custom logic, SimpleTransformer takes the boilerplate away:
from flytekit.core.type_engine import TypeEngine, SimpleTransformer
from flytekit.models.literals import Literal, Scalar, Primitive
from flytekit.models.types import LiteralType, SimpleType
class MyInt:
def __init__(self, x: int):
self.val = x
TypeEngine.register(
SimpleTransformer(
"MyInt",
MyInt,
LiteralType(simple=SimpleType.INTEGER),
lambda x: Literal(scalar=Scalar(primitive=Primitive(integer=x.val))),
lambda x: MyInt(x.scalar.primitive.integer),
)
)
Common Pitfalls
Registry is global and permanent. TypeEngine.register() raises ValueError if the type is already registered. There is no unregister method. Plugin tests that register custom types must clean up manually (del TypeEngine._REGISTRY[MyType]). To override an existing registration, use TypeEngine.register_additional_type(transformer, type, override=True).
DataclassTransformer is not in _REGISTRY. It lives as TypeEngine._DATACLASS_TRANSFORMER and is applied as a fallback. This means TypeEngine.guess_python_type() must also check it separately — the method does this explicitly after exhausting the registry.
FlyteAnnotation cannot use "cache-key-metadata" as a key. The CACHE_KEY_METADATA constant is reserved for the internal serialization format annotation that DataclassTransformer and PydanticTransformer embed. TypeEngine.to_literal_type() raises AssertionError if a FlyteAnnotation contains it.
TypeTransformerFailedError inherits from TypeError, AssertionError, and ValueError. This is intentional — UnionTransformer's try/except loops need to catch conversion failures from any transformer without knowing which exception type each raises.
TypeEngine.guess_python_type() is best-effort. It iterates all registered transformers in insertion order and returns the first match. Use explicit as_type arguments (e.g., LiteralsResolver.get(key, as_type=MyClass)) wherever the Python type is known.
FLYTE_USE_OLD_DC_FORMAT migration. Workflows compiled and executed with the old JSON/Struct format will fail to deserialize if the worker runs a newer flytekit that expects Binary IDL, and vice versa. During upgrades, set FLYTE_USE_OLD_DC_FORMAT=true to maintain backward compatibility until all workers are updated.