Protocol Buffers Schema Design
Protobuf schemas are living contracts.
Search across all documentation pages
Protobuf schemas are living contracts.
Field numbers, wire types, and package boundaries decide whether you can deploy a new service without taking down half the fleet.
Protobuf messages are versioned data structures defined in .proto files.
Each field has a permanent numeric tag.
Clients and servers compiled months apart must still decode each other's payloads.
Schema design is API design: names matter for readability, but numbers matter for compatibility.
This page covers message layout, oneof, maps, enums, imports, and the rules Google documents for safe evolution.
Quick-reference recipe card - copy-paste ready.
syntax = "proto3";
package inventory.v1;
option go_package = "example.com/inventory/api/inventory/v1;inventoryv1";
import "google/protobuf/timestamp.proto";
message Item {
string id = 1;
string sku = 2;
int32 quantity = 3;
google.protobuf.Timestamp updated_at = 4;
oneof status {
bool in_stock = 5;
string backorder_note = 6;
}
map<string, string> attributes = 7;
}When to reach for this:
oneof instead of parallel bool flags.map fields.Timestamp, Duration, Empty) instead of reinventing them.syntax = "proto3";
package orders.v1;
option go_package = "example.com/orders/api/orders/v1;ordersv1";
import "google/protobuf/field_mask.proto";
enum OrderState {
ORDER_STATE_UNSPECIFIED = 0;
ORDER_STATE_PENDING = 1;
ORDER_STATE_SHIPPED = 2;
}
message Order {
string id = 1;
OrderState state = 2;
repeated LineItem items = 3;
}
message LineItem {
string product_id = 1;
int32 qty = 2;
}
message UpdateOrderRequest {
Order order = 1;
google.protobuf.FieldMask update_mask = 2;
}package main
import (
"fmt"
ordersv1 "example.com/orders/api/orders/v1"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
func main() {
req := &ordersv1.UpdateOrderRequest{
Order: &ordersv1.Order{
Id: "ord-1",
State: ordersv1.OrderState_ORDER_STATE_SHIPPED,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"state"}},
}
fmt.Println(req.GetUpdateMask().GetPaths())
}What this demonstrates:
UNSPECIFIED - never use zero as a meaningful business state without documenting it.repeated maps to Go slices; absent fields decode as nil/empty, not errors.FieldMask expresses partial updates for PATCH-style RPCs.go_package keeps generated Go code in a stable import path.Protobuf encodes messages as tag-length-value triples on the wire.
The field number selects the slot; the wire type tells decoders how to parse bytes.
Decoders ignore unknown fields, which is the foundation of backward compatibility.
Renaming a field in .proto does not change the wire format - only the number and type do.
| Rule | Safe change | Breaking change |
|---|---|---|
| Add optional field | Yes, with new number | Reusing an old number |
| Change field type | No | int32 to string on same tag |
| Rename field | Yes (same number) | - |
| Delete field | Reserve number + name | Reusing deleted tag |
optional keyword | Explicit presence in proto3 | - |
Use reserved 3, 5; and reserved "legacy_field"; after removals.
oneof enforces mutual exclusion - only one member is set.
In Go, generated wrappers expose GetInStock() style accessors and an interface type for the active case.
map<K,V> fields become Go maps; keys cannot be messages or floats.
Keep map values simple strings or numbers when possible for cross-language consistency.
Nest messages for clarity, but flatten when the same structure repeats across RPCs - shared types belong in common.proto.
// Prefer constructors for required invariants
order := &ordersv1.Order{
Id: id,
State: ordersv1.OrderState_ORDER_STATE_PENDING,
}
// Clone before mutating shared protobufs passed between goroutines
clone := proto.Clone(order).(*ordersv1.Order)proto.Marshal / proto.Unmarshal work without gRPC for event payloads.buf lint or prototool in CI to enforce style and breaking-change detection.int32 to enum - Wire types may match but semantics diverge. Fix: add a new field number for the enum.PENDING collide. Fix: keep UNSPECIFIED = 0 and start business values at 1.oneof with many arms - Generated Go code balloons. Fix: split variants into nested messages or separate RPCs..proto. Fix: generate OpenAPI or use buf breaking checks against main.| Alternative | Use When | Don't Use When |
|---|---|---|
| Plain protobuf messages | gRPC services, events | You need untyped schemaless blobs |
google.protobuf.Struct | Truly dynamic attributes | Performance-critical hot paths |
| FlatBuffers / Cap'n Proto | Zero-copy read-heavy workloads | Team already standardized on gRPC+proto |
| JSON Schema + OpenAPI | Public HTTP-only APIs | You want binary efficiency and codegen |
| Avro with schema registry | Kafka-centric pipelines | Simple point-to-point gRPC |
Wire encoding uses numbers only.
Renaming is safe; renumbering is not.
Proto3 is the default for new gRPC work.
Proto2 persists in legacy Google APIs; avoid mixing in one service.
It tracks explicit presence separately from default zero values.
Useful for PATCH semantics and distinguishing "unset" from "empty string".
Extract common messages into common/v1/types.proto and import them.
Keep package names versioned (v1, v2).
Use google.protobuf.Timestamp in protos.
Convert at boundaries with timestamppb.New(t) in Go.
Keep RPC payloads under a few MB; use streaming or object storage for bulk binary.
Huge messages hurt latency and proxy buffers.
buf breaking, prototool break check, or pinned CI against a baseline git ref.
Gateways map enums to strings in JSON.
Proto enum names should be stable since they surface externally.
Use // comments above fields; some generators emit godoc-style comments.
For public APIs, mirror docs in your schema repo README.
Proto3 omits default values in binary encoding.
Do not depend on receiving zero values for unset primitive fields unless using optional.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 18, 2026