Protocol Buffer best practices

Protocol Buffers provide language-neutral schemas for structured data. Generated code lets clients and servers exchange compatible messages across languages, including Python and Rust.

This guide provides practical guidance for API design and change management. Follow the Buf Style Guide and enforce it with buf lint, as specified in our coding standards. In addition, also follow Google's Protocol Buffers Best Practices. The practices in this document complement those standards.

Reading the examples

  • 🟢 DO: Recommended patterns.
  • 🔴 DO NOT: Patterns to avoid.

Each recommendation includes examples to follow or avoid. These examples are independent schema excerpts unless they include a file header. Imports and referenced types must be available in the containing file. Comments identify the important differences.

Schema-level best practices

Standardize on a supported edition

Use Protocol Buffers 2026 edition for new Protocol Buffers.

🟢 DO: Use the agreed, supported edition consistently.

edition = "2026";
package qctrl.examples.v1;

message Item {
  // Editions use explicit presence for singular fields by default.
  string name = 1;
}

Use ProtoValidate

Enforce request-specific requirements at runtime with Protovalidate. Validation rules and google.api.field_behavior annotations serve different purposes: the former enforce constraints, while the latter document API behavior. They can be used together. The Q-CTRL API integrates runtime validation.

🟢 DO: Track presence and validate requirements at runtime.

edition = "2026";

package qctrl.examples.v1;

import "buf/validate/validate.proto";
import "google/api/field_behavior.proto";

message LookupItemRequest {
  string id = 1 [
    (buf.validate.field).required = true,
    (buf.validate.field).string.min_len = 1,
    (google.api.field_behavior) = REQUIRED
  ];
}

The required validation rule checks presence; the additional min_len rule rejects a present but empty ID.

Use enums for a known set of values

Use a Protocol Buffer enum when a field represents a defined set of choices. Enums communicate the allowed values, avoid spelling variations, and provide generated symbolic names rather than unvalidated strings.

Include an UNSPECIFIED (or UNKNOWN) value at number 0. Add new values without renumbering existing ones, and ensure consumers handle unrecognized values safely. Generated APIs can convert between symbolic names and numeric values; protobuf wire-format stores the numeric value.

🟢 DO: Define choices explicitly and provide an unspecified value.

enum ExecutionMode {
  EXECUTION_MODE_UNSPECIFIED = 0;
  EXECUTION_MODE_SIMULATION = 1;
  EXECUTION_MODE_HARDWARE = 2;
}

message CreateJobRequest {
  ExecutionMode execution_mode = 1;
}

🔴 DO NOT: Represent a closed set as free-form text.

message CreateJobRequest {
  // Allows inconsistent values such as "hardware", "Hardware", or "hw".
  string execution_mode = 1;
}

Choose between string and bytes deliberately

Use string for UTF-8 text, such as names and descriptions. Use bytes for arbitrary binary data, such as encrypted payloads and image content.

Do not rely on one language's representation of either type. Generated implementations can use different containers and memory layouts; for example, some C++ configurations can represent bytes using a cord (non-contiguous blocks of memory) rather than a contiguous array.

🟢 DO: Separate human-readable text from binary payloads.

message Image {
  string name = 1;
  bytes image_data = 2;
  bytes encrypted_message = 3;
}

🔴 DO NOT: Treat arbitrary binary data as UTF-8 text.

message Image {
  bytes name = 1;
  // Image bytes are not necessarily valid UTF-8.
  string image_data = 2;
}

Changing an existing field between string and bytes is not a safe general-purpose migration, even though both use length-delimited binary encoding.

Use well-known types for time values

Prefer an appropriate well-known type over an ambiguous scalar. Use google.protobuf.Duration for elapsed time and google.protobuf.Timestamp for an instant in time.

These types have defined seconds-and-nanoseconds representations and allow for unit-independent manipulation of these values. They let callers convert their preferred units into a standard representation and let services validate values consistently. Use a shared custom message when no suitable standard type exists.

