proto2pydantic

March 25, 2026 · View on GitHub

CI SLSA 3 Go Report Card

A protoc plugin that generates Pydantic v2 BaseModel classes from Protocol Buffer definitions — with google.api.field_behavior support.

Why this exists

Proto3 has no native required keyword. Every field silently accepts zero values, which means proto-generated models can't enforce that critical fields like message_id or url are actually provided.

Google's solution is google.api.field_behavior — annotations that mark fields as REQUIRED, OPTIONAL, OUTPUT_ONLY, etc:

string url = 1 [(google.api.field_behavior) = REQUIRED];  // must be provided
string tenant = 2;                                         // optional, zero-value OK
optional int32 history_length = 3;                         // explicitly optional

The problem: no existing proto-to-Pydantic tool reads these annotations.

ToolReads field_behavior?
protobuf-to-pydantic❌ Uses PGV or custom p2p: comments
protoc-gen-pydantic❌ Uses optional keyword only
protobuf-pydantic-gen❌ Basic mapping only
python-betterproto❌ Uses optional keyword only
proto2pydantic

This means projects like A2A that use field_behavior annotations extensively have had to go through an intermediate JSON Schema step to get proper validation in Pydantic. proto2pydantic eliminates that indirection.

Features

  • 🔒 field_behavior supportREQUIRED → required Pydantic field, OUTPUT_ONLYexclude=True
  • buf/validate support — proto validation rules → Pydantic Field() constraints
  • 🐍 Idiomatic Python — snake_case fields, str Enums, oneof → union types
  • 📦 Well-known typesStructdict[str, Any], Timestampdatetime
  • 🔌 buf native — works as a local or remote buf plugin
  • ⚙️ Configurable — custom base class, camelCase aliases, output filename (see CONFIG.md for full reference)
  • 🔄 Topological sort — models ordered so dependencies are defined before use
  • 📋 __all__ exports — generated files include a clean public API list
  • 🅰️ A2A / ProtoJSON presetpreset=a2a for full ProtoJSON compatibility (camelCase, raw enums, to_proto_json(), RFC 3339 timestamps, base64 bytes)

Install

go install github.com/protocgen/proto2pydantic@latest

Usage

With buf

# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-proto2pydantic
    out: src/types
    opt:
      - preset=a2a
      - base_class=a2a._base.A2ABaseModel
      - output_file=types.py
buf generate

With protoc

protoc --proto2pydantic_out=./output \
       --proto2pydantic_opt=base_class=myapp.BaseModel \
       your_service.proto

How it works

.proto file → protoc/buf → proto2pydantic → .py with Pydantic models

Given:

message AgentInterface {
  string url = 1 [(google.api.field_behavior) = REQUIRED];
  string protocol_binding = 2 [(google.api.field_behavior) = REQUIRED];
  string tenant = 3;
  string protocol_version = 4 [(google.api.field_behavior) = REQUIRED];
}

Generates:

class AgentInterface(BaseModel):
    model_config = ConfigDict(
        populate_by_name=True,
        alias_generator=to_camel,
    )

    url: str = Field(..., description='The URL where this interface is available.')
    protocol_binding: str = Field(..., description='The protocol binding supported at this URL.')
    tenant: str = Field(default='', description='Tenant ID.')
    protocol_version: str = Field(..., description='The version of the A2A protocol.')
  • Field(...) = required — Pydantic raises ValidationError if missing
  • Field(default='') = proto3 zero-value default — field is optional

Validation with buf/validate

proto2pydantic reads buf/validate (the successor to protoc-gen-validate) and maps constraints to Pydantic Field() arguments:

import "buf/validate/validate.proto";

message CreateUserRequest {
  string email = 1 [
    (google.api.field_behavior) = REQUIRED,
    (buf.validate.field).string.email = true
  ];
  string name = 2 [
    (buf.validate.field).string = {min_len: 1, max_len: 100}
  ];
  int32 age = 3 [
    (buf.validate.field).int32 = {gte: 0, lte: 150}
  ];
  repeated string tags = 4 [
    (buf.validate.field).repeated = {min_items: 1, max_items: 10}
  ];
}

Generates:

class CreateUserRequest(BaseModel):
    email: str = Field(...)
    name: str = Field(default='', min_length=1, max_length=100)
    age: int = Field(default=0, ge=0, le=150)
    tags: list[str] | None = Field(default=None, min_length=1, max_length=10)
