Skip to content
Learn Rust
Theme

A gRPC service with tonic

gRPC is a way for programs to call each other. The grpc.io introduction puts it this way: “a client application can directly call a method on a server application on a different machine as if it were a local object”. The methods are described in a .proto file (a protocol buffers file), and tools generate the client and server code from it. tonic is a Rust library for gRPC. Its docs say its client and server are “built on top of tokio, hyper and tower” (hyper is an HTTP library, and tower a library of building blocks for network services), so the code on this page is async code that needs a runtime: Async with Tokio shows that runtime, and Async Basics explains futures first. You write a .proto file, tonic generates the Rust types at build time, and you fill in the methods.

Rememberbuild.rs turns the .proto into Rust code, and it needs protoc installed. You implement the generated trait for the server and call the generated client.

Project layout
greeter/
├── proto/
│   └── greeter.proto           the service definition
├── src/
│   ├── bin/
│   │   ├── client.rs           cargo run --bin client
│   │   └── server.rs           cargo run --bin server
│   └── lib.rs                  pulls in the generated code
├── build.rs                    runs before compiling: turns the .proto into Rust
├── Cargo.lock                  created by Cargo
└── Cargo.toml                  dependencies go here

The image that runs every session on this site installs Debian’s protobuf-compiler package, and this is the version it has:

terminal
$ protoc --version
libprotoc 3.21.12

The build below worked with that version. protobuf.dev’s comments say to expect 33.0 or newer, and I did not run a newer protoc here, so check yours with protoc --version. This is what the build prints when protoc is not found. (The session moves protoc aside for one build and puts it back. The folder name holds a hash that Cargo chooses and that can differ on your machine.)

$ cargo build
error: failed to run custom build command for `greeter v0.1.0 (/home/you/greeter)`

Caused by:
  process didn't exit successfully: `target/debug/build/greeter-d1dfd6f86395b19c/build-script-build` (exit status: 1)
  --- stderr
  Error: Custom { kind: NotFound, error: "Could not find `protoc`. If `protoc` is installed, try setting the `PROTOC` environment variable to the path of the `protoc` binary. To install it on Debian, run `apt-get install protobuf-compiler`. It is also available at https://github.com/protocolbuffers/protobuf/releases  For more information: https://docs.rs/prost-build/#sourcing-protoc" }
Rust
syntax = "proto3";
package greeter;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }

The server and the client are two programs in the same project, which is the pattern from More than one program. The server prints the address it listens on. That port is written in server.rs, so the line is the same every run. The server keeps running after the client finishes: stop it with Ctrl-C when you try this yourself (the session runner stops it when the session ends).

Rust
use greeter::pb::greeter_server::{Greeter, GreeterServer};
use greeter::pb::{HelloReply, HelloRequest};
use tonic::{Request, Response, Status, transport::Server};
struct MyGreeter;
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<Response<HelloReply>, Status> {
let name = request.into_inner().name;
if name.is_empty() {
return Err(Status::invalid_argument("name is required"));
}
Ok(Response::new(HelloReply {
message: format!("Hello, {name}!"),
}))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "127.0.0.1:50051".parse()?;
println!("gRPC server listening on {addr}");
Server::builder()
.add_service(GreeterServer::new(MyGreeter))
.serve(addr)
.await?;
Ok(())
}
$ cargo run --bin server
gRPC server listening on 127.0.0.1:50051
$ cargo run --bin client
server said: Hello, Ana!
error InvalidArgument: name is required
  • build.rs runs before your code compiles. It calls tonic_prost_build::compile_protos, which turns greeter.proto into Rust structs (HelloRequest, HelloReply), a server trait (Greeter) and a client (GreeterClient).
  • tonic::include_proto!("greeter") pulls that generated code into a module. The argument is the proto package name, and the macro docs say it works while the output directory is the default one, OUT_DIR. Putting it in lib.rs lets both programs share it. You can read what it includes:
terminal
$ ls target/debug/build/greeter-*/out/
greeter.rs
  • Errors are Status values with a gRPC code, like Status::invalid_argument, which shows up as InvalidArgument on the client. The codes are the variants of tonic::Code.
  • Streaming RPCs exist: tonic has a Streaming type for a stream of messages, and the routeguide tutorial covers them. This page does not run a streaming example.

