Protocol Buffers Schema Design
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.
Summary
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.
Recipe
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:
- Defining gRPC request/response types shared across Go, Java, and other generators.
- Modeling optional variants with
oneofinstead of parallel bool flags. - Attaching open-ended key-value metadata with
mapfields. - Importing well-known types (
Timestamp,Duration,Empty) instead of reinventing them.
Working Example
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:
- Enums include zero value
UNSPECIFIED- never use zero as a meaningful business state without documenting it. repeatedmaps to Go slices; absent fields decode as nil/empty, not errors.FieldMaskexpresses partial updates for PATCH-style RPCs.go_packagekeeps generated Go code in a stable import path.
Deep Dive
How It Works
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.
Field Rules at a Glance
| 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, maps, and nested messages
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.
Go Notes
// 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)- Generated structs use pointer fields for messages; check nil before nested access or use getters.
proto.Marshal/proto.Unmarshalwork without gRPC for event payloads.- Run
buf lintorprototoolin CI to enforce style and breaking-change detection.
Gotchas
- Reusing field numbers - Old clients send bytes into the wrong slot. Fix: reserve numbers and names permanently.
- Changing
int32toenum- Wire types may match but semantics diverge. Fix: add a new field number for the enum. - Zero enum value as real state - Unset and
PENDINGcollide. Fix: keepUNSPECIFIED = 0and start business values at 1. - Huge
oneofwith many arms - Generated Go code balloons. Fix: split variants into nested messages or separate RPCs. - JSON over protobuf for config - Hand-edited JSON drifts from
.proto. Fix: generate OpenAPI or usebufbreaking checks against main. - Maps inside repeated messages - Serialization order is undefined; do not rely on map iteration order in tests.
Alternatives
| 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 |
FAQs
Why are field numbers more important than names?
Wire encoding uses numbers only.
Renaming is safe; renumbering is not.
Should I use proto2 or proto3?
Proto3 is the default for new gRPC work.
Proto2 persists in legacy Google APIs; avoid mixing in one service.
What does optional mean in proto3?
It tracks explicit presence separately from default zero values.
Useful for PATCH semantics and distinguishing "unset" from "empty string".
How do I share types between services?
Extract common messages into common/v1/types.proto and import them.
Keep package names versioned (v1, v2).
Can I use time.Time directly?
Use google.protobuf.Timestamp in protos.
Convert at boundaries with timestamppb.New(t) in Go.
How big should a message be?
Keep RPC payloads under a few MB; use streaming or object storage for bulk binary.
Huge messages hurt latency and proxy buffers.
What tools catch breaking changes?
buf breaking, prototool break check, or pinned CI against a baseline git ref.
Should enums be stringly in JSON gateway?
Gateways map enums to strings in JSON.
Proto enum names should be stable since they surface externally.
How do I document fields?
Use // comments above fields; some generators emit godoc-style comments.
For public APIs, mirror docs in your schema repo README.
Are default values sent on the wire?
Proto3 omits default values in binary encoding.
Do not depend on receiving zero values for unset primitive fields unless using optional.
Related
- gRPC in Go: Contracts, Streaming, and Performance - why contracts matter
- gRPC Basics - codegen workflow
- gRPC Error Codes & Status Mapping - error detail messages
- gRPC Best Practices - proto versioning in production
- Struct Tags for JSON, DB & Validation - Go-side serialization patterns
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).