• English
  • Example: HTTP Client Set Request Headers

    HTTP request headers carry metadata such as authentication, content negotiation, and client identity.

    In Go, request headers are stored in http.Request.Header and should normally be set before calling http.Client.Do.

    http.Header is a map[string][]string. The []string matters because it determines the difference between Set and Add.

    Quick Example

    package main
    
    import (
    	"fmt"
    	"net/http"
    )
    
    func main() {
    	req, err := http.NewRequest(
    		http.MethodGet,
    		"https://example.com/api/users",
    		nil,
    	)
    	if err != nil {
    		panic(err)
    	}
    
    	req.Header.Set("Authorization", "Bearer secret-token")
    	req.Header.Set("Accept", "application/json")
    	req.Header.Set("User-Agent", "my-client/1.0")
    
    	resp, err := http.DefaultClient.Do(req)
    	if err != nil {
    		panic(err)
    	}
    	defer resp.Body.Close()
    
    	fmt.Println(resp.Status)
    }

    panic is used only to simplify the minimal demo. Production code must propagate and handle errors instead.

    Header.Set vs Header.Add

    Set

    Set replaces all existing values for that header.

    req.Header.Set("Accept", "application/json")
    req.Header.Set("Accept", "text/plain")

    The header now has one value:

    Accept: text/plain

    Use Set when you want to establish or replace a value.

    This is the usual choice for headers such as:

    • Authorization
    • Content-Type
    • User-Agent

    Add

    Add appends another value.

    req.Header.Add("Accept", "application/json")
    req.Header.Add("Accept", "text/plain")

    The header now contains two values.

    Whether multiple values are valid is determined by the HTTP semantics of that header, not by the fact that Go's Header type uses a slice.

    Use Add only when multiple values are intentionally supported by the HTTP semantics of that header.

    Common Mistake

    Do not use Add when a value should be replaced:

    // Bad
    req.Header.Add("Authorization", "Bearer "+token)

    If the code runs more than once against the same request, values accumulate.

    Prefer:

    req.Header.Set("Authorization", "Bearer "+token)

    Common Request Headers

    Authorization

    For Bearer-token authentication:

    req.Header.Set("Authorization", "Bearer "+token)

    This value is sensitive; never log raw bearer tokens.

    Accept

    Accept describes the response formats the client is willing to receive:

    req.Header.Set("Accept", "application/json")

    Content-Type

    Content-Type describes the request body:

    body := strings.NewReader(`{"name":"Alice"}`)
    
    req, err := http.NewRequest(
    	http.MethodPost,
    	url,
    	body,
    )
    if err != nil {
    	return err
    }
    
    req.Header.Set("Content-Type", "application/json")

    The distinction is simple:

    Accept        → what response format I want
    Content-Type  → what format my request body uses

    Do not use one in place of the other.

    User-Agent

    Identify the client application:

    req.Header.Set("User-Agent", "my-service/1.0")

    Use a meaningful application identifier when the remote service benefits from knowing what client is making the request.

    Do not pretend to be a browser unless browser emulation is actually required.

    Headers Belong to the Request

    Headers that vary between operations should normally be attached to the http.Request.

    req1.Header.Set("Authorization", "Bearer token-a")
    req2.Header.Set("Authorization", "Bearer token-b")

    This keeps request-specific state local to the request.

    Avoid package-level mutable maps such as:

    var headers = map[string]string{
    	"Authorization": "...",
    }

    Shared mutable header state makes ownership and concurrency harder to reason about and can leak credentials between requests.

    http.Client Has No Built-in Headers Field

    http.Client has no general-purpose Headers field.

    A client can be reused safely across many requests, while each request carries its own headers:

    client := &http.Client{}
    
    req1, _ := http.NewRequest(http.MethodGet, url1, nil)
    req2, _ := http.NewRequest(http.MethodGet, url2, nil)
    
    req1.Header.Set("Authorization", "Bearer token-a")
    req2.Header.Set("Authorization", "Bearer token-b")

    The separation is useful:

    http.Client     → transport, connection pooling, redirects, timeouts
    http.Request    → URL, method, body, headers

    If every request made by a particular client needs the same header policy, a custom RoundTripper can centralize it.

    Shared Header Policy with RoundTripper

    For example, a transport can inject authentication into every request:

    type authTransport struct {
    	base  http.RoundTripper
    	token string
    }
    
    func newAuthTransport(base http.RoundTripper, token string) http.RoundTripper {
    	if base == nil {
    		base = http.DefaultTransport
    	}
    
    	return authTransport{
    		base:  base,
    		token: token,
    	}
    }
    
    func (t authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    	// Clone is available starting in Go 1.18.
    	r := req.Clone(req.Context())
    	r.Header.Set("Authorization", "Bearer "+t.token)
    
    	return t.base.RoundTrip(r)
    }

    Create it with a known base transport:

    client := &http.Client{
    	Transport: newAuthTransport(http.DefaultTransport, token),
    }

    The clone prevents the transport from modifying the caller's original request headers.

    This pattern is useful for genuinely client-wide policy.

    Do not use it simply to avoid writing three Header.Set calls. Per-request headers are usually clearer.

    If the original request already contains an Authorization header, this transport overwrites it. That policy should be intentional.

    Header Names Are Case-Insensitive

    HTTP header field names are case-insensitive. Go canonicalizes header keys internally, so the casing used in a Set call does not create a different header.

    These refer to the same header:

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("content-type", "text/plain")

    The second call replaces the first value.

    When reading a header, use:

    value := req.Header.Get("Content-Type")

    Do not depend on the capitalization used by the remote server.

    Do Not Manually Set Content-Length

    Avoid treating Content-Length like an ordinary application header:

    // Bad
    req.Header.Set("Content-Length", "1234")

    Let net/http determine the request length from the body when possible.

    For bodies whose size is known, http.NewRequest can determine the length automatically:

    body := bytes.NewReader(data)
    
    req, err := http.NewRequest(
    	http.MethodPost,
    	url,
    	body,
    )
    if err != nil {
    	return err
    }

    If you explicitly know the size and need to provide it, use the request field:

    req.ContentLength = size

    Do Not Reuse a Request Concurrently

    A *http.Request represents one HTTP operation.

    Do not send the same request concurrently:

    // Bad
    go client.Do(req)
    go client.Do(req)

    Even if you do not mutate headers, the request body may not be replayable.

    Create a separate request for each operation:

    for _, token := range tokens {
    	req, err := http.NewRequest(http.MethodGet, url, nil)
    	if err != nil {
    		return err
    	}
    
    	req.Header.Set("Authorization", "Bearer "+token)
    
    	resp, err := client.Do(req)
    	if err != nil {
    		return err
    	}
    
    	resp.Body.Close()
    }

    If you need to derive another request from an existing request, Clone (Go 1.18+) creates a separate request object and copies its headers:

    clone := req.Clone(req.Context())
    clone.Header.Set("Authorization", "Bearer "+token)

    A cloned request is separate from the original request, but cloning does not make an arbitrary request body independently reusable.

    For streaming or replayable request bodies, body ownership requires separate consideration.

    Never Log Sensitive Headers

    This is dangerous:

    // Unsafe: may leak credentials.
    log.Printf("request headers: %v", req.Header)

    Headers can contain credentials such as:

    • Authorization
    • Cookie
    • X-API-Key

    If headers must be logged for debugging, clone them and remove sensitive values:

    headers := req.Header.Clone()
    
    headers.Del("Authorization")
    headers.Del("Cookie")
    headers.Del("X-API-Key")
    
    log.Printf("request headers: %v", headers)

    Redaction should be part of the logging policy, not something added after a credential appears in production logs.

    Production Example

    A small API client can keep authentication and common request headers in one place while leaving request construction visible.

    package api
    
    import (
    	"context"
    	"fmt"
    	"io"
    	"net/http"
    )
    
    const maxResponseSize = 1 << 20 // 1 MiB
    
    type Client struct {
    	httpClient *http.Client
    	token      string
    }
    
    func NewClient(httpClient *http.Client, token string) *Client {
    	if httpClient == nil {
    		httpClient = &http.Client{}
    	}
    
    	return &Client{
    		httpClient: httpClient,
    		token:      token,
    	}
    }
    
    func (c *Client) GetUser(ctx context.Context, id string) ([]byte, error) {
    	req, err := http.NewRequestWithContext(
    		ctx,
    		http.MethodGet,
    		"https://example.com/api/users/"+id,
    		nil,
    	)
    	if err != nil {
    		return nil, err
    	}
    
    	req.Header.Set("Accept", "application/json")
    	req.Header.Set("Authorization", "Bearer "+c.token)
    	req.Header.Set("User-Agent", "my-service/1.0")
    
    	resp, err := c.httpClient.Do(req)
    	if err != nil {
    		return nil, err
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    		// Best-effort drain for small error responses.
    		_, _ = io.CopyN(io.Discard, resp.Body, 4<<10)
    
    		return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
    	}
    
    	body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
    	if err != nil {
    		return nil, fmt.Errorf("read response: %w", err)
    	}
    
    	if len(body) > maxResponseSize {
    		return nil, fmt.Errorf(
    			"response exceeds %d bytes",
    			maxResponseSize,
    		)
    	}
    
    	return body, nil
    }

    The important boundary is:

    http.Client     → reusable transport behavior
    http.Request    → per-request state, including headers

    The helper does not need a custom header abstraction just to call Header.Set.

    Common Mistakes

    1. Using Add when you mean Set

    // Bad
    req.Header.Add("Authorization", token)
    
    // Good
    req.Header.Set("Authorization", token)

    2. Keeping per-request credentials in shared mutable state

    Put them on the request, or enforce a deliberate client-wide policy via RoundTripper.

    3. Confusing Accept and Content-Type

    Accept describes the desired response.

    Content-Type describes the request body.

    4. Manually setting Content-Length

    Let net/http manage it, or use Request.ContentLength when you explicitly know the size.

    5. Logging raw headers

    Redact authentication and other secrets first.

    6. Sharing one *http.Request between concurrent operations

    Create a new request for each operation.

    Rule of Thumb

    SituationUse
    One request-specific valuereq.Header.Set
    Multiple values intentionally supportedreq.Header.Add
    Header varies per request*http.Request
    Fixed policy for one clienthttp.RoundTripper
    AuthenticationAuthorization header
    Request body formatContent-Type
    Desired response formatAccept
    Known request lengthreq.ContentLength

    The core rule is simple:

    Request-specific header → Request
    Client-wide policy      → Transport
    One value               → Set
    Multiple valid values   → Add

    Never log secrets. Do not manually write Content-Length. Do not share one Request between concurrent operations.