NATS headers are a way to attach arbitrary metadata to your messages without altering the message payload itself.

Let’s see it in action. Imagine you have a service that processes orders. You want to add a X-Request-ID to trace the request through your system and a X-Tenant-ID to identify which customer’s order it is.

Here’s how you’d send a message with headers using the NATS Go client:

package main

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

	"github.com/nats-io/nats.go"
)

func main() {
	// Connect to NATS
	nc, err := nats.Connect("nats://localhost:4222")
	if err != nil {
		log.Fatalf("Failed to connect to NATS: %v", err)
	}
	defer nc.Close()

	// Create a context with a timeout
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// Define the subject
	subject := "orders.new"

	// Create a message with headers
	msg := nats.NewMsg(subject)
	msg.Header.Set("X-Request-ID", "req-12345abc")
	msg.Header.Set("X-Tenant-ID", "tenant-xyz")
	msg.Data = []byte(`{"order_id": "ORD7890", "item": "widget", "quantity": 2}`)

	// Publish the message
	err = nc.PublishMsg(msg)
	if err != nil {
		log.Fatalf("Failed to publish message: %v", err)
	}

	// Flush to ensure the message is sent
	err = nc.FlushWithContext(ctx)
	if err != nil {
		log.Fatalf("Failed to flush NATS connection: %v", err)
	}

	fmt.Printf("Published message to %s with headers: %+v\n", subject, msg.Header)
}

And here’s how a subscriber would receive and inspect those headers:

package main

import (
	"fmt"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/nats-io/nats.go"
)

func main() {
	// Connect to NATS
	nc, err := nats.Connect("nats://localhost:4222")
	if err != nil {
		log.Fatalf("Failed to connect to NATS: %v", err)
	}
	defer nc.Close()

	// Subscribe to the subject
	subject := "orders.new"
	sub, err := nc.SubscribeSync(subject)
	if err != nil {
		log.Fatalf("Failed to subscribe to %s: %v", subject, err)
	}

	fmt.Printf("Subscribed to %s. Waiting for messages...\n", subject)

	// Channel to listen for OS signals
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

	go func() {
		for {
			msg, err := sub.NextMsg(10 * time.Second) // Timeout to prevent blocking indefinitely
			if err != nil {
				if err == nats.TimeoutError {
					// No message received within the timeout, continue loop
					continue
				}
				log.Printf("Error receiving message: %v", err)
				continue
			}

			fmt.Printf("Received message on subject: %s\n", msg.Subject)
			fmt.Printf("  Headers: %+v\n", msg.Header)
			fmt.Printf("  Data: %s\n", string(msg.Data))
		}
	}()

	// Wait for a termination signal
	<-sigChan
	fmt.Println("\nReceived termination signal. Shutting down.")
}

When you run these two programs, you’ll see the subscriber outputting the headers you set in the publisher. The msg.Header is a nats.Header type, which is essentially a map[string][]string. You can set multiple values for the same header key if needed, although it’s less common.

Headers are invaluable for adding context to your messages. Think of them as the "envelope" information for your NATS messages. They allow you to pass around crucial details like:

  • Trace IDs: For distributed tracing across multiple services.
  • Authentication/Authorization Tokens: To secure your message processing.
  • Tenant IDs: For multi-tenant applications to route or filter messages.
  • Message Types/Versions: To help consumers understand how to process the payload.
  • Timestamps: When a message was generated or should be processed.
  • Correlation IDs: To link request-response pairs or related events.

Crucially, headers are not part of the message payload. This means your core message processing logic, which operates on msg.Data, remains clean and unaware of this contextual metadata. This separation of concerns is a powerful design principle. The NATS server itself doesn’t interpret headers; it simply forwards them along with the message. The intelligence lies entirely with the publishers and subscribers.

The most common way to format header keys is using the X- prefix, following HTTP header conventions, though NATS doesn’t strictly enforce this. NATS headers are case-insensitive for lookup but are preserved in their original casing when sent. When you retrieve headers, msg.Header.Get("x-request-id") will work even if the original was X-Request-ID.

A detail often overlooked is how multiple values for the same header key are handled. When you call msg.Header.Set("Key", "value1") and then msg.Header.Set("Key", "value2"), the Header map will store {"Key": ["value1", "value2"]}. If you just use msg.Header.Get("Key"), you’ll only get the first value, value1. To access all values, you need to use msg.Header.Values("Key"), which returns a []string. This is important if your publisher might legitimately send multiple values for a single header key.

The underlying mechanism for headers is a simple, well-defined string format prepended to the message payload. When a message is published with headers, they are encoded as Header-Key-1: value1\r\nHeader-Key-2: value2\r\n\r\n followed by the actual message data. NATS clients are responsible for encoding and decoding this. This means that even though NATS itself doesn’t "understand" headers, the standard format ensures interoperability between different NATS clients.

Understanding how to correctly retrieve all values for a header, not just the first, is key to robustly handling metadata.

The next evolution in message context often involves using NATS’s JetStream for persistent messaging, where you might want to add metadata to control stream placement or message expiration.

Want structured learning?

Take the full Nats course →