Protobuf reserved fields are the silent guardians of your API’s backward compatibility, allowing you to prune fields without breaking old clients.

Let’s see it in action. Imagine you have an OldMessage with a few fields:

syntax = "proto3";

message OldMessage {
  string name = 1;
  int32 age = 2;
  bool is_active = 3;
}

Now, you decide is_active is no longer needed. A naive approach might be to simply delete it:

syntax = "proto3";

message NewMessage {
  string name = 1;
  int32 age = 2;
  // bool is_active = 3; // Deleted!
}

This is a disaster waiting to happen. If a client using the OldMessage definition sends a message to a server expecting NewMessage, the server will ignore the data that was is_active because field number 3 is now unassigned. If the client receives a NewMessage from the server, and the server had a field 3 (which it doesn’t in this example, but imagine it did), the client would incorrectly parse it as age or name.

The correct way is to mark the field as reserved:

syntax = "proto3";

message SafeMessage {
  string name = 1;
  int32 age = 2;
  reserved 3; // Mark field number 3 as reserved
}

This tells the protobuf compiler: "Field number 3 is no longer valid for use. Do not assign any new fields to this number."

How it works internally:

When the protobuf compiler (protoc) generates code for a .proto file, it uses the field numbers to serialize and deserialize messages. These numbers are the keys in the underlying binary encoding. If you delete a field and reuse its number, an old client sending a message will have its is_active data (which used to be field 3) interpreted by a new client as a completely different field (e.g., if you added string address = 3; later). Conversely, a new client sending a message might have its new field 3 data misinterpreted by an old client.

By using reserved 3;, you’re essentially creating a tombstone for that field number. The compiler will emit an error if you try to assign a new field to number 3. This prevents accidental reuse and ensures that any serialized data using field number 3 from older versions of your schema will be treated as unknown fields by newer parsers, which is the desired behavior for backward compatibility.

You can also reserve ranges of field numbers:

syntax = "proto3";

message AnotherSafeMessage {
  string user_id = 1;
  string email = 2;
  reserved 3, 4, 5; // Reserve field numbers 3, 4, and 5
  reserved 10 to 20; // Reserve field numbers 10 through 20
}

This is particularly useful when you anticipate needing to remove multiple fields or want to reserve a block for future use, preventing accidental assignment within that range. The compiler will flag any attempt to define a field with a number that falls within a reserved range.

One of the most powerful, yet often overlooked, aspects of reserved is its ability to also reserve field names. If you have a field that you’ve deprecated and want to ensure no one accidentally reuses its name for a new field, you can do this:

syntax = "proto3";

message NameReservedMessage {
  string product_name = 1;
  double price = 2;
  reserved "old_sku", "deprecated_id"; // Reserve field names
}

This prevents you from defining a new field named old_sku or deprecated_id in the future, even if you assign it a different field number. This adds another layer of safety, particularly in large, long-lived projects where name collisions can be a subtle source of bugs.

The next step in managing evolving schemas is understanding how to handle optional fields and the implications of changing their types.

Want structured learning?

Take the full Protobuf course →