buf/validate rulePydantic Field()
requiredField(...) — no default, field is required
string.min_lenmin_length=
string.max_lenmax_length=
string.patternpattern=
int32.gte / float.gtege=
int32.lte / float.ltele=
int32.gt / float.gtgt=
int32.lt / float.ltlt=
repeated.min_itemsmin_length=
repeated.max_itemsmax_length=

field_behavior annotations

AnnotationEffect
REQUIREDNo default value → Pydantic requires the field
OUTPUT_ONLYField(exclude=True) → excluded from model_dump()
OPTIONALTreated as proto3 default (zero-value)
(none)proto3 zero-value default

Options

OptionDescriptionExample
presetPreset configuration. a2a auto-sets alias_generator=camel + enum_style=raw for ProtoJSONa2a
base_classCustom base class for modelsa2a._base.A2ABaseModel
alias_generatorAdd model_config with Pydantic's to_camel for ProtoJSON-compatible lowerCamelCase aliases (populate_by_name=True allows both snake_case and camelCase input)camel
enum_styleEnum generation style. raw preserves original proto names (e.g., TASK_STATE_COMPLETED) and includes UNSPECIFIED values for ProtoJSON compatibility. Default strips prefix and lowercasesraw
output_fileOverride output filenametypes.py
strip_proto_suffixUse foo.py instead of foo_pb2_pydantic.pytrue
descriptionOverride module-level docstringA2A type definitions

Type Mapping

ProtoPython
stringstr
int32, int64, etc.int
float, doublefloat
boolbool
bytesbytes (with base64 @field_serializer for ProtoJSON)
repeated Tlist[T]
map<K, V>dict[K, V]
optional TT | None
oneofT1 | T2 | ... | None
google.protobuf.Structdict[str, Any]
google.protobuf.Timestampdatetime (with RFC 3339 @field_serializer for ProtoJSON)
google.protobuf.ValueAny
Enum (default)str Enum (prefix-stripped, lowercase)
Enum (enum_style=raw)str Enum (original proto names, e.g., TASK_STATE_COMPLETED)

ProtoJSON / A2A Support

For projects following the ProtoJSON specification (like A2A per ADR-001), use the preset=a2a option:

# buf.gen.yaml
plugins:
  - local: protoc-gen-proto2pydantic
    out: src/types
    opt:
      - preset=a2a

This enables:

ProtoJSON RequirementWhat preset=a2a does
camelCase field namesalias_generator=to_camel + populate_by_name=True
SCREAMING_SNAKE_CASE enumsenum_style=raw preserves original proto names
UNSPECIFIED enum valuesIncluded (not skipped)
Null omissionto_proto_json() method on every model
Timestamp → RFC 3339@field_serializer emitting "2025-01-01T10:00:00.000Z"
bytes → base64@field_serializer emitting base64-encoded strings

Round-trip example

from generated_types import Task, TaskState

# Deserialize ProtoJSON → Pydantic (camelCase keys accepted)
task = Task.model_validate({
    "id": "task-123",
    "contextId": "ctx-456",
    "status": {"state": "TASK_STATE_WORKING"}
})

# Pythonic access
assert task.context_id == "ctx-456"
assert task.status.state == TaskState.TASK_STATE_WORKING

# Serialize back to ProtoJSON (camelCase keys, no None values)
proto_json = task.to_proto_json()
# {"id": "task-123", "contextId": "ctx-456", "status": {"state": "TASK_STATE_WORKING"}}

Contributing

See CONTRIBUTING.md for development setup, PR process, and commit signing requirements.

Security & Supply Chain

All release binaries include SLSA Level 3 provenance. Verify a downloaded binary:

# Install the verifier
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest

# Verify
slsa-verifier verify-artifact proto2pydantic_0.2.0_linux_amd64.tar.gz \
  --provenance-path multiple.intoto.jsonl \
  --source-uri github.com/protocgen/proto2pydantic

Additional security measures:

MeasureDetails
SLSA L3 provenanceSigned build attestations for every release
CodeQLSemantic code analysis on every PR + weekly scan
govulncheckGo vulnerability database checks in CI
gosecGo security linter in CI
Signed commitsRequired on main via repository ruleset
Immutable tagsRelease tags cannot be deleted or force-pushed
DependabotAutomated dependency updates (Go modules + GitHub Actions)

See SECURITY.md for reporting vulnerabilities.

License

Apache-2.0