How to Create a gRPC Client in Go

Web
Create a gRPC client in Go by generating code from a .proto file using protoc and calling the generated client methods.

When HTTP feels too chatty

You are building a backend service that needs to talk to another backend service. You start with standard HTTP and JSON. It works fine for a few endpoints. Then you add authentication headers, request tracing, and binary file uploads. The JSON payloads grow. The latency creeps up. You realize you are spending more time parsing strings and debugging serialization mismatches than solving the actual business logic. That is where gRPC steps in. It replaces the text-heavy HTTP dance with a binary protocol built on HTTP/2, and it uses Protocol Buffers to define exactly what data moves across the wire.

What gRPC actually does

gRPC stands for gRPC Remote Procedure Calls. The name is a mouthful, but the idea is straightforward. You write a contract in a .proto file that lists the available methods and the exact shape of the request and response messages. A compiler turns that contract into Go code. The generated code handles serialization, network framing, and method routing. Your application code just calls a regular Go function. The network part happens behind the scenes.

Think of it like a standardized shipping container. You do not worry about how the crane lifts it or how the freight company routes it. You pack the box according to the spec, hand it to the dock, and wait for the receipt. Protocol Buffers handle the packing. HTTP/2 handles the routing. The generated Go client handles the handoff.

The compiler step is mandatory. Go does not have a built-in reflection system that can guess your data shapes at runtime. The .proto file is the source of truth. You run the protoc compiler with two Go plugins. One plugin generates the message structs and serialization helpers. The second plugin generates the client and server interfaces. The output is plain Go code that lives alongside your application.

The contract and the compiler

Before you write any Go code, you need the protocol buffer compiler and the Go plugins installed on your machine. The plugins bridge the gap between the generic .proto syntax and Go's type system.

# Install the protobuf Go plugin for message structs
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

# Install the gRPC Go plugin for client and server stubs
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Once the tools are in your GOPATH/bin, you run the compiler against your service definition. The flags tell protoc where to output the files and how to resolve import paths.

# Generate message structs and serialization code
# Generate client and server interfaces for gRPC
# paths=source_relative keeps output in the same directory as the .proto file
protoc --go_out=. --go_opt=paths=source_relative \
  --go-grpc_out=. --go-grpc_opt=paths=source_relative \
  your_service.proto

The compiler produces two files. One contains the pb.YourRequest and pb.YourResponse structs along with Marshal and Unmarshal methods. The other contains the pb.YourServiceClient interface and the concrete implementation that wraps the network connection. You import the generated package like any other Go module. The compiler rejects the program with undefined: pb if you forget to add the generated directory to your go.mod or import path.

Trust the generated code. Do not edit it manually. Run the compiler again whenever the contract changes.

The minimal client

Here is the simplest way to spin up a gRPC client and make a single call. The example assumes you already have a generated pb package from your .proto file.

package main

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	pb "your_project/gen/pb"
)

func main() {
	// Create a connection object. Does not dial immediately.
	// WithTransportCredentials marks the channel as unencrypted.
	conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("failed to create client: %v", err)
	}
	defer conn.Close() // Releases the underlying HTTP/2 connection when main exits.

	// Instantiate the generated client struct.
	// It holds a reference to the connection and the service name.
	c := pb.NewYourServiceClient(conn)

	// Attach a timeout to prevent the goroutine from hanging forever.
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// Call the remote method. Blocks until the server responds or context expires.
	resp, err := c.YourMethod(ctx, &pb.YourRequest{Name: "test"})
	if err != nil {
		log.Fatalf("RPC failed: %v", err)
	}

	log.Printf("Server replied: %s", resp.GetMessage())
}

The code looks like a normal function call, but it is actually crossing a network boundary. The grpc.NewClient call does not open a TCP socket immediately. It creates a connection object that dials lazily on the first request. This saves resources when you create clients but never use them. The context.WithTimeout is mandatory in production code. Network calls will hang if the server crashes or the firewall drops the packet. A timeout guarantees your goroutine eventually wakes up.

How the connection works under the hood

When you call c.YourMethod, the generated client serializes the YourRequest struct into a compact binary format. Protocol Buffers assigns each field a numeric tag and encodes the values using variable-length integers. A string like "hello" takes five bytes plus a length prefix. The binary payload is significantly smaller than an equivalent JSON object.

