Protobuf custom options let you attach arbitrary metadata to your schema definitions, and the most surprising thing is how they fundamentally change the meaning of your .proto files, turning them from just static definitions into dynamic, executable configurations.
Let’s see this in action. Imagine you have a simple User message and you want to mark certain fields as "sensitive" for auditing purposes.
syntax = "proto3";
package myapp.v1;
import "google/protobuf/descriptor.proto";
extend google.protobuf.FieldOptions {
bool is_sensitive = 50000;
}
message User {
string id = 1;
string username = 2;
string email = 3 [(is_sensitive) = true]; // Custom option applied here
string address = 4;
}
Here, we’ve extended google.protobuf.FieldOptions with a new option called is_sensitive. This option is a simple boolean. We then applied it to the email field.
When you compile this .proto file using protoc, the generated code doesn’t magically know what is_sensitive means. The magic happens when you use this information. Your application code, or a library you’re using, needs to introspect the compiled protobuf descriptors to read these custom options.
Consider this Go code that demonstrates how to read the custom option:
package main
import (
"fmt"
"log"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/descriptorpb"
pb "your_module/myapp/v1" // Assuming your generated Go code is here
)
func main() {
// This is a dummy User message. In a real app, you'd get this from a file or network.
user := &pb.User{
Id: "user-123",
Username: "alice",
Email: "alice@example.com",
Address: "123 Main St",
}
// Get the reflection descriptor for the User message
messageType := proto.MessageV2(user).ProtoReflect().Descriptor()
// Iterate over all fields in the message
messageType.Fields().ForRange(func(field protoreflect.FieldDescriptor) bool {
// Check if the field has our custom option
// The custom option's name is constructed from its package and name.
// Here, it's "myapp.v1.is_sensitive"
opts := field.Options().(*descriptorpb.FieldOptions)
if opts != nil {
// We need to find our specific option. This is done by looking for
// the extension number. Our `is_sensitive` option has extension number 50000.
// We can access it via the generated `pb` package if we defined it properly,
// or by looking up the extension descriptor.
// For simplicity, let's assume `pb.E_IsSensitive` is available.
// In a real scenario, you might need to fetch the extension descriptor
// from the FileDescriptor.
// A more robust way is to get the extension descriptor by number.
// We need the FileDescriptor first.
fileDescriptor := messageType.ParentFile()
extensions := fileDescriptor.Extensions()
var sensitiveOption *protoreflect.ExtensionDescriptor
for i := 0; i < extensions.Len(); i++ {
ext := extensions.Get(i)
if ext.Number() == 50000 { // The extension number we defined
sensitiveOption = ext
break
}
}
if sensitiveOption != nil {
// Now get the actual value of the extension
sensitiveValue := opts.ProtoReflect().Get(sensitiveOption.Descriptor())
if sensitiveValue.IsValid() {
if isSensitive, ok := sensitiveValue.Interface().(bool); ok && isSensitive {
fmt.Printf("Field '%s' is sensitive.\n", field.Name())
}
}
}
}
return true // Continue iterating
})
}
In this Go code, we’re not just defining a User struct; we’re dynamically inspecting its structure at runtime. The protoreflect package allows us to ask questions about the message’s fields. We specifically look for our is_sensitive custom option. The key is that the .proto file, augmented with custom options, becomes a richer source of truth. It’s not just about data types, but about data semantics and behavior.
The problem custom options solve is adding domain-specific information to your protobuf definitions without altering the core protobuf specification or requiring complex, language-specific annotations that might not be portable. You can mark fields as deprecated (which has a standard option), required (in proto2, but you can emulate it), sensitive, PII, indexed, or even embed configuration for code generation tools. For example, a gRPC gateway might use a custom option to specify a custom HTTP path for a RPC method.
The internal mechanism involves protobuf’s reflection capabilities. When you compile a .proto file, protoc generates not only the message and service code but also descriptor information. This descriptor information is itself a protobuf message (FileDescriptorProto) that describes your schema. Custom options are simply fields within these descriptor messages. When you define an extend in your .proto file, you’re telling protobuf to reserve a specific field number within the FieldOptions (or MessageOptions, ServiceOptions, etc.) message. Your application then uses reflection to access these reserved fields by their extension numbers.
A common gotcha is how custom options are namespaced and accessed. When you extend google.protobuf.FieldOptions, the custom option isn’t directly accessible as field.is_sensitive. Instead, you need to access it as an extension on the FieldOptions object. The exact way to do this depends on the language’s protobuf reflection API, but it generally involves looking up the extension by its assigned number or a generated accessor. For instance, in Go, you might use opts.ProtoReflect().Get(yourExtensionDescriptor) after obtaining the yourExtensionDescriptor from the FileDescriptor.
The next step is often applying custom options to messages or services, not just fields, to control entire structures or API endpoints.