Picking a union realization mode¶
A "union realization" refers to the moment at which the members of a union are
decided. At realization time, the types that are being tracked in a
TrackingGroup are extracted from the internal mapping and produce a union for
use in a pydantic schema.
When using TrackingGroup directly, there is only one option for when the union
is realized, which is the moment you call .union(). At this point, any classes
that have been registered will be present in the union. Registration of
additional classes after a call to .union() will not update the returned
union from a previous call, so it is important to consider order of operations.
SubclassTrackingModel, is a bit more flexible in this regard. There are
currently three options for when a union may be realized, each with their own
tradeoffs:
-
Immediately: When using
dynapydantic.Union[T], the union is realized immediately. This is the "most eager" option, but is also the most sensitive with order of operations. Type checkers will not understand direct calls tounion(), butdynapydantic.Union[T]will resolve toT.Despite the tradeoff on sensitivity to order of operations, this option can be desirable for applications that inspect field annotations directly. This normally arises in user-implemented model reflection code and with
pydantic_settings. -
Model-construction time: Instead of eagerly realizing the union in the field annotation,
dynapydantic.Polymorphic[T], in its default configuration, will defer the union realization slightly, into the schema generation step for the model. The difference between this and option 1 is subtle, but does have an effect with recursive models. Consider the following:if we usedimport dynapydantic import pydantic class Base(dynapydantic.SubclassTrackingModel, union_mode="smart"): pass class A(Base, extra="forbid"): a: int class B(Base, extra="forbid"): other: dynapydantic.Polymorphic[Base] B(other={"other": {"other": {"a": 2}}}) # ValidationError (union only has A) B.model_rebuild(force=True) B(other={"other": {"other": {"a": 2}}}) # B(other=B(other=B(other=A(a=2))))Union[Base], themodel_rebuild()call would do nothing, as the union had already been realized. To accomplish the same thing with eager unions, we would have to use a forward reference, like"BUnion"then calldynapydantic.union(Base)right before themodel_rebuild()calls.Similar to
dynapydantic.Union,dynapydantic.Polymorphicis interpretable by the type checker, which will constrain fields to be of the base class type. -
Validation time: Finally, realization of the union can be deferred to validation time. This makes the union construction process more robust to order of operations. In this formulation, all subclasses must be registered before the use of the union in validation, rather than the declaration of a model using the union field. This reduces the previous example down to:
import dynapydantic import pydantic class Base( dynapydantic.SubclassTrackingModel, union_mode="smart", union_realization="validation", ): pass class A(Base, extra="forbid"): a: int class B(Base, extra="forbid"): other: dynapydantic.Polymorphic[Base] B(other={"other": {"other": {"a": 2}}}) # B(other=B(other=B(other=A(a=2))))This option has the cleanest syntax, as no
model_rebuild()calls are needed, but does incur a runtime penalty for potentially multiple schema compilations and the need for a field validator function, whereas options 1 and 2 can produce static schema. When validating from JSON, the field validator receives data after Pydantic has decoded it. To preserve Pydantic's JSON-specific validation behavior (including strict validation), validation-time unions re-encode the field value before validating it with the realized adapter. This adds JSON encoding and decoding overhead on top of the normal validation cost.See the benchmarks for a full picture of how validation-time unions perform. They have a significant first-time validation penalty for building the schema and a consistent per-validation penalty for the validator-based schema rather than a static schema.
Like option 2, the field is able to be interpreted by type checkers as the base class.
The union realization mode can be configured in the following ways:
- Via a class keyword argument to define the default behavior for a base type:
import dynapydantic class Base( dynapydantic.SubclassTrackingModel, union_mode="smart", union_realization="validation", ): pass # subclass definitions... class Model(pydantic.BaseModel): field: dynapydantic.Polymorphic[Base] # validation-time - Via a per-usage override via the
dynapydantic.Polymorphicannotation:import dynapydantic import pydantic class Base( dynapydantic.SubclassTrackingModel, union_mode="smart", ): pass # subclass definitions... class Model(pydantic.BaseModel): field: dynapydantic.Polymorphic[Base, "validation"]
The supported options are "model-construction" (default) or "validation"
(or their corresponding UnionRealization enum values).