Skip to main content

When to Use Protobuf

Protobuf serialization is useful for:
  • Cross-language communication - Rust ↔ Python ↔ C++ compatibility
  • Schema evolution - Adding/removing fields over time without breaking compatibility
  • Complex nested message types - Deeply nested structures that are difficult with raw bytes
  • Guaranteed compatibility - Versioned message formats with backward/forward compatibility
For same-language communication with simple, fixed-size types, Copy types with automatic serialization are faster and simpler. Use protobuf when you need the benefits listed above.

ProtoSerializable Trait

The ProtoSerializable trait provides protobuf serialization for types that implement ProstMessage:
pub trait ProtoSerializable: ProstMessage + Default + Send + Sync + 'static {
    fn to_proto_bytes(&self) -> Result<Vec<u8>, Box<dyn Error>> {
        let mut buf = Vec::new();
        self.encode(&mut buf)?;
        Ok(buf)
    }

    fn from_proto_bytes(bytes: &[u8]) -> Result<Self, Box<dyn Error>> {
        Ok(Self::decode(bytes)?)
    }
}

Automatic Implementation

The trait is automatically implemented for all ProstMessage types:
impl<T> ProtoSerializable for T 
where 
    T: ProstMessage + Default + Send + Sync + 'static 
{}

Defining Protobuf Messages

Step 1: Define the .proto File

Create a .proto file defining your message:
syntax = "proto3";

package sensor;

message SensorData {
    uint64 timestamp = 1;
    float temperature = 2;
    float pressure = 3;
}

Step 2: Generate Rust Code

Use prost-build or prost to generate Rust code from the .proto file. Add to your Cargo.toml:
[dependencies]
prost = "0.12"
prost-derive = "0.12"

[build-dependencies]
prost-build = "0.12"
Create a build.rs file:
fn main() {
    prost_build::compile_protos(&["src/sensor_data.proto"], &["src/"]).unwrap();
}

Step 3: Use the Generated Type

use prost::Message as ProstMessage;

// Generated from .proto file
#[derive(Clone, PartialEq, ProstMessage)]
pub struct SensorData {
    #[prost(uint64, tag = "1")]
    pub timestamp: u64,
    #[prost(float, tag = "2")]
    pub temperature: f32,
    #[prost(float, tag = "3")]
    pub pressure: f32,
}

// Automatically implements ProtoSerializable
let data = SensorData {
    timestamp: 1234567890,
    temperature: 23.5,
    pressure: 1013.25,
};

// Serialize using protobuf
let bytes = data.to_proto_bytes()?;

// Deserialize
let restored = SensorData::from_proto_bytes(&bytes)?;

Cross-Language Compatibility

Python

# Generated from .proto file
import sensor_data_pb2

# Create message
msg = sensor_data_pb2.SensorData()
msg.timestamp = 1234567890
msg.temperature = 23.5
msg.pressure = 1013.25

# Serialize
data = msg.SerializeToString()

# Deserialize
received = sensor_data_pb2.SensorData()
received.ParseFromString(data)

C++

// Generated from .proto file
#include "sensor_data.pb.h"

// Create message
sensor_data::SensorData msg;
msg.set_timestamp(1234567890);
msg.set_temperature(23.5);
msg.set_pressure(1013.25);

// Serialize
std::string data;
msg.SerializeToString(&data);

// Deserialize
sensor_data::SensorData received;
received.ParseFromString(data);

Schema Evolution

Protobuf supports schema evolution - you can add or remove fields without breaking compatibility:

Adding Fields

// Version 1
message SensorData {
    uint64 timestamp = 1;
    float temperature = 2;
}

// Version 2 - Add new field
message SensorData {
    uint64 timestamp = 1;
    float temperature = 2;
    float pressure = 3;  // New field
}
Old code reading new messages will ignore the new field. New code reading old messages will get a default value for the missing field.

Removing Fields

// Version 1
message SensorData {
    uint64 timestamp = 1;
    float temperature = 2;
    float pressure = 3;
}

// Version 2 - Remove field (reserve tag number)
message SensorData {
    uint64 timestamp = 1;
    float temperature = 2;
    // reserved 3;  // Don't reuse this tag number
}
When removing fields, always reserve the tag number to prevent accidental reuse. This ensures backward compatibility.

Comparison: Copy Types vs Protobuf

FeatureCopy TypesProtobuf
PerformanceFaster (zero-copy local)Slower (encoding/decoding)
SizeFixed, predictableVariable (compressed)
Cross-languageNo (Rust only)Yes (Rust/Python/C++)
Schema evolutionNo (must match exactly)Yes (add/remove fields)
ComplexitySimple (automatic)Moderate (code generation)
Use caseSame-language, simple typesCross-language, evolving schemas

Best Practices

1. Use Copy Types for Simple Cases

// ✅ Good: Simple, fixed-size, same-language
#[derive(Copy, Clone)]
#[repr(C)]
struct SimpleData {
    value: f32,
    timestamp: u64,
}

2. Use Protobuf for Cross-Language

// ✅ Good: Need Python/C++ compatibility
#[derive(Clone, PartialEq, ProstMessage)]
pub struct CrossLangData {
    #[prost(uint64, tag = "1")]
    pub timestamp: u64,
    #[prost(float, tag = "2")]
    pub temperature: f32,
}

3. Reserve Tag Numbers

// ✅ Good: Reserve removed fields
message MyMessage {
    uint64 field1 = 1;
    // reserved 2;  // Removed field
    float field3 = 3;
}

4. Use Semantic Versioning

Version your .proto files and document breaking changes:
// sensor_data_v1.proto
// sensor_data_v2.proto

Next Steps