The generated Greeter trait is declared with an #[async_trait] attribute, and its methods are async fn:

terminal
$ grep -B1 -A5 "pub trait Greeter" target/debug/build/greeter-*/out/greeter.rs
    #[async_trait]
    pub trait Greeter: std::marker::Send + std::marker::Sync + 'static {
        async fn say_hello(
            &self,
            request: tonic::Request<super::HelloRequest>,
        ) -> std::result::Result<tonic::Response<super::HelloReply>, tonic::Status>;
    }

tonic re-exports that attribute from the async-trait crate. That crate’s docs say an async fn in a trait became stable in Rust 1.75 without support for dyn Trait, that its macro rewrites each async fn into a method returning Pin<Box<dyn Future + Send + 'async_trait>>, and that you “write an #[async_trait] macro on top of traits and trait impls that contain async fn”. A future that is Send is what tokio::spawn requires (Send and Sync). Leave the attribute off your impl and rustc rejects it (the greeter-97760507d170f78c folder name holds a hash that Cargo chooses and that can differ on your machine):

$ cargo build --bin server
error[E0195]: lifetime parameters or bounds on method `say_hello` do not match the trait declaration
   --> src/bin/server.rs:8:23
    |
  8 |       async fn say_hello(
    |                         ^ lifetimes do not match method in trait
    |
   ::: target/debug/build/greeter-97760507d170f78c/out/greeter.rs:136:9
    |
136 | /         async fn say_hello(
137 | |             &self,
138 | |             request: tonic::Request<super::HelloRequest>,
139 | |         ) -> std::result::Result<tonic::Response<super::HelloReply>, tonic::Status>;
    | |____________________________________________________________________________________- lifetimes in impl do not match this method in trait

So put #[tonic::async_trait] on your impl as well, so that it matches the trait. I did not look for why the generated trait uses the macro instead of plain async fn, and the page does not claim a reason beyond the crate’s docs.

Where a row names a code generator, it starts from a .proto file; what differs is how the code is generated and which library carries the calls. I ran the Go row. For the other rows I checked that the packages exist (PyPI, npm, NuGet and Maven Central, on 25 October 2026) and read the pages named in the last column. I did not run them: the image has none of those libraries installed, and I did not add them.

Language Library Code comes from What I did
Rust tonic with prost build.rs and tonic-prost-build run above
Go grpc-go (google.golang.org/grpc) protoc with the plugins protoc-gen-go and protoc-gen-go-grpc run below
Python grpcio grpcio-tools: the quickstart says Python’s gRPC tools “include the protocol buffer compiler protoc” docs read, not run
TypeScript @grpc/grpc-js (“gRPC Library for Node - pure JS implementation” on npm), or Connect, “a family of libraries for building browser and gRPC-compatible APIs” @grpc/proto-loader (“for loading .proto files”); the Node quickstart uses a dynamic_codegen example docs read, not run
Java grpc-java (io.grpc on Maven Central) the build regenerates GreeterGrpc.java (quickstart) docs read, not run
Kotlin grpc-kotlin (io.grpc:grpc-kotlin-stub) the build regenerates the Kotlin client and server classes (quickstart) docs read, not run
C# Grpc.AspNetCore for servers, Grpc.Net.Client for clients (grpc-dotnet) Grpc.Tools: Microsoft’s gRPC docs say it “is required to generate the C# assets from .proto files” docs read, not run
C no entry in grpc.io’s language list or in the gRPC README’s list. The repository has GRPC Core, “a low-level library designed to be wrapped by higher level libraries” with its API in grpc.h not covered by the pages I read docs read, not run
C++ gRPC C++ the quickstart builds and installs gRPC and Protocol Buffers with CMake docs read, not run

The grpc.io site also has a Rust page. Its quickstart uses other generator tools (a protoc-gen-rust-grpc plugin and, it says, exactly protoc 34.0), so it is a different setup from this page, which is about tonic 0.14.6. tonic’s repository is now grpc/grpc-rust (github.com/hyperium/tonic redirects there), and its README says the master branch “is currently preparing breaking changes” and points to the 0.14.x branch for released code. I did not run the grpc.io Rust quickstart.

Go needs the same protoc, plus two plugins that go install puts in $(go env GOPATH)/bin, which has to be on your PATH (quickstart). The Go project uses the same greeter.proto, and go mod tidy downloads the gRPC and protobuf modules. The session below pins the plugin versions; the Go quickstart uses @latest. The go.mod file pins the modules, and says go 1.27; the image has Go 1.27.3. The M options on the protoc line tell the Go generators which Go package greeter.proto belongs to, because the shared .proto file has no go_package option. The go install, go mod tidy and go build lines hide Go’s “downloading” messages, so the output does not depend on what was already cached.

Go
package main
import (
"context"
"fmt"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "greeter/pb"
)
type greeter struct {
pb.UnimplementedGreeterServer
}
func (greeter) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
if req.GetName() == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
return &pb.HelloReply{Message: "Hello, " + req.GetName() + "!"}, nil
}
func main() {
lis, err := net.Listen("tcp", "127.0.0.1:50051")
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
pb.RegisterGreeterServer(s, greeter{})
fmt.Printf("gRPC server listening on %s\n", lis.Addr())
log.Fatal(s.Serve(lis))
}
$ go install google.golang.org/protobuf/cmd/[email protected]
$ go install google.golang.org/grpc/cmd/[email protected]
$ PATH="$PATH:$(go env GOPATH)/bin" protoc --go_out=. --go_opt=module=greeter --go_opt=Mproto/greeter.proto=greeter/pb --go-grpc_out=. --go-grpc_opt=module=greeter --go-grpc_opt=Mproto/greeter.proto=greeter/pb proto/greeter.proto
$ go mod tidy
$ go build -o bin/ ./server ./client
$ ./bin/server
gRPC server listening on 127.0.0.1:50051
$ ./bin/client
server said: Hello, Ana!
error InvalidArgument: name is required

Each result below comes from a run in the image, in the project at the top of the page.

  1. Change the status code. In src/bin/server.rs, change Status::invalid_argument to Status::not_found, start the server and run the client. The last line of the client’s output becomes error NotFound: name is required, the same message under a different code.
terminal
$ sed -i 's/invalid_argument/not_found/' src/bin/server.rs
$ cargo run --bin server
gRPC server listening on 127.0.0.1:50051
$ cargo run --bin client
server said: Hello, Ana!
error NotFound: name is required
  1. Run the client with no server. With nothing listening on the port, cargo run --bin client exits with status 1 and prints an Error: tonic::transport::Error(...) ending in Connection refused. No server said: line appears. Nothing is listening on the port, so the connection is refused. The type is tonic::transport::Error, which is what the generated GreeterClient::connect returns on failure, not a Status. Its signature, from the generated file:
terminal
$ grep -A2 "pub async fn connect" target/debug/build/greeter-*/out/greeter.rs
        pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
        where
            D: TryInto<tonic::transport::Endpoint>,

And the client run itself:

$ cargo run --bin client
Error: tonic::transport::Error(Transport, ConnectError(ConnectError("tcp connect error", 127.0.0.1:50051, Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })))
  1. Call the Rust server from the Go client. The Go client is built from the same greeter.proto with the same go install, protoc, go mod tidy and go build steps as in the Go section above (this session hides all but the build of the client). It printed the same two lines against the Rust server:
terminal
$ cargo run --bin server
gRPC server listening on 127.0.0.1:50051
$ go build -o bin/ ./client
$ ./bin/client   # the Go client, calling the Rust server
server said: Hello, Ana!
error InvalidArgument: name is required

The generated client’s say_hello takes &mut self, which matters for the gotcha below:

