In the previous tutorial, you built a complete microservice with REST. Now let’s learn gRPC — a faster alternative for service-to-service communication.

gRPC is a high-performance RPC framework created by Google. It uses Protocol Buffers (protobuf) for serialization and HTTP/2 for transport. It is faster than REST+JSON, supports streaming, and generates client and server code automatically.

When to Use gRPC vs REST

FeatureRESTgRPC
FormatJSON (text)Protobuf (binary)
SpeedGoodFaster (2-10x)
StreamingLimited (WebSocket)Built-in
Browser supportNativeNeeds proxy
Code generationOptional (OpenAPI)Built-in
Human readableYesNo (binary)

Use REST when:

  • Your API is public-facing (browsers, mobile apps)
  • You need human-readable responses
  • You want simple curl testing

Use gRPC when:

  • Services talk to each other (microservices)
  • You need streaming (real-time data)
  • Performance matters (high throughput)
  • You want strict API contracts

Protocol Buffers

Protocol Buffers (protobuf) is a language for defining data structures and services. You write a .proto file, and a tool generates Go code from it.

Install the tools:

# Install protoc compiler
# macOS
brew install protobuf

# Linux
apt install -y protobuf-compiler

# Install 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

Defining a Service

Create a proto file that defines our notes service:

// proto/notes.proto
syntax = "proto3";

package notes;

option go_package = "github.com/kemalcodes/go-tutorial/proto";

// The Notes service
service NoteService {
  rpc CreateNote(CreateNoteRequest) returns (Note);
  rpc GetNote(GetNoteRequest) returns (Note);
  rpc ListNotes(ListNotesRequest) returns (ListNotesResponse);
  rpc DeleteNote(DeleteNoteRequest) returns (DeleteNoteResponse);
  rpc StreamNotes(ListNotesRequest) returns (stream Note);
}

message Note {
  int32 id = 1;
  string title = 2;
  string content = 3;
  string created_at = 4;
}

message CreateNoteRequest {
  string title = 1;
  string content = 2;
}

message GetNoteRequest {
  int32 id = 1;
}

message ListNotesRequest {
  int32 page = 1;
  int32 limit = 2;
}

message ListNotesResponse {
  repeated Note notes = 1;
  int32 total = 2;
}

message DeleteNoteRequest {
  int32 id = 1;
}

message DeleteNoteResponse {
  bool success = 1;
}

Key parts:

  • service NoteService — defines the RPC methods
  • message Note — defines data structures
  • stream Note — server-side streaming response
  • Field numbers (= 1, = 2) are used for binary encoding, not ordering

Generate Go code:

protoc --go_out=. --go_opt=paths=source_relative \
  --go-grpc_out=. --go-grpc_opt=paths=source_relative \
  proto/notes.proto

This creates two files:

  • proto/notes.pb.go — message types
  • proto/notes_grpc.pb.go — service interface and client

Implementing the Server

package main

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

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"

    pb "github.com/kemalcodes/go-tutorial/proto"
)

type noteServer struct {
    pb.UnimplementedNoteServiceServer
    mu     sync.RWMutex
    notes  map[int32]*pb.Note
    nextID int32
}

func newNoteServer() *noteServer {
    return &noteServer{
        notes:  make(map[int32]*pb.Note),
        nextID: 1,
    }
}

func (s *noteServer) CreateNote(ctx context.Context, req *pb.CreateNoteRequest) (*pb.Note, error) {
    if req.Title == "" {
        return nil, status.Error(codes.InvalidArgument, "title is required")
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    note := &pb.Note{
        Id:        s.nextID,
        Title:     req.Title,
        Content:   req.Content,
        CreatedAt: time.Now().Format(time.RFC3339),
    }

    s.notes[s.nextID] = note
    s.nextID++

    log.Printf("Created note #%d: %s", note.Id, note.Title)
    return note, nil
}

func (s *noteServer) GetNote(ctx context.Context, req *pb.GetNoteRequest) (*pb.Note, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    note, ok := s.notes[req.Id]
    if !ok {
        return nil, status.Errorf(codes.NotFound, "note #%d not found", req.Id)
    }

    return note, nil
}

func (s *noteServer) ListNotes(ctx context.Context, req *pb.ListNotesRequest) (*pb.ListNotesResponse, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    var notes []*pb.Note
    for _, note := range s.notes {
        notes = append(notes, note)
    }

    return &pb.ListNotesResponse{
        Notes: notes,
        Total: int32(len(notes)),
    }, nil
}

func (s *noteServer) DeleteNote(ctx context.Context, req *pb.DeleteNoteRequest) (*pb.DeleteNoteResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.notes[req.Id]; !ok {
        return nil, status.Errorf(codes.NotFound, "note #%d not found", req.Id)
    }

    delete(s.notes, req.Id)
    log.Printf("Deleted note #%d", req.Id)

    return &pb.DeleteNoteResponse{Success: true}, nil
}

func (s *noteServer) StreamNotes(req *pb.ListNotesRequest, stream pb.NoteService_StreamNotesServer) error {
    // Copy notes under lock, then release before streaming
    s.mu.RLock()
    notes := make([]*pb.Note, 0, len(s.notes))
    for _, note := range s.notes {
        notes = append(notes, note)
    }
    s.mu.RUnlock()

    for _, note := range notes {
        if err := stream.Send(note); err != nil {
            return err
        }
        // Simulate delay between items
        time.Sleep(500 * time.Millisecond)
    }

    return nil
}

func main() {
    listener, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("Failed to listen: %v", err)
    }

    server := grpc.NewServer()
    pb.RegisterNoteServiceServer(server, newNoteServer())

    fmt.Println("gRPC server starting on :50051")
    if err := server.Serve(listener); err != nil {
        log.Fatalf("Failed to serve: %v", err)
    }
}

