Skip to content
gohacks.dev
APIs

A gRPC Cheat Sheet for Go Servers Clients and Streams

A quick gRPC reference for Go: proto and generation, server and client skeletons, streaming loops, status codes, metadata, deadlines and settings.

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

A reference for the things you look up while writing gRPC in Go. Each section is short, links to the article that explains it, and uses the same names as the examples in the series. Versions: Go 1.25, grpc-go 1.75, protobuf-go 1.36.

Tested with Go 1.25.6, grpc-go v1.75.0, a reference assembled from the runnable examples in this series

How to use this sheet

It is a lookup table for people who already know why. If a section raises a question, the linked article has the experiment behind it. The snippets are condensed: they leave out imports and error handling you would add in real code, and a few use placeholder names (tlsConfig, logging, hs) for things defined elsewhere in a service.

Decisions to write down for every service

A service's gRPC behaviour is a set of choices that should live in one place, not be rediscovered in each handler. These are the ones I write into the service's README before the first deploy.

  1. The deadline policy. What the default client deadline is, what the server enforces if a client sends none, and how handlers derive deadlines for downstream calls.
  2. The error vocabulary. Which codes the service returns for which situations, which domain errors map to them, and which details (BadRequest, ErrorInfo, RetryInfo) clients can rely on.
  3. The retry rules. Which methods are idempotent, which codes are retried, how many attempts, and whether throttling is on.
  4. The authentication model. What identifies a calling service (client certificate name, token audience), what identifies a user, and which methods need which scope.
  5. The shutdown sequence. The drain delay, the graceful-stop budget, and the platform's kill timeout they must fit inside.

Five paragraphs cover them. Most production gRPC incidents are a disagreement between two teams about one of these five, discovered under load.

Mistakes the sheet is designed to prevent

The ones that cost the most are on this page as defaults: a deadline in the client skeleton, both unary and stream interceptor chains in the server skeleton, a connection created once, and a shutdown sequence that tells balancers before it stops listening. If you copy only those four things into a new service, you avoid the most common causes of hangs, open streaming methods, connection storms and dropped calls. The longer list of mistakes, with measurements for three of them, is in gRPC mistakes in Go services.

Schema

proto
syntax = "proto3";
package orders.v1;                        // the version is part of every method path
option go_package = "example.test/gen/orders/v1;ordersv1";

service OrderService {
  rpc Get(GetRequest) returns (Order);                       // unary
  rpc List(ListRequest) returns (stream Order);              // server streaming
  rpc Upload(stream Chunk) returns (Summary);                // client streaming
  rpc Chat(stream Message) returns (stream Message);         // bidirectional
}

Rules: never change or reuse a field number; reserved removed numbers and names; enums start with X_UNSPECIFIED = 0; one request and one response message per rpc; add, do not alter (compatibility, protobuf basics).

Generation

Shell
protoc -I proto \
  --go_out=. --go_opt=paths=source_relative \
  --go-grpc_out=. --go-grpc_opt=paths=source_relative \
  proto/orders/v1/orders.proto

Pin the plugins in go.mod with a tools.go (//go:build tools) and build them into bin/; regenerate in CI and fail on a diff (code generation). Lint and breaking checks: buf lint, buf breaking --against '.git#branch=main' (Buf).

Server

Go
type server struct{ pb.UnimplementedOrderServiceServer }   // always embed

func (s *server) Get(ctx context.Context, r *pb.GetRequest) (*pb.Order, error) {
	if r.GetId() == "" {
		return nil, status.Error(codes.InvalidArgument, "id is required")
	}
	return &pb.Order{Id: r.GetId()}, nil
}

lis, _ := net.Listen("tcp", ":50051")
srv := grpc.NewServer(
	grpc.ChainUnaryInterceptor(logging, recovery, auth),
	grpc.ChainStreamInterceptor(streamLogging, streamRecovery, streamAuth), // both, always
	grpc.Creds(credentials.NewTLS(tlsConfig)),
	grpc.KeepaliveParams(keepalive.ServerParameters{MaxConnectionAge: 30 * time.Minute, MaxConnectionAgeGrace: 30 * time.Second}),
	grpc.MaxRecvMsgSize(8<<20),
)
pb.RegisterOrderServiceServer(srv, &server{})
healthpb.RegisterHealthServer(srv, hs)
srv.Serve(lis)

Use getters (r.GetId()), which are nil-safe. Embed the unimplemented type so new rpcs do not break the build (tutorial).

Client

Go
conn, err := grpc.NewClient("dns:///orders.internal:50051",
	grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
	grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
	grpc.WithChainUnaryInterceptor(withDeadline(5*time.Second), propagateRequestID),
)
defer conn.Close()                         // one per target, for the life of the process
c := pb.NewOrderServiceClient(conn)

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)   // every call has a deadline
defer cancel()
o, err := c.Get(ctx, &pb.GetRequest{Id: "o1"})

