gRPC is a high-performance RPC framework that uses Protocol Buffers for serialization and HTTP/2 for transport. Compared to REST, it offers strict interface contracts (the .proto file is the API definition), efficient binary serialization (5–10x smaller than JSON), bidirectional streaming, and automatic code generation for clients and servers in any language.
The tradeoff is complexity: you need the protoc compiler and the Go gRPC plugin, the binary format is not human-readable, and browser support requires a proxy layer. For internal microservice communication, gRPC is often the right choice. For public-facing APIs, REST/JSON is usually more practical.
For comparison see Go REST vs gRPC and for HTTP/2 fundamentals see Go HTTP client and server.
Setup
Install the required tools:
# Protocol Buffer compiler
brew install protobuf # macOS
apt install protobuf-compiler # Ubuntu
# Go plugins
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# gRPC library
go get google.golang.org/grpc
go get google.golang.org/protobuf
Defining a Service
The .proto file is the source of truth for the API. It defines messages (data structures) and service methods (RPCs):
// user/user.proto
syntax = "proto3";
package user.v1;
option go_package = "github.com/example/user/pb/v1";
message User {
string id = 1;
string name = 2;
string email = 3;
}
message GetUserRequest { string id = 1; }
message CreateUserRequest { string name = 1; string email = 2; }
message ListUsersRequest {} // empty request
service UserService {
// Unary RPC: one request, one response
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
// Server-side streaming: one request, stream of responses
rpc ListUsers(ListUsersRequest) returns (stream User);
}
Generate Go code:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
user/user.proto
This generates user.pb.go (message types) and user_grpc.pb.go (server and client interfaces).
Implementing the Server
The generated code includes a UserServiceServer interface. Embed UnimplementedUserServiceServer to get default implementations of any methods you haven’t written yet — this avoids a compilation error when the interface adds new methods:
package main
import (
"context"
"fmt"
"sync"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/example/user/pb/v1"
)
type userServer struct {
pb.UnimplementedUserServiceServer
mu sync.RWMutex
users map[string]*pb.User
}
func newUserServer() *userServer {
return &userServer{users: make(map[string]*pb.User)}
}
Unary RPC
A unary RPC is a normal function call: one request in, one response (or error) out. Use proper gRPC status codes instead of generic errors — clients use these codes to decide how to handle failures:
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "user ID is required")
}
s.mu.RLock()
user, ok := s.users[req.Id]
s.mu.RUnlock()
if !ok {
return nil, status.Errorf(codes.NotFound, "user %q not found", req.Id)
}
return user, nil
}
func (s *userServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
if req.Name == "" {
return nil, status.Error(codes.InvalidArgument, "name is required")
}
if req.Email == "" {
return nil, status.Error(codes.InvalidArgument, "email is required")
}
user := &pb.User{
Id: fmt.Sprintf("usr_%d", time.Now().UnixNano()),
Name: req.Name,
Email: req.Email,
}
s.mu.Lock()
s.users[user.Id] = user
s.mu.Unlock()
return user, nil
}
The codes package maps cleanly to HTTP status codes: codes.NotFound → 404, codes.InvalidArgument → 400, codes.Internal → 500, codes.Unauthenticated → 401. Using the right code lets clients handle errors correctly without parsing error strings.
Server-Side Streaming
A streaming RPC sends multiple responses to one request. The stream.Send method sends each item; returning nil closes the stream successfully, returning an error closes it with a failure status:
func (s *userServer) ListUsers(_ *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
s.mu.RLock()
defer s.mu.RUnlock()
for _, user := range s.users {
// Always check context — client may have disconnected
if err := stream.Context().Err(); err != nil {
return status.FromContextError(err).Err()
}
if err := stream.Send(user); err != nil {
return status.Errorf(codes.Internal, "send failed: %v", err)
}
}
return nil
}
Checking stream.Context().Err() before each Send is important for long-running streams — it prevents your server from doing work for a client that’s already gone.
Starting the Server
import (
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
pb "github.com/example/user/pb/v1"
)
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("listen: %v", err)
}
srv := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
grpc.StreamInterceptor(streamLoggingInterceptor),
)
pb.RegisterUserServiceServer(srv, newUserServer())
// reflection lets tools like grpcurl discover your API at runtime
reflection.Register(srv)
log.Println("gRPC server listening on :50051")
if err := srv.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}
reflection.Register is safe to enable in development and staging — it lets grpcurl and other tools inspect your service definition without the .proto file. Disable it in production if your API is internal.
Interceptors: Cross-Cutting Concerns
Interceptors are gRPC’s equivalent of HTTP middleware. A unary interceptor wraps every non-streaming call:
func loggingInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
slog.Info("rpc",
slog.String("method", info.FullMethod),
slog.Duration("duration", time.Since(start)),
slog.Any("error", err),
)
return resp, err
}
An authentication interceptor rejects unauthenticated calls before they reach the handler:
func authInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
// Extract token from metadata (gRPC equivalent of HTTP headers)
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization token")
}
claims, err := validateToken(strings.TrimPrefix(tokens[0], "Bearer "))
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Store claims in context for handlers to use
ctx = context.WithValue(ctx, userClaimsKey{}, claims)
return handler(ctx, req)
}
Chain multiple interceptors with grpc.ChainUnaryInterceptor:
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
loggingInterceptor,
authInterceptor,
recoveryInterceptor, // recover panics
),
)
The Client
The client connects to the server and calls methods the same way a local function is called:
import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "github.com/example/user/pb/v1"
)
func main() {
// In production, use credentials.NewTLS(...) instead of insecure
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("connect: %v", err)
}
defer conn.Close()
client := pb.NewUserServiceClient(conn)
// Always set a timeout — never call without a deadline
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
user, err := client.CreateUser(ctx, &pb.CreateUserRequest{
Name: "Alice",
Email: "[email protected]",
})
if err != nil {
// Check the gRPC status code
st, _ := status.FromError(err)
log.Fatalf("CreateUser: code=%s msg=%s", st.Code(), st.Message())
}
fmt.Printf("Created: %v\n", user)
}
Consuming a Server Stream
Streaming responses require a loop that calls Recv() until it returns io.EOF:
stream, err := client.ListUsers(ctx, &pb.ListUsersRequest{})
if err != nil {
log.Fatalf("ListUsers: %v", err)
}
for {
user, err := stream.Recv()
if err == io.EOF {
break // stream ended normally
}
if err != nil {
st, _ := status.FromError(err)
log.Fatalf("Recv: code=%s msg=%s", st.Code(), st.Message())
}
fmt.Printf("User: %v\n", user)
}
Error Handling
gRPC errors carry a codes.Code and a message. Clients should check the code, not parse the message string:
_, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "missing"})
if err != nil {
st, ok := status.FromError(err)
if !ok {
log.Println("non-gRPC error:", err)
return
}
switch st.Code() {
case codes.NotFound:
fmt.Println("user not found")
case codes.InvalidArgument:
fmt.Println("bad request:", st.Message())
case codes.DeadlineExceeded:
fmt.Println("request timed out")
default:
fmt.Println("unexpected error:", st.Code(), st.Message())
}
}
Common Mistakes
No deadline on client calls. A call without a deadline blocks indefinitely if the server is slow or unresponsive. Always pass a context with a timeout.
Not checking context in streaming handlers. If the client disconnects mid-stream, stream.Send starts returning errors, but the loop can continue doing expensive work. Check stream.Context().Err() before each send.
Using insecure credentials in production. insecure.NewCredentials() disables TLS — fine for development, a serious security issue in production. Use credentials.NewTLS(&tls.Config{}) with a proper certificate.
Forgetting UnimplementedXxxServer embedding. Without it, adding a method to the .proto causes a compilation error. With it, you get a safe default (returns codes.Unimplemented) for any method you haven’t written yet.
Summary
- Define your API in
.proto, generate Go code withprotoc— the generated interface is the contract - Use proper
codes.Codevalues instatus.Error/status.Errorf— not generic Go errors - Check
stream.Context().Err()in streaming handlers before eachSendto detect disconnected clients - Use
grpc.ChainUnaryInterceptorto compose logging, auth, and recovery interceptors - Always set a deadline (
context.WithTimeout) on client calls — never call without one - Enable
reflection.Registerin development forgrpcurldebugging; disable in production for internal services
Resources
- gRPC Go documentation
- Protocol Buffers Go reference
- google.golang.org/grpc
- grpcurl — command-line gRPC client
- gRPC status codes reference
Comments