How to Set Headers on an HTTP Request in Go
You're calling a third-party API. The documentation says you need an Authorization header. You construct the request, send it, and the server responds with a 401 Unauthorized. You checked the token. It's correct. The problem isn't the token; it's how you're sending it. HTTP headers are the handshake between client and server, and Go makes setting them explicit. You control the headers through the http.Header map on the request object.
Headers are metadata
Think of an HTTP request like mailing a letter. The body is the letter inside. The headers are the writing on the envelope: the address, the stamp, the "Fragile" sticker, or the "Return to Sender" note. The server reads the envelope before opening the letter. Headers tell the server how to interpret the body, who is sending the request, and what format the client expects back.
In Go, headers live in a http.Header type, which is defined as map[string][]string. This means each header name maps to a list of values. The HTTP specification allows multiple values for a single header name, so the slice handles that. For example, the Accept header can list several MIME types. The slice stores each value separately. When you call Set, Go replaces the entire slice with a new slice containing the single value. When you call Add, Go appends the value to the existing slice. This distinction is crucial. If you call Set multiple times for the same key, only the last value survives. If you call Add multiple times, all values accumulate. The server receives the combined list. Understanding this behavior prevents bugs where you accidentally overwrite a header you meant to extend.
Headers are metadata. Treat them as configuration, not data.
Minimal example
Here's the simplest way to set a header: create the request, call Set, send it.
package main
import (
"fmt"
"net/http"
)
func main() {
// NewRequest builds the request object. The nil body means no payload.
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
// Handle error from malformed URL or method.
panic(err)
}
// Set adds or replaces the header value.
req.Header.Set("X-Custom-Header", "my-value")
// DefaultClient sends the request.
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status)
}
Set replaces. Add appends. Know the difference.
How normalization works
Go normalizes header keys using textproto.CanonicalMIMEHeaderKey. This function converts the key to a standard format where the first letter of each hyphenated segment is capitalized. If you pass content-type, Go stores it as Content-Type. This matters when you iterate over the header map or when you compare keys manually. The HTTP specification treats header names as case-insensitive, but Go enforces a canonical representation to avoid duplicates and simplify lookups. The Set method performs this normalization automatically. You don't need to worry about casing when setting headers, but you should expect the canonical form if you inspect the map later.
If you try to manipulate the map directly without using the helper methods, the compiler catches you. Assigning a string to the map value fails because the map expects a slice. The compiler rejects req.Header["X-Custom"] = "value" with cannot use "value" (untyped string constant) as []string value in assignment. Use Set or Add to keep the types correct.
Realistic example
Here's a realistic POST request with a JSON body, authentication, and a timeout. This pattern appears in production code where you need to send data to an API safely.
// Create the request with a context for timeout control.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// NewRequestWithContext binds the context. The body is a reader for the JSON payload.
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/data", bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
// Set headers. Content-Type must match the body format.
req.Header.Set("Content-Type", "application/json")
// Authorization header provides the token. The server validates this before processing.
req.Header.Set("Authorization", "Bearer token123")
// DefaultClient sends the request and returns the response.
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
// Check the status code. A 200 OK means success.
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
Context is plumbing. Run it through every long-lived call site.
Pitfalls and conventions
The Host header behaves differently. HTTP/1.1 requires a Host header, and Go adds it automatically based on the URL. If you set req.Host, Go uses that value. If you also set the Host header in req.Header, you risk a conflict. The transport layer prefers req.Host. Setting the header manually can lead to subtle bugs where the header sent doesn't match the host the TLS certificate expects. Stick to req.Host for changing the target host. For everything else, use req.Header.Set.
Another trap is Transfer-Encoding. Go manages chunked encoding automatically when the body is a stream. Setting Transfer-Encoding: chunked manually can interfere with the transport logic. Let Go handle transport-level headers unless you have a specific reason to override them.
Go sets User-Agent automatically. The default value identifies the Go version and the runtime. Some services fingerprint the user agent to block bots or track clients. If you override it, be aware that you're changing the identity of your client. The convention is to leave User-Agent alone unless the API requires a specific identifier. If you do change it, set it explicitly with Set rather than appending, so the server sees a clean value.
Error handling follows the standard Go pattern. Check errors immediately after operations that can fail. The if err != nil boilerplate is verbose by design. It makes the unhappy path visible and forces you to decide how to handle failures. Wrap errors with fmt.Errorf and %w to preserve the error chain for debugging.
The compiler catches type mismatches. Runtime panics catch nil pointers. Test the happy path and the error path.
When to use what
Use req.Header.Set when you need to define or replace a single header value. Use req.Header.Add when you want to append a value to an existing header, like adding multiple Accept types. Use req.Header.Del when you need to remove a header that Go added automatically, such as stripping a default User-Agent. Use req.Header.Get when reading headers from a response to check status or metadata. Use http.NewRequestWithContext when the request needs a timeout or cancellation signal. Use http.NewRequest only for simple requests where you don't need context control.
Pick the method that matches the header semantics. Don't fight the map.