NewClient connects lazily and defaults to the dns resolver. Use passthrough:///host:port for a literal address, insecure.NewCredentials() only in tests (load balancing, authentication).

Streaming loops

Go
// server streaming, client side
stream, _ := c.List(ctx, &pb.ListRequest{})
for {
	o, err := stream.Recv()
	if errors.Is(err, io.EOF) { break }          // clean end
	if err != nil { return err }                 // a real failure
	use(o)
}

// server streaming, server side
for _, o := range items {
	if err := stream.Send(o); err != nil { return err }   // client gone or deadline passed
}

// client streaming
up, _ := c.Upload(ctx)
for _, ch := range chunks { up.Send(ch) }
sum, err := up.CloseAndRecv()

// bidirectional: send and receive on separate goroutines, CloseSend() when done sending

When Send returns an error, call Recv for the real status. One goroutine per direction; never two senders (server streaming, bidirectional).

Status codes

SituationCode
Bad input, whatever the stateInvalidArgument
Wrong system state for the callFailedPrecondition
Missing, or hidden from the callerNotFound
Duplicate createAlreadyExists
No or bad credentials / not allowedUnauthenticated / PermissionDenied
Quota or rate limitResourceExhausted
Transient, safe to retryUnavailable
Optimistic concurrency conflictAborted
Time limit passedDeadlineExceeded
BugInternal (generic message)
Go
st := status.New(codes.InvalidArgument, "the request is invalid")
st, _ = st.WithDetails(&errdetails.BadRequest{FieldViolations: violations})
return nil, st.Err()
// client
code := status.Code(err); st := status.Convert(err); details := st.Details()

Map domain errors in one place; unconverted errors reach the client as Unknown with their text (error codes).

Metadata

Go
ctx = metadata.AppendToOutgoingContext(ctx, "x-request-id", id)          // client to server
md, _ := metadata.FromIncomingContext(ctx)                               // in a handler
grpc.SetHeader(ctx, metadata.Pairs("k", "v")); grpc.SetTrailer(ctx, md)  // server to client
c.Get(ctx, req, grpc.Header(&hdr), grpc.Trailer(&trl))                   // client reads them

Keys are lower-case; -bin keys carry binary; grpc- is reserved. Incoming metadata is not forwarded automatically: copy an allow-list (metadata).

Deadlines, retries, keepalive

Go
ctx, cancel := context.WithTimeout(parent, 2*time.Second)       // pass the handler's ctx down: the budget shrinks
JSON
{"methodConfig":[{"name":[{"service":"orders.v1.OrderService"}],
  "retryPolicy":{"maxAttempts":3,"initialBackoff":"0.1s","maxBackoff":"1s","backoffMultiplier":2,
                 "retryableStatusCodes":["UNAVAILABLE"]}}],
 "retryThrottling":{"maxTokens":10,"tokenRatio":0.1}}

Client keepalive Time is at least 10 s and must be at least the server's EnforcementPolicy.MinTime (default 5 minutes) or the server sends too_many_pings. Hedging is not implemented in grpc-go (deadlines, retries, keepalive).

Limits

LimitDefaultOption
Server receive4 MiBgrpc.MaxRecvMsgSize
Client receive4 MiBgrpc.MaxCallRecvMsgSize
Send (both)unlimitedMaxSendMsgSize, MaxCallSendMsgSize
Metadata totalabout 8 KiBgrpc.MaxHeaderListSize

Stream big payloads instead of raising limits (message size).

Shutdown and health

Go
hs.Shutdown()                       // NOT_SERVING: balancers stop sending
time.Sleep(drain)                   // let that propagate
go func() { srv.GracefulStop(); close(done) }()
select { case <-done: case <-time.After(budget): srv.Stop() }

(graceful shutdown, health and reflection).

Testing

Go
lis := bufconn.Listen(1 << 20)                       // in-memory listener
conn, _ := grpc.NewClient("passthrough:///bufnet",
	grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
	grpc.WithTransportCredentials(insecure.NewCredentials()))

Table-test status.Code(err); read streams to the end; check goroutines after stream tests (testing).

Tools

CommandUse
grpcurl -plaintext localhost:50051 listServices on a server with reflection
grpcurl -d '{"id":"o1"}' host:port orders.v1.OrderService/GetCall a method with JSON
buf lint, buf breaking, buf generateSchema checks and generation
GRPC_GO_LOG_SEVERITY_LEVEL=infoConnection and transport logs

The reference documentation is at grpc.io and pkg.go.dev/google.golang.org/grpc, and the protobuf language guide at protobuf.dev.

Also inNetworking
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.