Skip to main content

protodantic

CI PyPI Python Pydantic v2 License: MIT

Bidirectional bridge between Protocol Buffers and Pydantic.

Point it at your .proto files and it generates plain pydantic v2 models — with full validation — where every model round-trips losslessly to and from real protobuf messages, wire bytes, and proto JSON. The pydantic → proto direction is a first-class citizen: to_proto_bytes() produces genuine wire-format output that any protobuf consumer in any language can parse.

Install

uv add protodantic-py   # or: pip install protodantic-py

The distribution is named protodantic-py (the plain name is squatted on PyPI); the import stays protodantic:

import protodantic

Usage

Given demo.proto:

syntax = "proto3";
package demo;

message Address {
  string street = 1;
  string city = 2;
}

message User {
  int64 id = 1;
  string name = 2;
  Address address = 3;
  repeated string tags = 4;
  optional string nickname = 5;
}

Generate models:

protodantic generate demo.proto -o models.py

Or point it at a whole directory of protos to get a python package tree mirroring your proto files (one module per file, single shared descriptor pool, relocatable):

protodantic generate ./protos -o generated/
# generated/myorg/billing.py, generated/myorg/common.py, ...

For a mixed proto2/proto3 source tree, explicitly generate only its proto3 subset:

protodantic generate ./protos --proto2 skip -o generated/

Skipped proto2 descriptors remain embedded for import and custom-option resolution. If a generated proto3 field directly references a skipped proto2 type, generation fails loudly instead of emitting an incomplete model.

from generated.myorg.billing import Invoice

Layout follows the input shape (files → single module, directory → package tree); override with --layout module|tree. Regenerating into an existing tree is managed-clean: stale modules from deleted protos are removed, and any file protodantic didn't generate aborts the run untouched.

Then:

from models import User, Address

user = User(id=7, name="kory", address=Address(city="Warsaw"), tags=["a", "b"])

# pydantic -> proto: real wire format, readable by any protobuf runtime
data: bytes = user.to_proto_bytes()
msg = user.to_proto()          # a live protobuf Message
json_str = user.to_proto_json()  # canonical proto JSON

# proto -> pydantic: parse + validate in one step
restored = User.from_proto_bytes(data)
assert restored == user

Or drive it from Python:

from protodantic import compile_fdset, fdset_from_package, generate_source

source_from_proto = generate_source(compile_fdset(["demo.proto"]))
source_from_package = generate_source(fdset_from_package("my_org_protos"))

Type mapping

proto pydantic
int32/64, uint32/64, sint, fixed range-validated int (out-of-range fails at construction)
float, double float
string / bytes / bool str / bytes / bool
enum generated OpenEnum (IntEnum that preserves unknown wire values — proto3 enums are open)
message generated ProtoModel (nested types flattened as Outer_Inner)
repeated T / map<K, V> list[T] / dict[K, V]
optional, oneof members, singular messages T | None (presence-aware: None ⇄ unset)
oneof groups mutual exclusion enforced by a model validator
google.protobuf.Timestamp datetime.datetime (UTC; naive input treated as UTC)
google.protobuf.Duration datetime.timedelta
google.protobuf.*Value wrappers T | None
google.protobuf.Struct / Value / ListValue dict[str, Any] / Any / list[Any]
google.protobuf.Any typing.Any — accepts any ProtoModel; packed/unpacked via the model registry

Field names that collide with python keywords or pydantic internals (class, from, model_config, ...) get a trailing underscore (class_) with the proto name kept as a populate alias. The same rule applies to message/enum type names and enum members that are python keywords or would shadow generated code (message listclass list_) — the proto full name stays the source of truth. Same-named messages in different packages get package-qualified class names; every model is also reachable via protodantic.model_for("pkg.Message").

