Skip to content

Conversation

@abrookins
Copy link
Collaborator

@abrookins abrookins commented Dec 8, 2025

Pydantic 2.12+ FieldInfo Subclass Regression

The Problem

Pydantic Issue: #12359FieldInfo.from_annotations no longer returns subclasses of FieldInfo.

What Changed in Pydantic 2.12

Pydantic 2.12 introduced a complete refactor of the FieldInfo class (PRs #11388 and #11898) to fix ~10 separate bugs.
This refactor changed the behavior of FieldInfo.from_annotation():

  • Pydantic 2.11.x and earlier:
    When a custom FieldInfo subclass appeared in Annotated metadata, from_annotation() preserved the subclass type.

  • Pydantic 2.12+:
    from_annotation() now returns a plain PydanticFieldInfo instance instead of preserving the subclass.

How This Affected redis-om-python

Redis OM Python uses a custom FieldInfo subclass with additional Redis-specific attributes:

class FieldInfo(PydanticFieldInfo):
    def __init__(self, default: Any = ..., **kwargs: Any) -> None:
        # Custom attributes for Redis indexing
        self.primary_key = kwargs.pop("primary_key", False)
        self.sortable = kwargs.pop("sortable", None)
        self.index = kwargs.pop("index", None)
        self.full_text_search = kwargs.pop("full_text_search", None)
        # ...

When users define fields with Annotated types like Coordinates:

class Location(JsonModel, index=True):
    coordinates: Coordinates = Field(index=True)

Where Coordinates is:

Coordinates = Annotated[
    CoordinateType,
    PlainSerializer(...),
    BeforeValidator(parse_redis),
]

Under Pydantic 2.12+, the internal steps are:

  1. Process the Annotated type and read metadata.
  2. Call FieldInfo.from_annotation().
  3. Receive a plain PydanticFieldInfo (not the custom subclass).
  4. Lose custom attributes:
    • index
    • sortable
    • primary_key
    • full_text_search
    • vector_options
    • case_sensitive

The Fix

The fix preserves the original custom FieldInfo objects by capturing them before Pydantic's metaclass runs and restoring them afterward:

# Before Pydantic processes the model:
original_field_infos: Dict[str, FieldInfo] = {}
for attr_name, attr_value in attrs.items():
    if isinstance(attr_value, FieldInfo):
        original_field_infos[attr_name] = attr_value

# After Pydantic processes the model:
if type(field) is PydanticFieldInfo:
    if field_name in original_field_infos:
        field = original_field_infos[field_name]
        field.annotation = pydantic_field.annotation
        field.metadata = pydantic_field.metadata

Will Pydantic Restore the Original Behavior?

No. Per Pydantic issue #12374:

  1. The old behavior was considered buggy — even older versions failed when multiple FieldInfo instances existed in metadata.
  2. Subclassing FieldInfo is discouraged — composition is recommended instead:

    “If possible, I would encourage libraries to avoid subclassing FieldInfo, and use composition if possible.”

  3. No guarantee of full compatibility:

    “Many external libraries are setting too many expectations on the FieldInfo logic… I don't know if we'll be able to fully preserve compatibility for every use case.”

Long-term Implications

The workaround in this PR is appropriate for now.
A more future-proof approach would be refactoring redis-om-python to use composition (custom metadata in Annotated) rather than inheritance, though this would be a larger change.

…types

Pydantic 2.12+ converts custom FieldInfo subclasses to plain PydanticFieldInfo
for fields using Annotated types with metadata (like Coordinates). This caused
custom attributes like index, sortable, etc. to be lost.

Fix: Capture original FieldInfo objects before Pydantic processes them and
restore them when Pydantic has converted them to plain PydanticFieldInfo.
mypy 1.19+ depends on librt which only has CPython wheels.
When Poetry tries to install on PyPy, it falls back to building
from source, which fails because librt uses CPython-specific C APIs.
@abrookins abrookins self-assigned this Dec 9, 2025
Copy link

@justin-cechmanek justin-cechmanek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@abrookins abrookins merged commit 65d3bcf into main Dec 10, 2025
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants