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.
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
You need protoc first
Section titled “You need protoc first”The image that runs every session on this site installs Debian’s protobuf-compiler package, and this is the version it has:
$ 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.)
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" }
The project
Section titled “The project”syntax = "proto3";package greeter;
service Greeter { rpc SayHello (HelloRequest) returns (HelloReply);}
message HelloRequest { string name = 1; }message HelloReply { string message = 1; }[package]name = "greeter"version = "0.1.0"edition = "2024"
[dependencies]prost = "0.14.4"tokio = { version = "1.54.1", features = ["full"] }tonic = "0.14.6"tonic-prost = "0.14.6"
[build-dependencies]tonic-prost-build = "0.14.6"fn main() -> Result<(), Box<dyn std::error::Error>> { // Turn proto/greeter.proto into Rust code at build time. tonic_prost_build::compile_protos("proto/greeter.proto")?; Ok(())}// The code generated from proto/greeter.proto, shared by the server and the client.pub mod pb { tonic::include_proto!("greeter");}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).
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(())}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(())}gRPC server listening on 127.0.0.1:50051$ cargo run --bin client
server said: Hello, Ana! error InvalidArgument: name is required
build.rsruns before your code compiles. It callstonic_prost_build::compile_protos, which turnsgreeter.protointo 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 protopackagename, and the macro docs say it works while the output directory is the default one,OUT_DIR. Putting it inlib.rslets both programs share it. You can read what it includes:
$ ls target/debug/build/greeter-*/out/
greeter.rs
- Errors are
Statusvalues with a gRPC code, likeStatus::invalid_argument, which shows up asInvalidArgumenton the client. The codes are the variants oftonic::Code. - Streaming RPCs exist: tonic has a
Streamingtype for a stream of messages, and the routeguide tutorial covers them. This page does not run a streaming example.
What is #[tonic::async_trait] for?
Section titled “What is #[tonic::async_trait] for?”The generated Greeter trait is declared with an #[async_trait] attribute, and its methods are async fn:
$ 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):
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.
The same job in your language
Section titled “The same job in your language”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, from the same .proto
Section titled “Go, from the same .proto”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.
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))}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()) }}module greeter
go 1.27
require ( google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12)
require ( golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect)gRPC server listening on 127.0.0.1:50051$ ./bin/client
server said: Hello, Ana! error InvalidArgument: name is required
Try it yourself
Section titled “Try it yourself”Each result below comes from a run in the image, in the project at the top of the page.
- Change the status code. In
src/bin/server.rs, changeStatus::invalid_argumenttoStatus::not_found, start the server and run the client. The last line of the client’s output becomeserror NotFound: name is required, the same message under a different code.
$ 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
- Run the client with no server. With nothing listening on the port,
cargo run --bin clientexits with status 1 and prints anError: tonic::transport::Error(...)ending inConnection refused. Noserver said:line appears. Nothing is listening on the port, so the connection is refused. The type istonic::transport::Error, which is what the generatedGreeterClient::connectreturns on failure, not aStatus. Its signature, from the generated file:
$ 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:
Error: tonic::transport::Error(Transport, ConnectError(ConnectError("tcp connect error", 127.0.0.1:50051, Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })))- Call the Rust server from the Go client. The Go client is built from the same
greeter.protowith the samego install,protoc,go mod tidyandgo buildsteps 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:
$ 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:
$ 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> {
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.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()) }}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.
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?; | +++
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).
A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.
A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.
A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.
A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.
A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.
A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.
A client feels like a read-only handle you make calls through, so declaring it mutable seems odd.
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.
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.
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(())}server said: Hello, Ana! error InvalidArgument: name is required
Read more
Section titled “Read more”tonicon docs.rs, the version this page pins (0.14.6), andtonic-prost-buildforcompile_protosandconfigure.- Sourcing
protocin the prost-build docs, and the Protocol Buffer Compiler Installation page. - The tonic routeguide tutorial, which covers streaming, and the examples.
async-traiton docs.rs, for what the attribute rewrites.- Language Guides on grpc.io, one quickstart per language.
- In this course: Async with Tokio, Async Basics, Send and Sync and Mutable borrows.
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.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.