The most surprising thing about Protobuf Rust code generation is that the generated structs are not intended for direct use as your primary data structures.

Let’s see it in action. Imagine you have a simple .proto file:

// person.proto
syntax = "proto3";

message Person {
  string name = 1;
  int32 id = 2;
  repeated string emails = 3;
}

After running protoc with the Rust plugin, you’d get something like this (simplified for clarity):

// person.rs (generated)
#[derive(PartialEq, Clone, Debug)]
pub struct Person {
    pub name: ::std::option::Option<::std::string::String>,
    pub id: ::std::option::Option<i32>,
    pub emails: ::std::vec::Vec<::std::string::String>,
}

impl Person {
    pub fn new() -> Person {
        Default::default()
    }

    pub fn default_instance() -> &'static Person {
        static instance: Person = Person {
            name: None,
            id: None,
            emails: ::std::vec::Vec::new(),
        };
        &instance
    }

    // ... more methods for serialization, deserialization, etc.
}

This Person struct is what Protobuf uses internally for serialization and deserialization. Notice the Option<String> for name and id. This is because Protobuf fields are optional by default in the wire format. The repeated field emails becomes a Vec<String>, which is standard.

The problem this solves is efficient, language-agnostic data serialization. Protobuf defines a compact binary format that can be decoded by any language with a Protobuf implementation. The generated Rust code is the bridge between your Rust application and this binary format.

Internally, the generated structs are designed to map directly to the Protobuf wire format. Each field in the .proto file corresponds to a field in the Rust struct. The types are mapped: string to Option<String>, int32 to Option<i32>, repeated fields to Vec, and so on. The Option is crucial because Protobuf doesn’t have a strict "required" concept in its core wire format; fields are implicitly optional. The generated code adds methods for encoding (encode_to_bytes) and decoding (parse_from_bytes) these structures.

The core mental model is: .proto file -> generated Rust structs -> your Rust application. You define your data contracts in .proto, and the protoc compiler generates the Rust types that can serialize/deserialize according to those contracts.

Most people interact with these generated structs by calling parse_from_bytes and then trying to use the resulting Option fields. This is where the friction comes in. You end up writing a lot of .unwrap() or if let Some(...) statements. For example, to get the name:

let person_data = Person::parse_from_bytes(some_bytes)?;
if let Some(name) = person_data.name {
    println!("Name: {}", name);
}

The better approach is to use these generated structs as adapters or gateways for your actual application data. You’d typically define your own idiomatic Rust structs and then implement methods to convert between your structs and the generated Protobuf structs.

// your_app_models.rs

#[derive(Debug, Clone)]
pub struct User {
    pub username: String,
    pub user_id: i32,
    pub contact_emails: Vec<String>,
}

impl From<Person> for User {
    fn from(person: Person) -> Self {
        User {
            username: person.name.unwrap_or_default(), // Handle missing name gracefully
            user_id: person.id.unwrap_or(0),        // Handle missing ID gracefully
            contact_emails: person.emails,           // repeated field maps directly
        }
    }
}

impl From<User> for Person {
    fn from(user: User) -> Self {
        Person {
            name: Some(user.username),
            id: Some(user.user_id),
            emails: user.contact_emails,
        }
    }
}

// In your main logic:
fn process_protobuf_data(bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
    let proto_person = Person::parse_from_bytes(bytes)?;
    let user: User = proto_person.into(); // Convert to your idiomatic struct
    println!("Processing user: {:?}", user);
    // ... do something with the User struct
    Ok(())
}

This way, your application logic operates on clean, idiomatic Rust types, and the Protobuf serialization/deserialization is confined to the edges of your system. The generated Person struct contains Option fields because Protobuf itself is schema-less and relies on field presence for identification and optionality. The wire format doesn’t enforce types as strictly as a compiled language often does, and the generated code reflects this by using Option to represent the potential absence of a field.

The next concept you’ll likely grapple with is managing Protobuf messages across different versions of your .proto files and how to handle breaking changes.

Want structured learning?

Take the full Protobuf course →