The most surprising thing about protobuf optional fields is that they don’t actually "exist" in the same way that required fields do. Instead, their presence is a matter of whether a specific value has been explicitly set, and this distinction matters immensely when you’re trying to reliably detect if a field was intended to be there.

Let’s see this in action. Imagine we have a simple protobuf definition:

syntax = "proto3";

message UserProfile {
  string name = 1;
  optional string bio = 2; // This is the key field
  int32 age = 3;
}

In Go, when you deserialize a UserProfile message, you might try to check if bio is set like this:

package main

import (
	"fmt"
	"log"

	"google.golang.org/protobuf/proto"
	pb "your_module_path/userprofile" // Replace with your actual module path
)

func main() {
	// Scenario 1: bio is explicitly set
	profile1 := &pb.UserProfile{
		Name: "Alice",
		Bio:  "Software Engineer",
		Age:  30,
	}
	data1, err := proto.Marshal(profile1)
	if err != nil {
		log.Fatalf("Failed to marshal profile1: %v", err)
	}

	var decodedProfile1 pb.UserProfile
	err = proto.Unmarshal(data1, &decodedProfile1)
	if err != nil {
		log.Fatalf("Failed to unmarshal profile1: %v", err)
	}

	// How to detect presence in Go
	if decodedProfile1.ProtoReflect().Has(pb.File_userprofile_proto.Messages().ByName("UserProfile").Fields().ByName("bio")) {
		fmt.Printf("Profile 1: Bio is present and set to: %s\n", decodedProfile1.GetBio())
	} else {
		fmt.Println("Profile 1: Bio is NOT present.")
	}

	// Scenario 2: bio is NOT set (default value for string is "")
	profile2 := &pb.UserProfile{
		Name: "Bob",
		Age:  25,
	}
	data2, err := proto.Marshal(profile2)
	if err != nil {
		log.Fatalf("Failed to marshal profile2: %v", err)
	}

	var decodedProfile2 pb.UserProfile
	err = proto.Unmarshal(data2, &decodedProfile2)
	if err != nil {
		log.Fatalf("Failed to unmarshal profile2: %v", err)
	}

	if decodedProfile2.ProtoReflect().Has(pb.File_userprofile_proto.Messages().ByName("UserProfile").Fields().ByName("bio")) {
		fmt.Printf("Profile 2: Bio is present and set to: %s\n", decodedProfile2.GetBio())
	} else {
		fmt.Println("Profile 2: Bio is NOT present.")
	}

	// Scenario 3: bio is explicitly set to an empty string
	profile3 := &pb.UserProfile{
		Name: "Charlie",
		Bio:  "", // Explicitly empty string
		Age:  40,
	}
	data3, err := proto.Marshal(profile3)
	if err != nil {
		log.Fatalf("Failed to marshal profile3: %v", err)
	}

	var decodedProfile3 pb.UserProfile
	err = proto.Unmarshal(data3, &decodedProfile3)
	if err != nil {
		log.Fatalf("Failed to unmarshal profile3: %v", err)
	}

	if decodedProfile3.ProtoReflect().Has(pb.File_userprofile_proto.Messages().ByName("UserProfile").Fields().ByName("bio")) {
		fmt.Printf("Profile 3: Bio is present and set to: '%s'\n", decodedProfile3.GetBio())
	} else {
		fmt.Println("Profile 3: Bio is NOT present.")
	}
}

When you run this, you’ll see:

Profile 1: Bio is present and set to: Software Engineer
Profile 2: Bio is NOT present.
Profile 3: Bio is present and set to: ''

The core problem protobuf solves here is differentiating between a field that was never sent and a field that was sent with its default value. In proto3, fields that are not optional and are set to their default value (e.g., an empty string for string, 0 for int32, false for bool, nil for messages) are omitted during serialization to save space. This is efficient but creates an ambiguity: if you receive a message where a string field is empty, was it intentionally set to empty, or was it simply not sent at all?

The optional keyword (introduced in proto3, though its behavior is a bit different from proto2’s required/optional) allows you to explicitly mark a field as one that might be omitted even if it has its default value. When you access an optional field in generated code, you typically get a Get method (e.g., GetBio()) that returns the field’s value, and a presence check mechanism. In Go, this presence check is done via the ProtoReflect().Has() method, which inspects the underlying serialization data to see if the field’s tag was actually present in the serialized byte stream. This is crucial because directly checking decodedProfile.Bio == "" would be misleading for Scenario 2; Bob’s bio is not present, but the GetBio() method on a decoded Go struct would return an empty string, just like Charlie’s, if you didn’t do the explicit presence check.

The ProtoReflect() API is the key here. It provides a reflection interface to the protobuf message, allowing you to inspect its fields and their metadata at runtime. To check for the presence of a field like bio, you first get the MessageDescriptor for UserProfile, then find the FieldDescriptor for bio. This descriptor is then passed to Has(). This ensures that you’re not just checking the value of the field (which might be the default zero value), but whether the field’s data was actually encoded in the message.

The optional keyword in proto3 is a bit of a hybrid. It allows a field to be omitted if it has its default value, but it also signals to the generated code that there’s a distinction between "not set" and "set to default." This is unlike non-optional fields in proto3, where "set to default" and "not set" are indistinguishable and both result in omission. This distinction is what the ProtoReflect().Has() mechanism leverages.

So, when you’re working with proto3 and want to know if a field was explicitly provided, even if that provision was for its default value (like an empty string or zero), you must use the reflection API to check for presence. Relying solely on comparing the field’s value to its zero value will lead to incorrect logic when the field was simply omitted during serialization.

The next step is understanding how this applies to repeated fields and their specific "cleared" state when they are empty.

Want structured learning?

Take the full Protobuf course →