Skip to content
gohacks.dev
APIs

gRPC in Go From Protobuf Definitions to Running Services

Build your first gRPC service in Go: write the proto, generate code with protoc, implement the server, call it with a timeout and see what was generated.

11 min read
Summarize in AI
Flat illustration of a gateway connected to a ring of services.

gRPC is a way for programs to call functions on other programs, with the contract written down in a file and the networking code generated from it. You describe a service and its messages in Protocol Buffers; a compiler produces Go types and interfaces; you implement the server interface and call the client one. Underneath, it uses HTTP/2 and a compact binary encoding, which makes it fast and suitable for streaming. Within a company's services it has become a common alternative to JSON over REST.

Tested with Go 1.25.6, grpc-go v1.75.0, protobuf-go v1.36.8, protoc 29.3, macOS on Apple Silicon (arm64)

This tutorial takes a service from an empty directory to a running client and server in one process, without skipping the setup steps that tutorials usually wave at: where the generator comes from, what exactly it writes, and why the server embeds an Unimplemented type. Later articles go deeper on protobuf and schema evolution, code generation, unary calls and streaming. If you are deciding between gRPC and something friendlier, read Connect RPC as a gRPC alternative as well. Everything here ran on Go 1.25.6.

What you need

  • Go, and a module (go mod init is enough).
  • The Protocol Buffers compiler, protoc. On macOS brew install protobuf; on Linux your package manager or the releases page. The lab ran with version 29.3.
  • Two code generator plugins for protoc: protoc-gen-go (messages) and protoc-gen-go-grpc (services). They are ordinary Go programs.

The plugins are the part tutorials usually answer with go install ...@latest, which installs whatever version is newest today. The better way is to make the module itself depend on them, so the versions are in go.mod, everyone gets the same ones, and updates are a reviewed change. A tiny file with a tools build tag does it:

tools.goGo
//go:build tools

package main

import (
	_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
	_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

The blank imports keep the modules in go.mod. The build tag keeps the file out of the normal build, so it never compiles into your program.

The contract

The .proto file is the source of truth. Everything else is derived from it.

proto/greeter.protoprotobuf
syntax = "proto3";

package greeter.v1;

option go_package = "example.test/lab/greeterpb";

// Greeter is the service. Each rpc becomes a method on the generated client and server interfaces.
service Greeter {
  rpc SayHello(HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

A few things in it matter more than they look.

syntax = "proto3" selects the current language version. package greeter.v1 namespaces the service on the wire; it becomes part of the method path, /greeter.v1.Greeter/SayHello, so putting a version in the package is how you make a breaking change later without breaking old clients. option go_package tells the generator which Go import path to use.

The numbers after the field names (= 1) are field numbers, and they are the real identity of a field on the wire. The name does not travel in the binary format, only the number does. That is why you can rename a field safely and why you must never reuse or renumber one. The schema evolution article covers the rules.

An rpc declaration is a method. This one takes a HelloRequest and returns a HelloReply: a unary call, one request and one response. Streaming variants add the stream keyword.

Generating the code

Three steps do the work: build the plugins from the module's dependencies, run protoc with both, then run the program. The script below does all three and prints what was generated.

Shell
mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
PATH="$PWD/bin:$PATH" protoc --go_out=. --go_opt=module=example.test/lab --go-grpc_out=. --go-grpc_opt=module=example.test/lab proto/*.proto
ls greeterpb
grep -E '^type (GreeterClient|GreeterServer|UnimplementedGreeterServer|HelloRequest|HelloReply) ' greeterpb/*.go | sed -E 's/^[^:]*://'
go run .
Text
greeter.pb.go
greeter_grpc.pb.go
type HelloRequest struct {
type HelloReply struct {
type GreeterClient interface {
type GreeterServer interface {
type UnimplementedGreeterServer struct{}
reply: Hello, Gopher
as text: message:"Hello, Gopher"

The first lines build the plugins into a local bin directory. The protoc line puts that directory first in the PATH, so protoc finds protoc-gen-go and protoc-gen-go-grpc by name (protoc looks for an executable named protoc-gen-<name> for each --<name>_out flag). module=example.test/lab makes the output paths relative to the module, so files land in greeterpb/ and not in a deep import-path directory.

What was generated

The ls and grep lines of the script list the output and print the key declarations, which is the first part of what the run produced. Two files: greeter.pb.go holds the messages. HelloRequest and HelloReply are structs with generated getters (GetName, GetMessage), serialisation methods and reflection support. Use the getters in general: they are safe on a nil message and return the zero value, which removes a class of nil-pointer panics.

greeter_grpc.pb.go holds the service plumbing: GreeterClient (what callers use), GreeterServer (what you implement), UnimplementedGreeterServer, a registration function and the service descriptor. Never edit these files. Commit them, or generate them in CI; do not do half of each.

The server and the client

This is the program the script runs at the end.

main.goGo
package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	pb "example.test/lab/greeterpb"
)

// server embeds UnimplementedGreeterServer so adding an rpc to the .proto later does not break the build.
type server struct {
	pb.UnimplementedGreeterServer
}

func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
	return &pb.HelloReply{Message: "Hello, " + req.GetName()}, nil
}

func main() {
	lis, err := net.Listen("tcp", "127.0.0.1:0") // port 0 asks the OS for a free port
	if err != nil {
		log.Fatal(err)
	}
	srv := grpc.NewServer()
	pb.RegisterGreeterServer(srv, &server{})
	go srv.Serve(lis)
	defer srv.GracefulStop()

	conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	client := pb.NewGreeterClient(conn)
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	reply, err := client.SayHello(ctx, &pb.HelloRequest{Name: "Gopher"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("reply:", reply.GetMessage())
	fmt.Println("as text:", reply.String())
}

The server type embeds pb.UnimplementedGreeterServer. That is a compile-time requirement in current versions of the generator (the interface has an unexported method for forward compatibility). It also gives every rpc a default implementation that returns the Unimplemented status. When someone adds a second rpc to the proto and regenerates, the server still builds, and calls to the new method fail cleanly until you implement it. Without the embedding, every proto change would break every server at compile time, which sounds safe and turns out to make rolling deployments awkward.

SayHello takes a context and a request and returns a reply and an error. The context carries the deadline and cancellation from the caller. Use it for any database or network call the method makes. Returning an error produces a gRPC status; later articles cover error codes in detail.

The listener uses port 0. The operating system picks a free port, and lis.Addr().String() tells the client where to connect. This is how examples and tests avoid clashing with whatever else is running.

grpc.NewServer() and RegisterGreeterServer create the server and attach the implementation. go srv.Serve(lis) runs it. defer srv.GracefulStop() stops accepting new calls and waits for running ones to finish, the right way to shut down (graceful shutdown).

The client uses grpc.NewClient. It creates a connection object that connects lazily and reconnects on its own. NewClient replaced Dial and DialContext in current grpc-go; the old functions are deprecated. The insecure credentials mean plain text: fine for local examples, not for production, where you use TLS (authentication). A grpc.ClientConn is safe for concurrent use and expensive to create, so share one per target for the life of the process.

Every call gets a deadline. context.WithTimeout gives the call two seconds. Without a deadline, a call to a hung server waits forever. Making a deadline on every outgoing call a habit is the most valuable gRPC rule, and deadlines and propagation explains how it travels across services.

What the run proves

The output shows the plugins' output files, the generated declarations, then the client's result: Hello, Gopher, and the message printed with its text format, message:"Hello, Gopher". Client and server ran in one process over a real TCP socket with real HTTP/2 framing, and every call goes through generated code, so the same program with two processes would behave the same.

What happens on the wire

A gRPC call is an HTTP/2 request with a particular shape, and knowing it demystifies the tooling. The client sends a POST to the path /greeter.v1.Greeter/SayHello with content-type: application/grpc and a header te: trailers. The request body is one or more length-prefixed messages: a one byte compression flag, a four byte big-endian length, and then the protobuf bytes. The server answers with headers, then the same framing for the response message, then HTTP/2 trailers carrying grpc-status and grpc-message.

Trailers are why gRPC needs HTTP/2 end to end and why browsers cannot speak it directly: the browser fetch API does not expose trailers. The same framing explains the practical limits you meet later, such as the default maximum message size of 4 MiB on the receive side (message size limits) and why a proxy that does not support HTTP/2 trailers breaks calls.

Because the method path is /package.Service/Method, load balancers and service meshes can route by method, and metrics are naturally labelled by it. It is also the reason that renaming a package or service is a breaking change even though no field changed.

Reading the generated code once

It is worth opening greeter_grpc.pb.go once. You will find a GreeterClient interface with SayHello(ctx, in, opts ...grpc.CallOption), a greeterClient struct implementing it with a cc grpc.ClientConnInterface, and an Invoke call that does the work. You will find GreeterServer, the Unimplemented embedding and RegisterGreeterServer, which calls s.RegisterService(&Greeter_ServiceDesc, srv). And Greeter_ServiceDesc, the table that maps method names to handler functions.

There is no magic: the generated client marshals the request, calls conn.Invoke with the method path, and unmarshals the reply. The CallOption variadic argument is how you pass per-call settings such as grpc.WaitForReady(true) or grpc.MaxCallRecvMsgSize(...). Knowing the code is that small makes the library's behaviour easier to reason about when something odd happens.

gRPC or the alternatives

gRPC fits calls between your own services, where a typed contract, efficient encoding and streaming matter and both ends are under your control. It is weaker for browser clients (it needs gRPC-Web and a proxy, or a Connect-compatible server), for public APIs consumed by third parties who want to try them with curl, and for places where HTTP caching helps. GraphQL or REST covers those trade-offs from the other direction.

When you do choose it, adopt the surrounding practices early: a buf configuration for linting and breaking-change checks, deadlines on every call, interceptors for logging and authentication, TLS everywhere outside local development, health checking for load balancers and reflection for tooling. The articles that follow in this series take each of those in turn, starting with how protobuf encodes your messages and setting up generation properly.

Project layout for a real service

A layout that scales past one proto:

Text
proto/
  orders/v1/orders.proto
gen/                      # generated Go, committed or built in CI
  orders/v1/...
cmd/
  ordersd/main.go         # the server binary
internal/
  orders/                 # implementation of the server interface
tools.go                  # pins the generator plugins
buf.yaml / Makefile       # how generation is invoked

Keep the implementation in internal/orders, with the gRPC server type as a thin adapter that converts requests into calls on your domain code and domain errors into status codes. That keeps your business logic testable without a network (testing gRPC services) and keeps you free to expose the same logic over REST or Connect later.

For managing protos across repositories, linting and detecting breaking changes, most teams adopt the buf tool, covered in Buf linting and breaking change detection.

Running it as two processes

The lab keeps client and server in one program so a single command shows everything. A real service splits them. Move main into cmd/greeterd/main.go with a fixed listen address from configuration (:50051 is the conventional port), and write a separate small client, or use a generic tool to call it. grpcurl can call any server that has reflection enabled, without generated code: grpcurl -plaintext localhost:50051 greeter.v1.Greeter/SayHello with a JSON request body.

Two settings are worth adding to the server right away. grpc.MaxRecvMsgSize and grpc.MaxSendMsgSize to state the limits you intend, and a grpc.KeepaliveParams policy so dead connections are noticed (keepalive). On the client side, set a default per-call deadline in one wrapper and a unary interceptor that adds request metadata, so every call in the program follows the same rules (interceptors).

Common first-day problems

protoc-gen-go: program not found or is not executable. protoc cannot find the plugin on the PATH. Build the plugins into a directory and put it on the PATH for the command, as gen.sh does.

Import cycles or the wrong import path. The go_package option decides the package path of the generated code. Make it match your module path and where you write the output.

Unimplemented desc = method ... not implemented. You embedded the unimplemented type and forgot to override the method, or the method signature does not match, so it is not an override. Check the generated interface.

connection refused or hanging calls. NewClient does not connect until the first call, so a wrong address shows up as a failure on that call. Always set a deadline so it fails in seconds, not minutes.

Mismatched generated code and library versions. Keep the generator plugins and the google.golang.org/grpc and google.golang.org/protobuf runtime modules in step, which pinning them in go.mod achieves.

The canonical documentation is the gRPC Go quick start and the Protocol Buffers guide; the Go packages are described at pkg.go.dev/google.golang.org/grpc.

Also inNetworking
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.