terminal
$ grep -A3 "pub async fn say_hello" target/debug/build/greeter-*/out/greeter.rs
        pub async fn say_hello(
            &mut self,
            request: impl tonic::IntoRequest<super::HelloRequest>,
        ) -> std::result::Result<tonic::Response<super::HelloReply>, tonic::Status> {
Gotcha

A client that isn't mut

Looks fine: in Go it runs fine. Rust won't compile it.

Works in Go. In Rust it's just a rule.Not run in Kotlin on this page.Not run in Python on this page.Not run in TypeScript on this page.Not run in Java on this page.Not run in C# on this page.Not run in C on this page.Not run in C++ on this page.
Rustsrc/bin/client.rs✗ won't compile
use greeter::pb::HelloRequest;
use greeter::pb::greeter_client::GreeterClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = GreeterClient::connect("http://127.0.0.1:50051").await?;
let ana = HelloRequest { name: "Ana".into() };
let reply = client.say_hello(ana).await?;
println!("server said: {}", reply.into_inner().message);
let nobody = HelloRequest::default(); // name is empty
match client.say_hello(nobody).await {
Ok(_) => println!("unexpected success"),
Err(status) => println!("error {:?}: {}", status.code(), status.message()),
}
Ok(())
}
package main
import (
"context"
"fmt"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
pb "greeter/pb"
)
func main() {
conn, err := grpc.NewClient("127.0.0.1:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// An ordinary variable: no `mut` in Go.
client := pb.NewGreeterClient(conn)
ctx := context.Background()
reply, err := client.SayHello(ctx, &pb.HelloRequest{Name: "Ana"})
if err != nil {
log.Fatal(err)
}
fmt.Println("server said:", reply.GetMessage())
_, err = client.SayHello(ctx, &pb.HelloRequest{}) // name is empty
if err != nil {
st := status.Convert(err)
fmt.Printf("error %v: %s\n", st.Code(), st.Message())
}
}
$ go run main.go✓ runs
server said: Hello, Ana!
error InvalidArgument: name is required

There is no Kotlin version of this one.

There is no Python version of this one.

There is no TypeScript version of this one.

There is no Java version of this one.

There is no C# version of this one.

There is no C version of this one.

There is no C++ version of this one.

What does the compiler say? Guess first, then open.
$ cargo buildWhat is E0596?
error[E0596]: cannot borrow `client` as mutable, as it is not declared as mutable
  --> src/bin/client.rs:6:9
   |
 6 |     let client = GreeterClient::connect("http://127.0.0.1:50051").await?;
   |         ^^^^^^ not mutable
...
 9 |     let reply = client.say_hello(ana).await?;
   |                 ------ cannot borrow as mutable
...
13 |     match client.say_hello(nobody).await {
   |           ------ cannot borrow as mutable
   |
help: consider changing this to be mutable
   |
 6 |     let mut client = GreeterClient::connect("http://127.0.0.1:50051").await?;
   |         +++
What happens in Go

Go runs this. The client is an ordinary variable, client := pb.NewGreeterClient(conn), and the same two calls print the same two lines (run here, against a Go server).

Why it looks fine

A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.

Why it looks fine

A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.

Why it looks fine

A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.

Why it looks fine

A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.

Why it looks fine

A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.

Why it looks fine

A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.

Why it looks fine

A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.

What Rust sees

The generated say_hello takes &mut self (you can read the signature in the generated session above). Calling a &mut self method needs a mut variable.

Why Rust says no

It is the rule from Mutable borrows: a &mut borrow needs a variable declared mut. tonic's `Channel` docs explain the same requirement one level down, on the Channel that the generated client wraps: sending a request "requires a &mut self and thus can only send one request in flight", which they call "intentional" and required by the Service contract of the tower library. Need several tasks to make calls at once? The same docs say cloning a Channel is "cheap and encouraged", because the connection runs in a background task that the clones talk to. The docs say that about a Channel; this page does not run a program that clones the generated client.

The fix

Compiles and runs.

Rust, fixedsrc/bin/client.rs
use greeter::pb::HelloRequest;
use greeter::pb::greeter_client::GreeterClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Calls take &mut self, so the client must be mut.
let mut client = GreeterClient::connect("http://127.0.0.1:50051").await?;
let ana = HelloRequest { name: "Ana".into() };
let reply = client.say_hello(ana).await?;
println!("server said: {}", reply.into_inner().message);
let nobody = HelloRequest::default(); // name is empty
match client.say_hello(nobody).await {
Ok(_) => println!("unexpected success"),
Err(status) => println!("error {:?}: {}", status.code(), status.message()),
}
Ok(())
}
$ cargo run --bin client
server said: Hello, Ana!
error InvalidArgument: name is required

The version this page is about is tonic 0.14.6 with prost 0.14.4 and tokio 1.54.1, as pinned in Cargo.lock. The image has protoc 3.21.12, Go modules grpc v1.84.0 and protobuf v1.36.12, protoc-gen-go v1.36.12 and protoc-gen-go-grpc v1.6.2.