🟢 DO: Represent intervals and instants with distinct types.

import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";

message Job {
  google.protobuf.Duration timeout = 1;
  google.protobuf.Timestamp created_at = 2;
}

🔴 DO NOT: Leave units or time formats implicit.

message Job {
  // Seconds, milliseconds, or minutes?
  double timeout = 1;
  // Which format and time zone?
  string created_at = 2;
}

Prefer structured types over encoded strings

Do not use a string as a substitute for a structured object. Prefer a standard message or a purpose-built type when callers would otherwise need to parse text to recover meaningful fields. For example, use google.rpc.Status for structured RPC status information instead of a string representing the status of an operation that requires the user to parse the string to disambiguate the status.

Strings remain appropriate for actual text, opaque identifiers, URLs that are themselves the desired value, and opaque pagination tokens. Avoid introducing custom messages that add no useful semantics.

🟢 DO: Model the information consumers actually need.

import "google/rpc/status.proto";

message PhotoReference {
  int64 user_id = 1;
  int64 photo_id = 2;
  int64 container_id = 3;
}

message LookupPhotoResponse {
  PhotoReference photo = 1;
  google.rpc.Status status = 2;
}

🔴 DO NOT: Require consumers to parse compound strings.

message LookupPhotoResponse {
  // Consumers must parse both the code and the message.
  string status = 2;
}

Prefer oneof over Any for a known set of types

Avoid google.protobuf.Any when the accepted message types are known in advance. Any shifts type selection and checking to runtime, making the contract harder to inspect, validate, and analyze.

Use oneof to enumerate supported alternatives. It allows at most one alternative to be set; if one is mandatory, enforce that with runtime validation. Reserve Any for genuinely extensible contracts that have a documented type policy.

🟢 DO: Make supported alternatives visible in the schema.

message SimulationConfig {
  int32 shot_count = 1;
}

message HardwareConfig {
  string device_id = 1;
}

message CreateJobRequest {
  oneof config {
    SimulationConfig simulation = 1;
    HardwareConfig hardware = 2;
  }
}

🔴 DO NOT: Hide a closed set of alternatives behind Any.

import "google/protobuf/any.proto";

message CreateJobRequest {
  // The schema does not say which configurations are accepted.
  google.protobuf.Any config = 1;
}

Track the next available field number

Add a // Next available ID: N comment to messages that accumulate fields, and update it whenever you allocate a field number. This makes additions easier to review and can expose conflicting allocations during merges.

The comment is a maintenance aid, not an allocator or a guarantee of correctness. Check active fields, reservations, and the legal field-number range before using it. Never reuse a deleted field's number.

Rule of thumb is to start adding the next available tag once the message is sufficiently large (e.g. 10 fields or more).

🟢 DO: Keep the allocation hint current.

// Next available ID: 5
message Item {
  string id = 1;
  string name = 3;
  string description = 4;
}

🔴 DO NOT: Leave a stale hint after adding a field.

// This ID should have been 5 (the next AVAILABLE ID, NOT the last ID used)
// Next available ID: 4
message Item {
  string id = 1;
  string name = 3;
  string description = 4;

  reserved 2;
  reserved "legacy_name";
}

Put reservations after active fields

Keep active fields first and reserved declarations at the end of the message. Readers see the current contract immediately, while contributors adding fields see the reservations near the insertion point.

Reserve both the number and name of each deleted field. Number reservations protect binary compatibility; name reservations help prevent accidental reuse.

🟢 DO: Keep the active contract prominent and retain reservations.

message Item {
  string id = 1;
  string name = 3;

  reserved 2;
  reserved "legacy_name";
}

🔴 DO NOT: Place reserve fields before the active contract.

message Item {
  reserved 2;
  reserved "legacy_name";

  string id = 1;
  string name = 3;
}

Reservation placement is a readability convention; it does not change wire behavior.

