protodantic
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 list → class 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.NULLexpresses an explicit JSON null in agoogle.protobuf.Valuefield (Nonemeans unset). Inmodel_dump_json()it serializes as realnull; python-mode dumps keep the sentinel.- Subclassing a generated model does not affect parsing:
from_proto/model_forkeep 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
28d0b8e7f59c21107544a33491eb2fd9191bced847247e9a8cca96e13fe284b6
|
|
| MD5 |
0cffe1a3b3d7c057fa0edfec3a2070c9
|
|
| BLAKE2b-256 |
20c288aa0af5b1c75b014f260808c09b1a51cc1d92b447847e091fa3cfada008
|
Provenance
The following attestation bundles were made for protodantic_py-0.1.3.tar.gz:
Publisher:
release.yml on Koryto/protodantic
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
protodantic_py-0.1.3.tar.gz -
Subject digest:
28d0b8e7f59c21107544a33491eb2fd9191bced847247e9a8cca96e13fe284b6 - Sigstore transparency entry: 2138054137
- Sigstore integration time:
-
Permalink:
Koryto/protodantic@c57203873803690f819884fb1a5b650314145291 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/Koryto
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c57203873803690f819884fb1a5b650314145291 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d10f004eee8ceea451f020d5de4228a662295b839ee35acdb5e144bc49e871a2
|
|
| MD5 |
47f75c6da5716a550007d4bea532af84
|
|
| BLAKE2b-256 |
23503e28eb14771494aa79653df44a2fd971d278736994bdff5ecbe670afef6a
|
Provenance
The following attestation bundles were made for protodantic_py-0.1.3-py3-none-any.whl:
Publisher:
release.yml on Koryto/protodantic
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
protodantic_py-0.1.3-py3-none-any.whl -
Subject digest:
d10f004eee8ceea451f020d5de4228a662295b839ee35acdb5e144bc49e871a2 - Sigstore transparency entry: 2138054170
- Sigstore integration time:
-
Permalink:
Koryto/protodantic@c57203873803690f819884fb1a5b650314145291 -
Branch / Tag:
refs/heads/master - Owner: https://github.com/Koryto
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c57203873803690f819884fb1a5b650314145291 -
Trigger Event:
push
-
Statement type: