The map behind the headers
You are writing a Go client to talk to a third-party API. The documentation says you need to send an Authorization header with a bearer token. You grab the request object, find the Header field, and try to assign a string directly. The compiler rejects the code with a type mismatch error. Or worse, you use a workaround that compiles, but the header arrives empty on the server. HTTP headers in Go are not a simple string map. They are a map of string slices, and the standard library handles them with specific rules about capitalization, multiple values, and lifecycle.
The http.Header type is defined as map[string][]string. This structure exists because HTTP allows multiple headers with the same name. A server might send two Set-Cookie headers. A client might send multiple Accept values to indicate content type preferences. The slice structure captures this reality. Go also normalizes header names automatically. The HTTP specification treats header names as case-insensitive. Go enforces this by converting every key to "Canonical-Mixed-Case" during storage. You can type content-type, CONTENT-TYPE, or Content-Type, and Go stores them as Content-Type.
Minimal example
Here's the standard way to set headers on a request using the helper methods.
package main
import (
"fmt"
"net/http"
)
func main() {
// Create a GET request to a target URL.
req, err := http.NewRequest("GET", "https://example.com", nil)
if err != nil {
panic(err)
}
// Set replaces any existing values for this key with a single value.
req.Header.Set("Content-Type", "application/json")
// Add appends a value, allowing multiple values for the same key.
req.Header.Add("Accept", "text/html")
req.Header.Add("Accept", "application/json")
// Print the header map to see the canonicalized keys and slice values.
fmt.Println(req.Header)
}
# output:
map[Accept:[text/html application/json] Content-Type:[application/json]]
How the methods work
When you call Set, the library looks up the key. If the key exists, it overwrites the slice with a single-element slice containing your new value. If the key is missing, it creates a new entry. The key gets canonicalized during the lookup. Add works differently. It finds the existing slice for the key and appends your value to the end. If the key doesn't exist, it creates a new slice with one element. This distinction matters when you are building headers like Accept where you want to list multiple content types, versus Authorization where you usually want exactly one token.
Under the hood, http.Header is an alias for textproto.MIMEHeader. This type lives in the textproto package and handles the canonicalization logic. The CanonicalMIMEHeaderKey function converts keys to the standard format. You rarely call this function directly, but understanding it explains why Set works the way it does. The function title-cases the first letter of each hyphen-separated segment. x-custom-header becomes X-Custom-Header. This normalization ensures that lookups are case-insensitive while keeping the stored keys consistent.
Realistic example: middleware
In a server, you often need to set headers on every response. A middleware function is the standard place to do this. Middleware wraps an http.Handler and runs code before or after the handler executes. This pattern keeps header logic centralized and reusable.
The middleware function sets security headers.
package main
import (
"net/http"
)
// SecurityHeaders wraps an http.Handler to add standard security headers.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set headers on the ResponseWriter before calling the next handler.
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
// Call the next handler in the chain.
next.ServeHTTP(w, r)
})
}
The main function wires the middleware to a handler.
package main
import (
"net/http"
)
func main() {
// Create a simple handler that returns JSON.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set content type before writing the body.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Wrap the handler with the security middleware.
secured := SecurityHeaders(handler)
// Start the server on port 8080.
http.ListenAndServe(":8080", secured)
}
The http.ResponseWriter exposes headers via the Header() method. This method returns the http.Header map. You must modify this map before you write any body content. Once you call Write or WriteHeader, the headers are flushed to the client. Changing the map after that point has no effect. The convention is to set all headers at the top of your handler or middleware, then proceed with logic. The http.Handler interface is the standard way to structure HTTP logic. Functions that implement ServeHTTP receive the request and response writer. This interface allows you to compose handlers. Middleware functions return http.Handler, accepting an http.Handler and returning a wrapped http.Handler. This pattern is idiomatic Go.
Pitfalls and runtime behavior
If you try to assign a string directly to the map without using Set or Add, you hit a type error. The map expects a slice of strings. The compiler rejects this with cannot use "value" (untyped string constant) as []string value in assignment. You must use the helper methods or construct a slice manually. Direct map assignment is rare and usually unnecessary.
Another trap is assuming header names are preserved exactly as you type them. If you log the headers and see Content-Type instead of content-type, the library did not break your code. It normalized the key. This can cause confusion if you are debugging and searching for a lowercase key in a dump. The lookup is case-insensitive, so req.Header.Get("content-type") works perfectly fine.
On the server side, calling w.Write before setting headers triggers an implicit WriteHeader(200). If you try to set a header after writing the body, the change is silently ignored. The client never sees the late header. There is no panic. The request just completes without the update. This is a common runtime bug in handlers that do work before configuring the response. Always set headers before any write operation.
When reading headers, Get returns only the first value. If a header has multiple values, Get silently drops the rest. Use Values if you expect multiple entries. Values returns the full slice. If the header is missing, Values returns an empty slice, not nil. This invariant makes range loops safe.
For low-level debugging, Go exposes the GODEBUG environment variable. Setting GODEBUG=http2client=0 disables HTTP/2 for outgoing requests. This forces the client to use HTTP/1.1. This is useful when a server has a broken HTTP/2 implementation and you need to verify the issue. This variable affects the transport layer, not the header map itself. It is a diagnostic tool, not a configuration for production code.
Headers are maps of slices. Canonicalization is automatic.
When to use each method
Use Header.Set when you need a single value for a header and want to replace any existing values.
Use Header.Add when you need to append a value to a header that might already have values, such as multiple Accept types.
Use Header.Del when you need to remove all values for a specific header key.
Use Header.Get when you need to retrieve the first value of a header, returning an empty string if the header is missing.
Use Header.Values when you need to retrieve all values for a header as a slice, handling the case where the header might not exist.
Use direct map assignment only when you are constructing a slice of strings manually, which is rare and usually unnecessary.
Pick the method that matches the cardinality of your header.