The gRPC runtime wraps that binary payload in an HTTP/2 frame. HTTP/2 multiplexes multiple requests over a single TCP connection. You can fire ten concurrent RPCs through the same conn object without blocking each other. The runtime assigns each request a stream ID and waits for the corresponding response frame. When the server replies, the runtime deserializes the binary data back into a YourResponse struct and returns it to your Go code.

Error handling follows standard Go conventions. The err return value is never nil on success. If the server returns an error, the runtime converts it into a *status.Status error. You can extract the gRPC status code and details using the status.FromError helper. The compiler rejects the program with cannot use x (type *pb.YourRequest) as type *pb.YourRequest in argument if you accidentally pass the wrong message type. The type system catches shape mismatches before the code ever hits the network.

Context is plumbing. Run it through every long-lived call site.

A realistic service call

Production code rarely stops at a single call. You need to handle retries, extract structured errors, and manage connection lifecycle properly. Here is how a realistic client method looks in a service that fetches user profiles.

package service

import (
	"context"
	"fmt"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	pb "your_project/gen/pb"
)

// FetchUser retrieves a user profile from the remote gRPC service.
func FetchUser(ctx context.Context, conn *grpc.ClientConn, userID string) (*pb.User, error) {
	// Create a client instance scoped to this function.
	// The underlying connection is shared and reused across calls.
	c := pb.NewUserServiceClient(conn)

	// Attach a deadline to the context. The server will cancel if it exceeds this.
	callCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	// Send the request. The generated client handles serialization and HTTP/2 framing.
	resp, err := c.GetUser(callCtx, &pb.GetUserRequest{UserId: userID})
	if err != nil {
		// Unwrap the error to check the gRPC status code.
		st, ok := status.FromError(err)
		if ok && st.Code() == codes.NotFound {
			return nil, fmt.Errorf("user %s not found", userID)
		}
		return nil, fmt.Errorf("fetch user failed: %w", err)
	}

	// Validate the response payload before returning it.
	if resp.User == nil {
		return nil, fmt.Errorf("server returned empty user payload")
	}

	return resp.User, nil
}

Notice the conn parameter. You create the connection once at startup and pass it to every handler or worker. Creating a new grpc.NewClient for every request burns file descriptors and TCP ports. The generated client struct is lightweight. It is just a wrapper around the connection and a service name string. Instantiate it freely. Share the connection carefully.

The status.FromError call extracts the gRPC metadata. Standard Go errors do not carry HTTP-like status codes. The gRPC runtime packs the code and message into a special error type. Checking codes.NotFound lets you distinguish between a missing resource and a network timeout. The fmt.Errorf with %w wraps the original error so higher-level code can still unwrap it later.

Accept interfaces, return structs. The generated client accepts a *grpc.ClientConn and returns concrete message structs. Follow the pattern.

Where things go wrong

gRPC hides a lot of complexity, but the abstraction leaks in predictable ways. The most common mistake is ignoring connection lifecycle. If you call grpc.NewClient inside a request handler and forget defer conn.Close(), you will exhaust the operating system file descriptor limit within minutes. The runtime will panic with too many open files or silently drop connections. Create one connection per target host at startup. Close it on shutdown.

Another trap is using deprecated credentials functions. The old grpc.WithInsecure() function is removed in newer module versions. The modern replacement is grpc.WithTransportCredentials(insecure.NewCredentials()). If you stick with the old import, the compiler complains with undefined: grpc.WithInsecure after you upgrade the google.golang.org/grpc module. Always pin your gRPC version and run go mod tidy.

Context cancellation is the third landmine. If you pass context.Background() without a timeout, a slow server will hold your goroutine hostage. The goroutine stays alive until the server responds or the TCP stack gives up. That could be minutes. Always attach a deadline or timeout. The server respects the context and will abort long-running queries early.

Streaming calls introduce a different class of bugs. Client and server streams require explicit loop handling. If you forget to call stream.CloseSend() on a client stream, the server waits forever for more data. The goroutine leaks. The compiler cannot catch this because the stream interface is dynamic. You have to manage the lifecycle manually.

The worst goroutine bug is the one that never logs.

When to reach for gRPC

Use gRPC when you control both the client and the server and need high-performance internal communication. Use gRPC when you want strict contract enforcement and automatic code generation across multiple languages. Use standard HTTP/REST when you need to expose a public API to third-party developers who expect JSON and familiar status codes. Use WebSockets when you require persistent, bidirectional text streams for real-time chat or live dashboards. Use direct database queries when the data lives in the same process and network overhead is unnecessary.

Where to go next