How to use prost crate in Rust protobuf

Enable the protobuf feature in axum-extra and use the Protobuf extractor to handle binary message serialization.

The Binary Bridge

You're building an API that handles thousands of requests per second. Your frontend sends JSON, but your internal services are choking on the overhead. You decide on Protocol Buffers for the heavy lifting. You've got prost generating your Rust structs from a .proto file. Now you need to wire those structs into an axum handler so the request body deserializes automatically. The setup requires a specific extractor from axum-extra and a build script that generates code before your application compiles. Once the gears are aligned, your handlers receive typed structs and return binary responses with zero manual parsing.

The Contract and the Translator

Protocol Buffers define a schema. You write a .proto file that describes the data structure. That file is the contract between services. prost is the translator. It reads the schema and generates Rust code. The generated code contains the structs and the logic to encode data to bytes and decode bytes back to structs. axum handles HTTP. axum-extra provides the bridge. The Protobuf<T> extractor tells Axum to read the request body as raw bytes, pass those bytes to the prost decoder, and hand you a typed struct. If the bytes are garbage, the extractor rejects the request before your handler runs.

Think of the .proto file as a blueprint. prost-build is the construction crew that builds the Rust types from that blueprint. axum-extra is the receptionist that checks the envelope, verifies the contents match the blueprint, and hands the package to your handler.

Minimal Setup

Start with the dependencies. You need axum for the server, axum-extra with the protobuf feature enabled, prost for the runtime traits, and prost-build for code generation.

[dependencies]
axum = "0.8"
axum-extra = { version = "0.12", features = ["protobuf"] }
prost = "0.14"

[build-dependencies]
prost-build = "0.14"

The protobuf feature in axum-extra is mandatory. Without it, the Protobuf type doesn't exist. The prost-build crate lives in build-dependencies because it only runs during compilation.

Define your message struct. prost uses derive macros and attributes to mark fields.

use axum::{routing::post, Router};
use axum_extra::protobuf::Protobuf;
use prost::Message;

// Derive prost::Message to get encode/decode methods.
#[derive(prost::Message)]
struct CreateUser {
    // Tag numbers are required by the protobuf wire format.
    #[prost(string, tag = "1")]
    email: String,
}

// The extractor deserializes the body into CreateUser.
// If decoding fails, Axum returns a 400 error automatically.
async fn create_user(Protobuf(payload): Protobuf<CreateUser>) -> String {
    format!("User created: {}", payload.email)
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/users", post(create_user));
    println!("Listening on 127.0.0.1:3000");
    axum::serve(
        tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(),
        app,
    )
    .await
    .unwrap();
}

The Protobuf(payload) syntax is a pattern match. The extractor pulls the CreateUser out of the wrapper and binds it to payload. If the request body is empty or malformed, the extractor returns an error response. Your handler never executes. This keeps your logic clean. You only deal with valid data.

The Build Script Hurdle

Rust doesn't have a pre-processor. Code generation happens via a build script. Create a build.rs file in your project root. This file runs before main.rs compiles.

// build.rs
fn main() {
    // Compile .proto files and generate Rust code.
    // The first argument is the list of proto files.
    // The second argument is the include path.
    prost_build::compile_protos(&["src/proto/user.proto"], &["src/proto/"]).unwrap();
}

When you run cargo build, Cargo executes build.rs. prost-build reads the .proto files and writes generated Rust code to a temporary directory called OUT_DIR. Your main.rs needs to include that generated code.

// main.rs
// Include the generated code from OUT_DIR.
// This pulls the structs and implementations into scope.
mod user {
    include!(concat!(env!("OUT_DIR"), "/user.rs"));
}

The include! macro inserts the generated file at compile time. env!("OUT_DIR") expands to the path where prost-build wrote the code. This pattern is standard for prost. It feels like magic until you realize it's just file inclusion. Keep your build script simple. It runs every time you compile.

Wire Format and Tags

Protobuf doesn't use field names on the wire. It uses tags. The tag = "1" attribute in the code above maps to an integer on the network. Names are for humans. Tags are for the machine. This design enables forward compatibility.

If you rename a field from email to user_email, the wire format doesn't change. Clients still see tag 1. They decode it correctly. You can rename fields freely without breaking compatibility. If you change the tag number, you break compatibility. Tag 1 becomes tag 2. Clients reading tag 1 see nothing. Clients reading tag 2 see the data. This is a common mistake. Treat tags as immutable identifiers. Names are documentation. Tags are the contract.

Unknown fields are skipped. If a server sends a message with tag 5 and the client only knows tags 1 and 2, the client ignores tag 5 and decodes the rest. This allows you to add new fields without updating all clients immediately. The decoder is resilient. It only fails on structural errors like truncated data or invalid varint encoding.

Realistic Integration

A real project usually has a .proto file and a response type. The Protobuf type works symmetrically. It extracts requests and encodes responses.

use axum::{response::IntoResponse, routing::post, Router};
use axum_extra::protobuf::Protobuf;
use prost::Message;

// Generated by prost-build from user.proto.
mod user {
    include!(concat!(env!("OUT_DIR"), "/user.rs"));
}

// Handler receives decoded request and returns encoded response.
async fn create_user(Protobuf(req): Protobuf<user::CreateUserRequest>) -> impl IntoResponse {
    // Business logic here.
    // In a real app, you'd save to a database.
    let resp = user::CreateUserResponse {
        id: 42,
        status: "ok".to_string(),
    };
    // Wrap in Protobuf to encode as binary response.
    // Sets Content-Type to application/x-protobuf.
    Protobuf(resp)
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/users", post(create_user));
    axum::serve(
        tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(),
        app,
    )
    .await
    .unwrap();
}

The response wrapper sets the Content-Type header to application/x-protobuf. Clients expecting JSON will see binary data. Ensure your clients know to request protobuf. The extractor also checks the incoming Content-Type. If a client sends application/json, the extractor rejects it. This prevents accidental misuse. The symmetry makes the API consistent. Request and response use the same mechanism.

Pitfalls and Errors

Missing the prost::Message derive is the most common error. If you define a struct but forget the derive, the compiler rejects the code.

If you try to use a struct without the derive, the compiler rejects you with E0277 (trait bound not satisfied). The Protobuf extractor requires the type to implement prost::Message. Add the derive and the error disappears.

Another trap is the Content-Type header. The extractor expects application/x-protobuf. If you test with curl and forget the header, you get a 415 Unsupported Media Type.

curl -X POST http://127.0.0.1:3000/users \
  -H "Content-Type: application/x-protobuf" \
  --data-binary @payload.bin

The --data-binary flag sends raw bytes. The header tells Axum how to interpret them. Without the header, the extractor bails out. Trust the decode error. It saves you from parsing garbage.

Convention aside: prost uses Option<T> for optional fields and Vec<T> for repeated fields. This matches Rust idioms. You don't need wrapper types. The generated code handles the wire format details. You work with standard Rust types.

Decision Matrix

Use axum-extra with Protobuf when you're building an HTTP REST API that needs binary efficiency and you want automatic extraction. Use axum's Json extractor when your clients are browsers or mobile apps that expect text-based payloads and human-readable debugging. Use tonic when you need gRPC streaming, bidirectional communication, or strict service definitions beyond simple request-response. Use raw Bytes extraction when you need to inspect the payload before decoding or support multiple content types in the same handler.

Where to go next