How to Handle gRPC Metadata (Headers) in Go

Web
Handle gRPC metadata in Go by retrieving it from the request context using metadata.FromIncomingContext or attaching it with metadata.AppendToOutgoingContext.

The backpack on every RPC call

You are building a microservice that needs to know which user triggered a request. In traditional HTTP, you reach for r.Header.Get("Authorization"). In gRPC, that approach fails. The protocol strips standard HTTP headers and replaces them with a context-aware bag of key-value pairs called metadata. You cannot read headers the old way. You have to pull them from the request context.

gRPC metadata works like a backpack attached to every RPC call. The client packs items into it before sending. The server unpacks it when the call arrives. Under the hood, gRPC translates these metadata entries into HTTP/2 headers, but Go's gRPC library hides that translation. You interact with it through context.Context. The google.golang.org/grpc/metadata package provides the tools to pack and unpack. Metadata keys are always lowercase strings. Values are always slices of strings. This design matches HTTP/2's multi-value header model and keeps the Go API consistent.

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

How metadata actually travels

When you attach metadata on the client, you are not modifying a network packet directly. You are storing a value inside a context.Context. The gRPC client runtime intercepts the outgoing call, extracts that context value, and serializes it into HTTP/2 headers. Pseudo-headers like :method and :path are reserved by the protocol. Your custom keys become regular HTTP/2 headers. The gRPC runtime lowercases every key automatically. It also joins multiple values for the same key with a comma, or sends them as duplicate header lines, depending on the transport.

On the server side, the process reverses. The gRPC runtime deserializes the incoming HTTP/2 headers back into a metadata.MD map. It injects that map into a new context and passes it to your handler. Your code never touches the network layer. You only call metadata.FromIncomingContext(ctx) to retrieve the map. The boolean return value tells you whether the context actually carried metadata. If the client sent nothing, you get an empty map and false.

The Get method returns a []string because HTTP/2 allows duplicate header names. You usually check the slice length and take the first element if you expect a single value. This slice behavior prevents silent truncation when a load balancer or proxy appends extra values.

Minimal example

Here is the simplest client-server flow: attach a trace ID on the client, extract it on the server, and print it.

package main

import (
	"context"
	"fmt"
	"google.golang.org/grpc/metadata"
)

// HandleRequest extracts metadata from the incoming context
func HandleRequest(ctx context.Context) {
	// Pull metadata from context. Returns empty map if none exists.
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		fmt.Println("no metadata attached to this call")
		return
	}
	// Get returns a slice because headers can repeat.
	ids := md.Get("x-request-id")
	fmt.Printf("request ids: %v\n", ids)
}

// SendRequest attaches metadata before making the call
func SendRequest() {
	// Start with a background context and layer metadata on top.
	ctx := metadata.AppendToOutgoingContext(context.Background(), "x-request-id", "abc-123")
	// Pass ctx to your gRPC client call here.
	HandleRequest(ctx)
}

The client creates a context, layers the key-value pair onto it, and passes it downstream. The server pulls it back out. No network code is visible because the gRPC stubs handle serialization. The ok boolean is a safety net. It tells you whether the context actually carried a metadata map or if you are looking at a bare context.

Walking through the runtime

When AppendToOutgoingContext runs, it creates a new context that wraps the parent. It stores a metadata.MD map inside that context using a private key type. The gRPC client interceptor chain sees this context when you call your generated client method. The transport layer extracts the map, iterates over the keys, and writes them as HTTP/2 headers. If you call AppendToOutgoingContext multiple times, the runtime merges the maps. Later calls override earlier values for the same key.

On the server, the transport receives the HTTP/2 headers. It filters out protocol-specific pseudo-headers and copies the rest into a metadata.MD map. It then calls metadata.NewIncomingContext(ctx, md) to attach the map to the incoming context. Your handler receives this enriched context as its first argument. Go convention dictates that context.Context always goes first and is named ctx. Functions that accept a context should respect cancellation and deadlines before doing any work.

The FromIncomingContext function performs a context value lookup. It returns the map and a boolean. If the lookup fails, it returns an empty map and false. This design avoids nil pointer panics. You can safely call md.Get("key") even when ok is false, but checking ok saves you from unnecessary lookups.

Realistic example: authentication interceptor

Real services rarely read metadata inside business logic handlers. They extract it in interceptors. Interceptors run before your handler and can reject unauthorized calls early. Here is a unary server interceptor that validates a bearer token from metadata.

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

// AuthInterceptor validates a bearer token from metadata
func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
	// Extract metadata from the incoming RPC context.
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return nil, status.Error(codes.Unauthenticated, "missing metadata")
	}
	// Retrieve the authorization header value.
	tokens := md.Get("authorization")
	if len(tokens) == 0 {
		return nil, status.Error(codes.Unauthenticated, "missing authorization token")
	}
	// Validate the token and proceed to the actual handler.
	if tokens[0] != "valid-token" {
		return nil, status.Error(codes.PermissionDenied, "invalid token")
	}
	return handler(ctx, req)
}

The interceptor signature is fixed by the grpc.UnaryServerInterceptor type. It receives the context, the request, server info, and a handler function. You extract metadata, check for the token, and return early with a gRPC status error if validation fails. Returning status.Error instead of fmt.Errorf ensures the client receives a proper gRPC status code and message. The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible.

You register this interceptor when building the server:

srv := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor))

The interceptor runs on every unary call. Stream calls require a separate StreamServerInterceptor, but the metadata extraction logic remains identical. You pull the context from the stream server object instead of the handler argument.

Pitfalls and compiler traps

Metadata looks simple until you hit edge cases. The most common mistake is case sensitivity. gRPC lowercases all keys before sending them. If you search for X-Custom-Header on the server, you get an empty slice. Always use lowercase keys. The compiler will not catch this. You will just get silent failures.

Another trap is assuming Get returns a single string. It returns []string. Accessing md.Get("key")[0] without checking the length causes a runtime panic. Check len(tokens) == 0 first. The compiler rejects programs that ignore slice bounds, but it cannot predict runtime values.

Type mismatches trigger verbose compiler messages. If you pass a map directly to AppendToOutgoingContext, the compiler complains with cannot use map[string]string as string value in argument. The function expects alternating key and value strings. If you forget to import the metadata package, you get undefined: metadata. If you import it and never use it, you get imported and not used. Go's strict import rules force you to keep your dependency graph clean.

Context deadlines are another silent killer. Metadata travels with the context. If the client sets a deadline and the network is slow, the context cancels before your handler runs. Always check ctx.Err() before doing expensive work. The worst goroutine bug is the one that never logs. If your handler blocks on a database query after a context deadline, you leak resources. Respect cancellation.

Convention aside: do not pass a *string for metadata values. Strings are already cheap to pass by value. The metadata package handles allocation efficiently. Stick to the provided API.

When to use metadata versus alternatives

Use gRPC metadata when you need to pass cross-cutting data like trace IDs, auth tokens, or routing hints across service boundaries. Use request message fields when the data is part of the business logic and should be versioned alongside your protobuf schema. Use a separate authentication service when token validation requires heavy computation or database lookups that would block the RPC handler. Use plain HTTP headers when you are building a REST API or need to integrate with legacy systems that do not understand gRPC context propagation.

Metadata is not a substitute for well-designed protobuf messages. It is a transport layer convenience. Keep business data in your request structs. Keep operational data in metadata.

Where to go next