Skip to main content

gRPC in Go: Services, Streaming, and Interceptors

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

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 with protoc — the generated interface is the contract
  • Use proper codes.Code values in status.Error/status.Errorf — not generic Go errors
  • Check stream.Context().Err() in streaming handlers before each Send to detect disconnected clients
  • Use grpc.ChainUnaryInterceptor to compose logging, auth, and recovery interceptors
  • Always set a deadline (context.WithTimeout) on client calls — never call without one
  • Enable reflection.Register in development for grpcurl debugging; disable in production for internal services

Resources

Comments

👍 Was this article helpful?