Key points:

  • Embed pb.UnimplementedNoteServiceServer — this makes your server forward-compatible. If you add new methods to the proto file, the server still compiles.
  • Use status.Error(codes.NotFound, ...) for gRPC errors. Each error has a code (like HTTP status codes) and a message.
  • StreamNotes sends notes one by one through a stream.

Implementing the Client

package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "time"

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

    pb "github.com/kemalcodes/go-tutorial/proto"
)

func main() {
    // Connect to the server
    conn, err := grpc.NewClient("localhost:50051",
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        log.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()

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

    // Create notes
    note1, err := client.CreateNote(ctx, &pb.CreateNoteRequest{
        Title:   "Learn gRPC",
        Content: "Start with Protocol Buffers",
    })
    if err != nil {
        log.Fatalf("CreateNote failed: %v", err)
    }
    fmt.Printf("Created: #%d %s\n", note1.Id, note1.Title)

    note2, err := client.CreateNote(ctx, &pb.CreateNoteRequest{
        Title:   "Build microservices",
        Content: "Use gRPC for service communication",
    })
    if err != nil {
        log.Fatalf("CreateNote failed: %v", err)
    }
    fmt.Printf("Created: #%d %s\n", note2.Id, note2.Title)

    // Get a note
    got, err := client.GetNote(ctx, &pb.GetNoteRequest{Id: 1})
    if err != nil {
        log.Fatalf("GetNote failed: %v", err)
    }
    fmt.Printf("Got: #%d %s — %s\n", got.Id, got.Title, got.Content)

    // List all notes
    list, err := client.ListNotes(ctx, &pb.ListNotesRequest{})
    if err != nil {
        log.Fatalf("ListNotes failed: %v", err)
    }
    fmt.Printf("Total notes: %d\n", list.Total)
    for _, n := range list.Notes {
        fmt.Printf("  #%d: %s\n", n.Id, n.Title)
    }

    // Stream notes
    fmt.Println("\nStreaming notes:")
    stream, err := client.StreamNotes(ctx, &pb.ListNotesRequest{})
    if err != nil {
        log.Fatalf("StreamNotes failed: %v", err)
    }

    for {
        note, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            log.Fatalf("Stream error: %v", err)
        }
        fmt.Printf("  Received: #%d %s\n", note.Id, note.Title)
    }

    // Delete a note
    _, err = client.DeleteNote(ctx, &pb.DeleteNoteRequest{Id: 1})
    if err != nil {
        log.Fatalf("DeleteNote failed: %v", err)
    }
    fmt.Println("Deleted note #1")
}

Run the server in one terminal, the client in another:

# Terminal 1
go run server/main.go
# gRPC server starting on :50051

# Terminal 2
go run client/main.go
# Created: #1 Learn gRPC
# Created: #2 Build microservices
# Got: #1 Learn gRPC — Start with Protocol Buffers
# Total notes: 2
#   #1: Learn gRPC
#   #2: Build microservices
#
# Streaming notes:
#   Received: #1 Learn gRPC
#   Received: #2 Build microservices
# Deleted note #1

gRPC Streaming Types

gRPC supports four communication patterns:

1. Unary (Request-Response)

Standard request-response. One request, one response:

rpc GetNote(GetNoteRequest) returns (Note);

2. Server Streaming

Client sends one request, server sends multiple responses:

rpc StreamNotes(ListNotesRequest) returns (stream Note);

Use for: feeds, real-time updates, large result sets.