Semantics worth knowing:

  • Validation on mutation is on by default (validate_assignment=True): assigning a second oneof member or an out-of-range int raises immediately. Opt out per-model with standard pydantic config on a subclass.
  • protodantic.NULL expresses an explicit JSON null in a google.protobuf.Value field (None means unset). In model_dump_json() it serializes as real null; python-mode dumps keep the sentinel.
  • Subclassing a generated model does not affect parsing: from_proto/model_for keep resolving to the generated class. To make your subclass the resolution target (e.g. to add custom validators applied on parse), re-declare __proto_full_name__ in its body — explicit opt-in.

Interop with existing _pb2 code

Already consuming a centralized proto package as protoc-generated _pb2 modules? Generate models straight from it — no .proto sources needed:

protodantic generate --from-package my_org_protos -o generated/

Reflection imports *_pb2 modules without importing helper modules or gRPC stubs. Generated module paths follow the proto file names recorded in their descriptors. Generated models also interoperate directly with _pb2 instances:

user = User.from_proto(their_pb2_user_instance)   # accepts _pb2 messages
their_msg = user.to_proto(into=their_pb2.User)    # returns THEIR class
raw = their_pb2.User.FromString(user.to_proto_bytes())  # canonical bytes

How it works

Both input paths produce serialized FileDescriptorSet bytes: grpcio-tools compiles .proto sources, while descriptor reflection reads installed _pb2 packages. The same code generator consumes either form. At runtime, ProtoModel builds protobuf message classes from the embedded descriptors.

If several imported generated modules define the same proto type, the registry behind model_for() / nested-message resolution is last-import-wins.

Status & roadmap

Requires Python ≥ 3.11. proto3 only by design — proto2 input is rejected with a clear error, but mixed proto2/proto3 packages (common in enterprise repos) can opt into --proto2 skip to generate the proto3 subset: skipped files are named in an audit comment, and any proto3 field depending on a proto2 type fails loudly instead of generating a hole. The full supported-behavior spec lives in tests/ — every test documents one use case. Documented policies: unknown fields are dropped when a model re-serializes (the model is the source of truth), and naive datetimes are interpreted as UTC.

  • 0.1.3 (current) — mixed proto2/proto3 packages can explicitly skip proto2 files while generating a complete, audited proto3 subset.
  • 0.2.x — brownfield — reverse schema codegen: pydantic models → .proto.
  • 0.3.0 — performance — benchmark suite (vs json.loads+pydantic, raw _pb2, betterproto), then cached field plans and trusted-construction fast paths.

gRPC service stubs are out of scope: protodantic is a message layer.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

protodantic_py-0.1.3.tar.gz (110.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

protodantic_py-0.1.3-py3-none-any.whl (25.6 kB view details)

Uploaded Python 3

File details

Details for the file protodantic_py-0.1.3.tar.gz.

File metadata

  • Download URL: protodantic_py-0.1.3.tar.gz
  • Upload date:
  • Size: 110.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for protodantic_py-0.1.3.tar.gz
Algorithm Hash digest
SHA256 28d0b8e7f59c21107544a33491eb2fd9191bced847247e9a8cca96e13fe284b6
MD5 0cffe1a3b3d7c057fa0edfec3a2070c9
BLAKE2b-256 20c288aa0af5b1c75b014f260808c09b1a51cc1d92b447847e091fa3cfada008

See more details on using hashes here.

Provenance

The following attestation bundles were made for protodantic_py-0.1.3.tar.gz:

Publisher: release.yml on Koryto/protodantic

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file protodantic_py-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: protodantic_py-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 25.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for protodantic_py-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 d10f004eee8ceea451f020d5de4228a662295b839ee35acdb5e144bc49e871a2
MD5 47f75c6da5716a550007d4bea532af84
BLAKE2b-256 23503e28eb14771494aa79653df44a2fd971d278736994bdff5ecbe670afef6a

See more details on using hashes here.

Provenance

The following attestation bundles were made for protodantic_py-0.1.3-py3-none-any.whl:

Publisher: release.yml on Koryto/protodantic

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.3 This release

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page