Custom recursive model_construct helper (pydantic v2, trusted-data fast path) leaves enum-annotated fields holding raw strings, surfacing later as UserWarning: Pydantic serializer warnings: PydanticSerializationUnexpectedValue(Expected 'enum' - serialized value may not be as expected). Adding an enum-coercion branch didn't fix union-typed fields (MyStrEnum | None): the union handler used if result != value: return result to detect whether recursion transformed the value, but for str-enums MyStrEnum('monthly') == 'monthly' is True, so the freshly coerced enum member was silently discarded and the raw str kept.
Two fixes needed:
model_construct (and any hand-rolled recursive variant) skips validation entirely, so enum fields keep whatever raw value was passed. Add an explicit branch: if issubclass(annotation, enum.Enum) and not isinstance(value, annotation): return annotation(value) — exactly what pydantic validation would do.
In union-dispatch code, never use equality to detect 'did the recursive call coerce this value' when enums may be involved. Members of class Foo(str, Enum) (and int-enums) compare EQUAL to their raw values, and hash identically, so != checks and dict lookups mask the difference. Use identity instead: if result is not value: return result. No-op paths return the same object; transforming paths return a new one, so identity is the reliable signal.
Symptom to watch for: everything appears to work (str-enum == str comparisons pass, dict lookups keyed by the enum succeed) until pydantic serializes the model and emits PydanticSerializationUnexpectedValue — which is a UserWarning, not an error, so it's easy to carry silently in tests.