Skip to content

Subclass Serialization Includes Child Fields with MISSING Sentinel #13001

@knit-nisha

Description

@knit-nisha

Initial Checks

  • I confirm that I'm using Pydantic V2

Description

Pydantic Bug Report: Subclass Serialization Includes Child Fields with MISSING Sentinel

Summary

When a base class contains fields with | MISSING type annotation AND a container field uses | MISSING in its union type, Pydantic fails to properly serialize subclasses according to their declared parent type. Instead, it serializes the full runtime (child) type, including fields that should be excluded.

Versions

  • pydantic: 2.12.5
  • pydantic_core: 2.41.5

Minimal Reproducer

from pydantic import BaseModel, ConfigDict, TypeAdapter
from pydantic.experimental.missing_sentinel import MISSING

class Base(BaseModel):
    model_config = ConfigDict(extra='forbid')
    base_field: str | MISSING = MISSING  # Key ingredient 1

class Parent(Base):
    parent_field: str

class Child(Parent):
    child_field: str  # Should NOT appear when serializing as Parent

class Container(BaseModel):
    model_config = ConfigDict(extra='forbid')
    item: Parent | MISSING = MISSING  # Key ingredient 2

# Create child and assign to parent-typed field
child = Child(parent_field='p', child_field='c')
container = Container(item=child)

# Serialize
result = TypeAdapter(Container).dump_python(container, mode='json')

# Expected: {'item': {'parent_field': 'p'}}
# Actual:   {'item': {'parent_field': 'p', 'child_field': 'c'}}
print(f"Has child_field (BUG): {'child_field' in result.get('item', {})}")  # True (incorrect)

Control Tests

The issue only occurs when both conditions are met:

Without MISSING in base class - Works correctly

class BaseNoMissing(BaseModel):
    model_config = ConfigDict(extra='forbid')
    base_field: str = ''  # Not MISSING

class ParentNoMissing(BaseNoMissing):
    parent_field: str

class ChildNoMissing(ParentNoMissing):
    child_field: str

class ContainerControl1(BaseModel):
    model_config = ConfigDict(extra='forbid')
    item: ParentNoMissing | MISSING = MISSING

child1 = ChildNoMissing(parent_field='p', child_field='c')
container1 = ContainerControl1(item=child1)
result1 = TypeAdapter(ContainerControl1).dump_python(container1, mode='json')
print(f"Without MISSING in base: has child_field = {'child_field' in result1.get('item', {})}")  # False (correct)

Without MISSING in container field union - Works correctly

class ContainerControl2(BaseModel):
    model_config = ConfigDict(extra='forbid')
    item: Parent  # No MISSING union

child2 = Child(parent_field='p', child_field='c')
container2 = ContainerControl2(item=child2)
result2 = TypeAdapter(ContainerControl2).dump_python(container2, mode='json')
print(f"Without MISSING in container: has child_field = {'child_field' in result2.get('item', {})}")  # False (correct)

Using None instead of MISSING - Works correctly

class ContainerWithNone(BaseModel):
    model_config = ConfigDict(extra='forbid')
    item: Parent | None = None

child3 = Child(parent_field='p', child_field='c')
container3 = ContainerWithNone(item=child3)
result3 = TypeAdapter(ContainerWithNone).dump_python(container3, mode='json')
print(f"With None instead of MISSING: has child_field = {'child_field' in result3.get('item', {})}")  # False (correct)

Expected Behavior

When serializing a child class instance through a field typed as the parent class, Pydantic should only serialize fields defined in the declared type (parent), excluding fields added by subclasses.

Actual Behavior

When both a base class in the inheritance chain has | MISSING fields AND the container field uses | MISSING union, Pydantic serializes all fields from the runtime type, including child-specific fields that should be excluded.

Impact

This breaks type safety in APIs where the response schema declares a parent type but the implementation may return subclasses. Clients receive unexpected extra fields that violate the API contract.

Workaround

Replace | MISSING with | None in the container field type annotation:

class Container(BaseModel):
    item: Parent | None = None  # Instead of: Parent | MISSING = MISSING

This changes the API semantics slightly (returns null instead of omitting the field when unset), but fixes the serialization bug.

Root Cause Analysis

The combination of MISSING in both the base class fields and the container union appears to break Pydantic's serialization schema generation. The serializer falls back to using the runtime type rather than the declared type annotation.

When serializing directly with TypeAdapter(TicketShort) on the child instance, it correctly excludes child fields. The issue only manifests when serializing through a container with the problematic | MISSING union pattern.

Example Code

Python, Pydantic & OS Version

pydantic version: 2.12.5
        pydantic-core version: 2.41.5
          pydantic-core build: profile=release pgo=false
               python version: 3.13.11 (main, Feb  2 2026, 19:03:49) [Clang 17.0.0 (clang-1700.3.19.1)]
                     platform: macOS-26.3.1-arm64-arm-64bit-Mach-O
             related packages: fastapi-0.128.6 mypy-1.19.1 email-validator-2.3.0 typing_extensions-4.15.0
                       commit: unknown

Metadata

Metadata

Assignees

Labels

bug V2Bug related to Pydantic V2pendingIs unconfirmed

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions