protobuf
protobuf is the Python runtime for Protocol Buffers, Google's binary serialization format. You describe messages in a .proto schema, compile it with the protoc compiler into _pb2.py modules, and get message classes that serialize to a compact, versioned wire format any other protobuf language can read. Compared to JSON you get smaller payloads, a typed schema both sides agree on, and rules for evolving that schema without breaking old readers. It is the wire format under gRPC, which is why this package sits near the top of PyPI downloads even in codebases that never mention it directly.
The standard for cross-language binary serialization and mandatory with gRPC; the format and rules are excellent even though the Python developer experience (codegen, version skew, un-Pythonic objects) is the worst part of the ecosystem. If you do not need cross-language contracts or gRPC, plain JSON or msgpack will cost you far less friction.
Use it if
- You use gRPC anywhere: protobuf is not optional there, and understanding the generated message classes is understanding your API
- Services in different languages exchange data: one .proto file generates matching code for Python, Go, Java, C++, and more, with the wire format as the contract
- Payload size and parse speed matter: binary protobuf is much smaller than JSON and the default upb-backed Python runtime parses in C, not Python
- You need schema evolution discipline: field numbers, unknown-field preservation, and documented rules let old and new binaries coexist safely
- You control both ends and traffic is small: JSON with a validation library ships today with no codegen step, no protoc in CI, and payloads a human can read in logs
- Your team will resent the toolchain: every schema change requires regenerating _pb2 files, keeping protoc and the runtime compatible, and wiring generation into the build; that overhead never goes away
- You want Pythonic objects: generated classes have no keyword-only niceties, cannot be subclassed usefully, reject direct assignment to nested message fields, and confuse type checkers without the separate mypy-protobuf stubs
- The yearly major-version policy scares your dependency tree: the runtime majors bump on a schedule (this line is 7.x), gencode and runtime majors must match, and libraries pinning different majors regularly deadlock pip resolvers
Setup reality
pip install protobuf installs only the runtime; the protoc compiler comes separately, either as a release binary from GitHub or via pip install grpcio-tools and python -m grpc_tools.protoc. You then check in or build-generate _pb2.py files, and imports break in ways that only make sense once you know the generated file naming (foo.proto becomes foo_pb2.py and imports its dependencies by that name). Version skew is the recurring tax: generated code carries the gencode version and the runtime enforces compatibility, so mixing an old checked-in _pb2.py with a new runtime, or two libraries pinning different protobuf majors, produces errors that Google's own packages (grpcio-status, googleapis-common-protos) are frequent parties to.
Patterns
Compile a .proto file to Pythoncompile-proto-file
# person.proto
# syntax = "proto3";
# message Person {
# string name = 1;
# int32 id = 2;
# repeated string emails = 3;
# }
# with a protoc release binary:
protoc --python_out=. person.proto
# or pip-installable, plus pyi stubs for type checkers:
python -m grpc_tools.protoc -I. --python_out=. --pyi_out=. person.protoOutput is person_pb2.py; the _pb2 suffix is fixed. Keep the protoc/gencode version and the installed protobuf runtime in compatible majors or imports will fail at runtime.
Create and populate a messagecreate-message
import person_pb2
p = person_pb2.Person()
p.name = "Ada"
p.id = 1234
p.emails.append("ada@example.com")
# or as constructor kwargs:
p2 = person_pb2.Person(name="Grace", id=7, emails=["g@example.com"])Assigning a wrong type raises TypeError immediately. Unset scalar fields read as defaults (0, empty string), not None.
Serialize to bytes and parse backserialize-parse
import person_pb2
p = person_pb2.Person(name="Ada", id=1234)
data = p.SerializeToString() # bytes
q = person_pb2.Person()
q.ParseFromString(data)
print(q.name) # AdaSerializeToString returns bytes despite the name. ParseFromString is a method on an existing instance and merges into it; call Clear() first if reusing objects.
Set a nested message fieldnested-message-copyfrom
import addressbook_pb2
book = addressbook_pb2.AddressBook()
# direct assignment raises AttributeError:
# book.owner = person # WRONG
book.owner.CopyFrom(person)
# or set subfields in place:
book.owner.name = "Ada"Message fields cannot be assigned wholesale; use CopyFrom or mutate subfields. Reading book.owner never returns None, so use HasField('owner') to test presence.
Work with repeated and map fieldsrepeated-and-map-fields
book = addressbook_pb2.AddressBook()
person = book.people.add() # append-and-return for repeated messages
person.name = "Ada"
book.people.extend([other_person])
# map<string, string> attributes = 4;
person.attributes["team"] = "infra"
for key, value in person.attributes.items():
print(key, value)Repeated message fields have add(), not append() with a constructed message assignment; scalars use append/extend. Maps behave like dicts but values that are messages need get_or_create or subfield mutation.
Convert between protobuf and JSONjson-conversion
from google.protobuf import json_format
json_str = json_format.MessageToJson(person, preserving_proto_field_name=True)
p = json_format.Parse(json_str, person_pb2.Person())
d = json_format.MessageToDict(person)By default field names become lowerCamelCase in JSON; preserving_proto_field_name keeps snake_case. Parse raises on unknown fields unless ignore_unknown_fields=True.
Distinguish unset from default valuesfield-presence
# proto3:
# message Update {
# optional int32 retries = 1;
# }
u = update_pb2.Update()
print(u.HasField("retries")) # False
u.retries = 0
print(u.HasField("retries")) # True, even though value is the defaultPlain proto3 scalars have no presence: 0 and unset are indistinguishable and HasField raises. Mark scalars optional to get explicit presence tracking; message fields always support HasField.
Use oneof and detect which field is setoneof-fields
# message Payment {
# oneof method {
# CardInfo card = 1;
# string iban = 2;
# }
# }
p = payment_pb2.Payment()
p.iban = "DE89..."
print(p.WhichOneof("method")) # 'iban'
p.card.number = "4111" # setting card clears iban
print(p.WhichOneof("method")) # 'card'Setting any member of a oneof clears the others silently; WhichOneof returns the set field's name or None. Reading an unset member still returns a default value, not an error.
Use Timestamp and other well-known typeswell-known-timestamp
from google.protobuf.timestamp_pb2 import Timestamp
from datetime import datetime, timezone
ts = Timestamp()
ts.GetCurrentTime()
ts.FromDatetime(datetime(2026, 8, 5, tzinfo=timezone.utc))
dt = ts.ToDatetime(tzinfo=timezone.utc)ToDatetime without tzinfo returns a naive UTC datetime, a classic source of off-by-timezone bugs. Import well-known types from google.protobuf, not your own generated modules.
Work with enum fieldsenums
# enum Status { STATUS_UNSPECIFIED = 0; ACTIVE = 1; DISABLED = 2; }
acct = account_pb2.Account()
acct.status = account_pb2.ACTIVE
print(acct.status) # 1 (an int)
print(account_pb2.Status.Name(acct.status)) # 'ACTIVE'
print(account_pb2.Status.Value("DISABLED")) # 2Enum fields are plain ints in Python. proto3 requires the zero value first (conventionally *_UNSPECIFIED), and unknown incoming values are preserved as ints rather than rejected.
Print and parse human-readable text formattext-format-debugging
from google.protobuf import text_format
print(text_format.MessageToString(person))
p = person_pb2.Person()
text_format.Parse('name: "Ada" id: 1234', p)Text format is the debugging lingua franca (it is what str(message) shows) and is also used for config files, but it is not a stable interchange format across schema changes the way binary is.
Diagnose gencode/runtime version mismatchescheck-runtime-version
import google.protobuf
print(google.protobuf.__version__) # e.g. 7.35.1
# typical failure: a checked-in _pb2.py generated by an old protoc
# raises at import time telling you to regenerate.
# fix: regenerate with a matching protoc
python -m grpc_tools.protoc -I. --python_out=. your.protoSince the 5.x line, generated code validates against the runtime at import. Regenerating is the fix; pinning the runtime down to match ancient gencode fights every other protobuf user in your environment.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| flatbuffers | PyPI | You need zero-copy reads of large messages (games, telemetry) and can accept a more awkward API in exchange for not parsing at all. |
| msgpack | PyPI | You want binary compactness without schemas or codegen; it is JSON-shaped data in fewer bytes, nothing more. |
| avro | PyPI | You are in the Kafka/Hadoop world where schemas travel with the data and a schema registry handles evolution. |
| grpcio-tools | PyPI | You are staying on protobuf and just need a pip-installable protoc; it is the companion compiler package, not a replacement. |