API-level best practices

Group cohesive fields into reusable messages

Apply Don't Repeat Yourself (DRY) when fields form a coherent concept with shared semantics. A dedicated message gives that concept a name and avoids repeating the same group across requests.

Extract meaningful groups, not merely fields that happen to have the same primitive types.

🟢 DO: Create commonly used types.

enum SortDirection {
  SORT_DIRECTION_UNSPECIFIED = 0;
  SORT_DIRECTION_ASCENDING = 1;
  SORT_DIRECTION_DESCENDING = 2;
}

message SortOptions {
  string field = 1;
  SortDirection direction = 2;
}

message ListItemsRequest {
  SortOptions sort = 1;
}

message ListJobsRequest {
  SortOptions sort = 1;
}

Grouping existing fields into a new message changes the schema and wire representation. Apply this design to new APIs or use a compatibility-preserving migration; do not rewrite existing fields in place.

Balance reuse with readability and independence

Do not make APIs so DRY that unrelated operations become coupled. Share a message only when its meaning, validation rules, and expected evolution are genuinely shared.

This is the DAMP counterpoint: some repetition is preferable to an abstraction that obscures intent or spreads a local change across unrelated APIs.

🟢 DO: Keep independently evolving concepts separate.

message ItemSortOptions {
  bool name_descending = 1;
}

message JobSortOptions {
  bool created_at_descending = 1;
}

message ListItemsRequest {
  ItemSortOptions sort = 1;
}

message ListJobsRequest {
  JobSortOptions sort = 1;
}

🔴 DO NOT: Share a catch-all type just to eliminate repetition.

message SharedSortOptions {
  bool name_descending = 1;
  // Only jobs can use this field, but every consumer now sees it.
  bool created_at_descending = 2;
}

message ListItemsRequest {
  SharedSortOptions sort = 1;
}

message ListJobsRequest {
  SharedSortOptions sort = 1;
}

Make operation names convey cardinality

Avoid names distinguished only by a subtle singular/plural difference. Choose verbs that communicate whether an operation returns one resource or a collection.

For a new API, names such as LookupItem and ListItems are easier to distinguish than GetItem and GetItems. Follow the naming conventions of the API framework where they prescribe a particular verb; do not rename established methods solely for this preference.

🟢 DO: Use distinct verbs and matching request/response names.

service ItemService {
  rpc LookupItem(LookupItemRequest) returns (LookupItemResponse);
  rpc ListItems(ListItemsRequest) returns (ListItemsResponse);
}

message LookupItemRequest {
  string id = 1;
}

message LookupItemResponse {
  Item item = 1;
}

message ListItemsRequest {}

message ListItemsResponse {
  repeated Item items = 1;
}

🔴 DO NOT: Make cardinality depend on spotting one extra letter.

service ItemService {
  rpc GetItem(GetItemRequest) returns (GetItemResponse);
  rpc GetItems(GetItemsRequest) returns (GetItemsResponse);
}

Keep request/response pairs and their dependencies together

Arrange definitions so readers can understand an operation without jumping around the file. Put shared types first, followed by request-specific types and the request, then response-specific types and the response.

It is also acceptable to put both sets of supporting types before the adjacent request/response pair. Avoid scattering related definitions among unrelated operations.

🟢 DO: Keep supporting types close to the operation that uses them.

message Item {
  string id = 1;
}

message ItemFilter {
  string name_prefix = 1;
}

message ListItemsRequest {
  ItemFilter filter = 1;
}

message ItemSummary {
  Item item = 1;
}

message ListItemsResponse {
  repeated ItemSummary items = 1;
}

🔴 DO NOT: Interrupt a request/response pair with unrelated types.

message ListItemsRequest {
  ItemFilter filter = 1;
}

message DeleteJobRequest {
  string id = 1;
}

message DeleteJobResponse {}

message ListItemsResponse {
  repeated ItemSummary items = 1;
}

Further reading