Skip to content

Nested Types

Proto3 allows messages and enums to be defined inside other messages. protoc-gen-pydantic generates these as true Python nested classes, so they are accessible via dotted attribute access — exactly as you would expect from idiomatic Python.

Nested messages

message Shipment {
  message Item {
    string sku      = 1;
    int32  quantity = 2;
    double price    = 3;
  }

  string        order_id = 1;
  repeated Item items    = 2;
}
class Shipment(_ProtoModel):
    class Status(str, _Enum):
        UNSPECIFIED = "UNSPECIFIED"  # 0
        PENDING = "PENDING"  # 1
        SHIPPED = "SHIPPED"  # 2
        DELIVERED = "DELIVERED"  # 3

    class Item(_ProtoModel):
        sku: str = _Field(default="")
        quantity: int = _Field(default=0)
        price: float = _Field(default=0.0)

    order_id: str = _Field(default="")
    items: "list[Shipment.Item]" = _Field(
        default_factory=list,
    )
    status_note: str = _Field(default="")
    status: "Shipment.Status | None" = _Field(default=None)
# Usage
shipment = Shipment(
    order_id="shp-1",
    items=[
        Shipment.Item(sku="ABC", quantity=2, price=9.99),
        Shipment.Item(sku="XYZ", quantity=1, price=24.99),
    ],
)
print(shipment.items[0].sku)  # ABC

Nested enums

Enums nested inside a message become nested classes of that message:

message Shipment {
  enum Status {
    STATUS_UNSPECIFIED = 0;
    STATUS_PENDING     = 1;
    STATUS_SHIPPED     = 2;
    STATUS_DELIVERED   = 3;
  }

  string status_note = 1;
  Status status      = 2;
}
class Shipment(_ProtoModel):
    class Status(str, _Enum):
        UNSPECIFIED = "UNSPECIFIED"  # 0
        PENDING = "PENDING"  # 1
        SHIPPED = "SHIPPED"  # 2
        DELIVERED = "DELIVERED"  # 3

    class Item(_ProtoModel):
        sku: str = _Field(default="")
        quantity: int = _Field(default=0)
        price: float = _Field(default=0.0)

    order_id: str = _Field(default="")
    items: "list[Shipment.Item]" = _Field(
        default_factory=list,
    )
    status_note: str = _Field(default="")
    status: "Shipment.Status | None" = _Field(default=None)
# Usage
shipment = Shipment(status=Shipment.Status.PENDING)
print(shipment.status)  # 'PENDING'

Deeply nested types

Nesting can go arbitrarily deep:

message Outer {
  message Inner {
    message Deepest {
      string deepest_field = 1;
    }
  }
  Inner inner = 1;
}
class Outer(_ProtoModel):
    """
    Outer message comment.
    """

    class OuterEnum(str, _Enum):
        """
        Outer enum comment.
        """

        UNSPECIFIED = "UNSPECIFIED"  # 0
        X = "X"  # 1

    class Inner(_ProtoModel):
        """
        Inner message comment.
        """

        class InnerEnum(str, _Enum):
            """
            Inner enum comment.
            """

            UNSPECIFIED = "UNSPECIFIED"  # 0
            A = "A"  # 1

        class Deepest(_ProtoModel):
            """
            Deepest message comment.
            """

            # Deepest field comment.
            deepest_field: str = _Field(
                default="",
                description="Deepest field comment.",
            )

        # Inner field comment.
        inner_field: str = _Field(
            default="",
            description="Inner field comment.",
        )

    # Outer field comment.
    outer_field: str = _Field(
        default="",
        description="Outer field comment.",
    )

Cross-file references

When a message in one file references a nested type from another file, the import uses only the top-level class name. The nested path is resolved via dotted attribute access at runtime:

# gen/collections_pydantic.py
from .scalars_pydantic import Scalars


class Collections(_ProtoModel):
    nested_enum_repeated: "list[Scalars.NestedEnum]" = _Field(default_factory=list)

This means you only import Scalars, not Scalars.NestedEnum directly — Python resolves the dotted access automatically.