3. Client Streaming

Client sends multiple messages, server returns one response:

rpc UploadNotes(stream CreateNoteRequest) returns (UploadResponse);

Use for: file uploads, batch operations.

4. Bidirectional Streaming

Both client and server send multiple messages:

rpc Chat(stream ChatMessage) returns (stream ChatMessage);

Use for: real-time chat, live collaboration.

Interceptors (gRPC Middleware)

Interceptors are like HTTP middleware. They run before and after every RPC call:

package main

import (
    "context"
    "log"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// Logging interceptor
func loggingInterceptor(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (any, error) {
    start := time.Now()

    // Call the handler
    resp, err := handler(ctx, req)

    duration := time.Since(start)
    if err != nil {
        log.Printf("[gRPC] %s — ERROR: %v (%s)", info.FullMethod, err, duration)
    } else {
        log.Printf("[gRPC] %s — OK (%s)", info.FullMethod, duration)
    }

    return resp, err
}

// Recovery interceptor (catch panics)
func recoveryInterceptor(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (resp any, err error) {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("[gRPC] Panic in %s: %v", info.FullMethod, r)
            err = status.Error(codes.Internal, "internal server error")
        }
    }()

    return handler(ctx, req)
}

func main() {
    server := grpc.NewServer(
        grpc.ChainUnaryInterceptor(
            loggingInterceptor,
            recoveryInterceptor,
        ),
    )

    // Register services and start server...
}

ChainUnaryInterceptor runs interceptors in order. The logging interceptor wraps every call with timing information.

Error Handling in gRPC

gRPC uses status codes instead of HTTP status codes:

import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// Not found
return nil, status.Error(codes.NotFound, "note not found")

// Invalid input
return nil, status.Error(codes.InvalidArgument, "title is required")

// Permission denied
return nil, status.Error(codes.PermissionDenied, "not authorized")

// Internal error
return nil, status.Error(codes.Internal, "database error")

Common gRPC status codes:

CodeMeaningHTTP Equivalent
OKSuccess200
InvalidArgumentBad request400
NotFoundNot found404
AlreadyExistsConflict409
PermissionDeniedForbidden403
UnauthenticatedUnauthorized401
InternalServer error500
UnavailableService down503

Check errors on the client side:

note, err := client.GetNote(ctx, &pb.GetNoteRequest{Id: 999})
if err != nil {
    st, ok := status.FromError(err)
    if ok {
        fmt.Printf("Code: %s, Message: %s\n", st.Code(), st.Message())
        // Code: NotFound, Message: note #999 not found
    }
}

gRPC-Gateway: REST + gRPC

Want to support both REST and gRPC from the same service? Use gRPC-Gateway. It generates a reverse proxy that translates REST calls to gRPC:

import "google/api/annotations.proto";

service NoteService {
  rpc GetNote(GetNoteRequest) returns (Note) {
    option (google.api.http) = {
      get: "/api/notes/{id}"
    };
  }

  rpc CreateNote(CreateNoteRequest) returns (Note) {
    option (google.api.http) = {
      post: "/api/notes"
      body: "*"
    };
  }
}

This means the same service handles:

  • gRPC calls on port 50051
  • REST calls on port 8080 (via the generated proxy)

This is common in production. Internal services use gRPC for speed. External clients use REST for simplicity.

Common Mistakes

1. Not embedding UnimplementedServer.

Without it, your server breaks when you add new methods to the proto file. Always embed the unimplemented server.

2. Forgetting to handle stream EOF.

When reading from a stream, check for io.EOF to know when the stream ends:

for {
    msg, err := stream.Recv()
    if err == io.EOF {
        break // Stream ended normally
    }
    if err != nil {
        return err // Actual error
    }
    // Process msg
}

3. Not using deadlines.

Always set a timeout on client calls. Without it, a slow server blocks your client forever:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

note, err := client.GetNote(ctx, req)

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-26 Source Code on GitHub

Series Complete!

Congratulations! You have completed the entire Go from Zero to Production series. Here is what you learned:

  1. Foundations (#1-10) — Variables, functions, structs, interfaces, pointers, project structure
  2. Concurrency (#11-14) — Goroutines, channels, select/context, error patterns
  3. Web Development (#15-21) — HTTP servers, Gin, testing, middleware, databases, file I/O, API best practices
  4. Advanced (#22-24) — Generics, CLI tools with Cobra, Docker
  5. Production (#25-26) — Complete microservice, gRPC

Want to learn another language? Check out the Rust Tutorial series for systems programming or the KMP Tutorial for cross-platform mobile development.


This is part 26 of the Go Tutorial series — the final article! Thanks for following along.