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
| Feature | REST | gRPC |
|---|---|---|
| Format | JSON (text) | Protobuf (binary) |
| Speed | Good | Faster (2-10x) |
| Streaming | Limited (WebSocket) | Built-in |
| Browser support | Native | Needs proxy |
| Code generation | Optional (OpenAPI) | Built-in |
| Human readable | Yes | No (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 methodsmessage Note— defines data structuresstream 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 typesproto/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 ¬eServer{
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. StreamNotessends 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:
| Code | Meaning | HTTP Equivalent |
|---|---|---|
OK | Success | 200 |
InvalidArgument | Bad request | 400 |
NotFound | Not found | 404 |
AlreadyExists | Conflict | 409 |
PermissionDenied | Forbidden | 403 |
Unauthenticated | Unauthorized | 401 |
Internal | Server error | 500 |
Unavailable | Service down | 503 |
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:
Related Articles
- Go Tutorial #25: Building a Microservice — Complete REST microservice
- Go Tutorial #15: Building HTTP Servers with net/http — HTTP basics
- Go Tutorial #11: Goroutines — Concurrency for understanding streaming
Series Complete!
Congratulations! You have completed the entire Go from Zero to Production series. Here is what you learned:
- Foundations (#1-10) — Variables, functions, structs, interfaces, pointers, project structure
- Concurrency (#11-14) — Goroutines, channels, select/context, error patterns
- Web Development (#15-21) — HTTP servers, Gin, testing, middleware, databases, file I/O, API best practices
- Advanced (#22-24) — Generics, CLI tools with Cobra, Docker
- 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.