When JSON gets too heavy
You're shipping messages between services. The payload is a JSON blob. It's verbose, it's slow to parse, and your bandwidth bill is creeping up. You switch to Protocol Buffers. Now you have a .proto file describing the data structure, but Rust doesn't speak .proto natively. You need a bridge that turns that schema into safe, ergonomic Rust structs without sacrificing performance.
That bridge is prost. It's the de facto standard for Protocol Buffers in Rust. It doesn't just wrap a C++ library or parse schemas at runtime. It generates idiomatic Rust code during the build step. The result is types that feel native, serialization that's fast, and a developer experience that fits right into the Rust ecosystem.
Code generation, not runtime magic
prost is a code generator. It reads your .proto files and emits Rust source code. Think of it like a mold for casting metal. You define the shape in the .proto file. prost pours Rust code into that mold while Cargo builds your project. The output is a set of structs, enums, and methods that know exactly how to pack and unpack bytes according to your schema.
The .proto file disappears from the runtime. Your binary contains only Rust code. There's no reflection overhead, no dynamic dispatch, and no hidden allocations. The generated code uses standard Rust patterns. A message becomes a struct. A oneof becomes an Option<Enum>. A repeated field becomes a Vec. This alignment with Rust idioms is what makes prost a favorite. It respects the borrow checker, it plays nice with serde, and it generates code you can read and debug.
The minimal setup
You need three pieces to get started: the prost runtime crate, the prost-build code generator, and a build.rs script to wire them together.
Add the dependencies to your Cargo.toml. The runtime crate goes in [dependencies]. The build crate goes in [build-dependencies] because it only runs at compile time.
[dependencies]
prost = "0.13" // Runtime library for encoding and decoding messages
[build-dependencies]
prost-build = "0.13" // Code generator that runs during cargo build
Create a build.rs file in your project root. This script runs before your main code compiles. It tells prost-build where to find your .proto files and where to look for imports.
fn main() {
// Compile the proto file into Rust source code
// The first argument is a list of proto files to process
// The second argument is the include path for resolving imports
prost_build::compile_protos(&["src/message.proto"], &["src/"]).unwrap();
}
Define your message in src/message.proto. Keep the syntax simple for now.
syntax = "proto3";
message Message {
string content = 1;
}
Import the generated code in src/main.rs. Cargo sets the OUT_DIR environment variable to a temporary directory where build scripts write output. The include! macro injects the generated file directly into your module tree.
// Include the generated code from the build script output directory
// This is the standard pattern for build-script generated files
mod message {
include!(concat!(env!("OUT_DIR"), "/message.rs"));
}
use message::Message;
fn main() {
// Create a message using the generated struct
let msg = Message { content: "Hello".to_string() };
// Encode the message to a byte vector
let data = msg.encode_to_vec();
// Decode the bytes back into a Message struct
let decoded = Message::decode(&data[..]).unwrap();
println!("Decoded: {}", decoded.content);
}
Run cargo build. The build script executes, generates the Rust code, and the compiler picks it up. Run cargo run to see the result. If it prints "Decoded: Hello", the bridge is built.
Run cargo run and watch the bytes flow. If it prints the decoded content, your types are working.
What happens under the hood
When you invoke cargo build, Cargo detects the build.rs file and executes it. prost-build reads src/message.proto, parses the schema, and writes a Rust file to the output directory. That file contains a Message struct with a content field, plus encode and decode methods.
The include! macro in main.rs takes the path constructed from OUT_DIR and injects the generated code as if you had typed it by hand. The compiler sees a Message struct in the message module. It checks the fields, enforces types, and generates the binary.
At runtime, encode_to_vec walks the struct and writes bytes to a buffer. Protocol Buffers use a binary format based on field numbers and wire types. prost writes a tag for each field, followed by the encoded value. Strings are length-delimited. Integers use varint encoding, which compresses small numbers into fewer bytes. The result is a compact byte sequence.
decode reads the bytes, checks the tags, and reconstructs the struct. It validates the wire format. If the bytes are malformed or truncated, decode returns an error. The method signature is decode<R: io::Read>(input: R) -> Result<T, prost::DecodeError>. You must handle the error. Ignoring it triggers a compiler warning or an error if you try to use the result without unwrapping.
The .proto file is a blueprint. Your binary carries only Rust code. Trust the build script. If build.rs succeeds, the generated code is valid. If it fails, fix the schema before touching Rust.
Real-world patterns
Real messages are rarely a single string. They contain nested types, optional fields, and binary data. prost handles these with idiomatic Rust mappings.
Consider an event system with different payload types. A oneof in Protobuf lets a field hold one of several message types. prost maps this to an Option<Enum>. This is a deliberate design choice. It forces you to handle the case where the field is absent, and it gives you a Rust enum to pattern match on.
// src/events.proto
syntax = "proto3";
package events;
message Event {
int64 timestamp = 1;
oneof payload {
UserLogin login = 2;
DataUpdate update = 3;
}
}
message UserLogin {
string username = 1;
}
message DataUpdate {
string key = 1;
bytes value = 2;
}
Update build.rs to compile the new file.
fn main() {
// Compile the events proto file
prost_build::compile_protos(&["src/events.proto"], &["src/"]).unwrap();
}
Use the generated types in your code. Notice how payload is an Option<Payload>. The Payload enum has variants for each message in the oneof.
mod events {
include!(concat!(env!("OUT_DIR"), "/events.rs"));
}
use events::{Event, Payload, UserLogin, DataUpdate};
/// Serializes an event and logs the byte count.
/// This demonstrates using the generated encode method.
fn log_event_size(event: &Event) {
let bytes = event.encode_to_vec();
println!("Event size: {} bytes", bytes.len());
}
fn main() {
// Construct an event with a login payload
let event = Event {
timestamp: 1700000000,
payload: Some(Payload::Login(UserLogin {
username: "alice".to_string(),
})),
};
// Log the size using the helper function
log_event_size(&event);
// Serialize to bytes
let bytes = event.encode_to_vec();
// Deserialize and pattern match on the payload
let decoded = Event::decode(&bytes[..]).unwrap();
match decoded.payload {
Some(Payload::Login(login)) => println!("User logged in: {}", login.username),
Some(Payload::Update(update)) => println!("Key updated: {}", update.key),
None => println!("Empty event"),
}
}
There's a convention here worth noting. prost maps bytes fields to the bytes crate's Bytes type, not Vec<u8>. Bytes is a reference-counted buffer that supports zero-copy slicing. It's more efficient for network data. You need to add bytes = "1" to your [dependencies] in Cargo.toml. If you forget, the generated code won't compile. You'll get an E0432 error because the module bytes isn't found.
Pattern match on the payload. Rust's type system ensures you handle every variant, even the ones you haven't written yet.
Pitfalls and gotchas
Proto3 semantics differ from Rust in subtle ways. prost follows the Protobuf spec, which means you need to watch for defaults.
Proto3 treats missing scalar fields as their default value. A missing int32 becomes 0. A missing string becomes "". prost reflects this. The generated struct fields are the scalar types, not Option<T>. If you need to distinguish "missing" from "default", you must use the optional keyword in the .proto file. prost generates Option<T> for optional fields.
message Config {
// This field is always present in Rust, defaulting to 0
int32 port = 1;
// This field is Option<i32> in Rust
optional int32 timeout = 2;
}
Another gotcha is build.rs panics. If compile_protos fails, the build stops. This usually means a syntax error in the .proto file or a missing dependency. Check the error message. It points to the proto file, not the Rust code. Fix the schema first.
Be careful with OUT_DIR. Never hardcode paths to generated files. Always use env!("OUT_DIR"). Cargo manages this directory. It changes between builds and targets. Hardcoding breaks cross-compilation and caching.
If you're working with large messages, prefer encode over encode_to_vec. encode writes to any io::Write implementation. It streams the data without allocating a full vector. encode_to_vec is convenient for small payloads, but it allocates the entire buffer upfront.
use std::io::Cursor;
fn stream_encode(event: &Event) -> Result<Vec<u8>, prost::EncodeError> {
let mut buf = Vec::new();
// Encode directly to the buffer without intermediate allocation
event.encode(&mut buf)?;
Ok(buf)
}
Trust the schema. A typo in .proto breaks the build before Rust sees the code. Validate your protos early.
Choosing your tools
Protocol Buffers are just one way to serialize data. Pick the right tool for your stack.
Use prost when you need a lightweight, idiomatic Rust implementation of Protocol Buffers without the baggage of the official C++-based runtime. Use tonic when you're building gRPC services and want a full stack that handles HTTP/2 transport alongside prost message generation. Use serde with serde_json when your consumers expect human-readable formats or when schema evolution is too chaotic for Protobuf's strict field numbering. Use the official protobuf crate only when you must interoperate with legacy codebases that depend on specific edge-case behaviors of the C++ runtime, though prost covers 99% of use cases.
Match the tool to the transport. prost for messages, tonic for services.