skip to content

Why does r.Header.Get("x-request-id") find a header that r.Header["x-request-id"] misses?

level: juniorimportance: must knowfreq 68%

answer

  1. it is only a map underneath
  2. the parser rewrites the field name
  3. one stored spelling for every client spelling
  4. Get canonicalizes, indexing does not
  5. X-Request-Id is the key that is stored

basics

~20 s

Go's http.Header is a plain map whose keys are stored in canonical form, such as X-Request-Id. Header.Get canonicalizes the name you pass before the lookup; indexing the map does not, so a lowercase key finds nothing.

solid answer

~40 s

`http.Header` is just `map[string][]string`. When the server parses a request it rewrites every field name with `textproto.CanonicalMIMEHeaderKey` — the first byte and every byte after a hyphen uppercased, the rest lowercased — so a client's `x-request-id`, `X-REQUEST-ID` and `X-Request-Id` all land under the single key `X-Request-Id`. `Get`, `Set`, `Add`, `Values` and `Del` each run that same canonicalization on the name you hand them, which is what makes them look case-insensitive. Direct map indexing is an ordinary Go map lookup with no canonicalization, so `r.Header["x-request-id"]` returns a nil slice and your check silently fails. Use the methods; if you genuinely need the map, index it with `http.CanonicalHeaderKey(name)`.

code

go · 7 lines
go
func handler(w http.ResponseWriter, r *http.Request) {
	// wire: "x-request-id: abc"
	fmt.Println(r.Header.Get("x-request-id"))            // abc
	fmt.Println(r.Header["x-request-id"] == nil)        // true
	fmt.Println(r.Header["X-Request-Id"])               // [abc]
	fmt.Println(http.CanonicalHeaderKey("x-request-id")) // X-Request-Id
}

go deeper

for a junior

Be ready to say what http.Header actually is, a map of string to a slice of strings, and to reach for r.Header.Get rather than indexing the map with whatever spelling you saw on the wire.

for a middle

Explain the canonicalization rule itself, first byte and every byte after a hyphen uppercased and the rest lowercased, and that Get, Set, Add, Values and Del all apply it to the name you pass.

for a senior

Show how you diagnose a lookup that silently returns empty: dump the request, look at the key the map really holds, and fix the call site rather than adding a second lowercase lookup as a fallback.

for a principal

Own the convention across services. Field names your teams invent live as canonical-form constants in one place, and nobody hand-rolls a case-insensitive scan over the header map.

## http.Header is a map, not a magic container The declaration is `type Header map[string][]string`. There is no hidden case-insensitive lookup, no interning, no ordered list of fields — an `http.Header` is an ordinary Go map, and everything that looks clever about it lives in the handful of methods declared on the type: `Get`, `Values`, `Set`, `Add`, `Del`, `Clone` and `Write`. Because it is a map, `r.Header["x-request-id"]` compiles, runs, and does exactly what a map lookup does: it compares the bytes you gave it against the bytes of the stored keys. Nothing translates spellings for you. ## What the parser stores HTTP field names are case-insensitive on the wire, so a client may send `X-Request-Id`, `x-request-id` or `X-REQUEST-ID` and mean the same field. Go resolves this once, at parse time, by rewriting each name into a **canonical** form before putting it in the map. The function is `textproto.CanonicalMIMEHeaderKey`, re-exported from `net/http` as `http.CanonicalHeaderKey`, and the rule is purely mechanical: - the first byte is uppercased; - every byte immediately following a hyphen is uppercased; - every other byte is lowercased. So `content-type` becomes `Content-Type`, `x-request-id` becomes `X-Request-Id`, and `etag` becomes `Etag` — note the rule knows nothing about conventional capitalisation, it only knows about hyphens. The key stored in the map is always this canonical spelling, never the spelling the client used. One edge: if the name contains a byte that is not valid in an HTTP token — a space, for example — `CanonicalMIMEHeaderKey` returns it unchanged rather than mangling it. You meet that only with names you construct in code, since the request parser rejects malformed field names on the wire. ## Why the methods work and the index does not `Header.Get(name)` canonicalizes `name`, looks the key up, and returns the **first** value, or `""` when the key is absent. `Set`, `Add`, `Values` and `Del` canonicalize the same way. That is the whole trick: the map's keys are canonical and the methods canonicalize their argument, so the two always meet. Write `r.Header["x-request-id"]` and you have opted out of that. The map holds `X-Request-Id`; you asked for `x-request-id`; the lookup misses and yields the zero value of `[]string`, which is `nil`. A `nil` slice has length zero and ranges cleanly, so the bug does not panic — it just makes the header look absent, which is why this shows up as "the header is definitely being sent but my service never sees it" rather than as a crash. ## When indexing the map is legitimate There is one thing `Get` cannot tell you: whether a field is **absent** or **present with an empty value**, since both give `""`. For that you need the comma-ok form, and then you must canonicalize yourself: if v, ok := r.Header[http.CanonicalHeaderKey(name)]; ok { ... } Ranging over the map is also fine and is the usual way to log or forward everything a request carried — you get canonical keys and each field's value slice, in randomised order, so sort the keys if the output must be stable. ## Writing headers has the same rule `w.Header().Set("content-type", "application/json")` is correct: `Set` canonicalizes, the map ends up with `Content-Type`, and that is what goes on the wire. Reaching into `w.Header()` as a map and assigning a lowercase key is how services end up sending two spellings of the same field. ## HTTP/2 changes the wire, not the map HTTP/2 requires lowercase field names on the wire. Go's HTTP/2 server canonicalizes them into `Request.Header` exactly as the HTTP/1 parser does, so handler code that reads `r.Header.Get("Content-Type")` behaves identically over both versions. Never branch on wire casing. ## Diagnosing it When a lookup returns empty and you are sure the header was sent, print the map rather than guessing: `httputil.DumpRequest(r, false)` gives the request's start line and fields as a printable blob, and ranging over `r.Header` shows you the exact key the map holds. Nine times out of ten the key is right there in canonical form and the call site is the thing spelled wrong. Fix the call site — do not add a lowercase fallback lookup, which only doubles the number of spellings the next reader has to consider.

  • What does Go do with a field name it cannot canonicalize, such as one containing a space?
    `textproto.CanonicalMIMEHeaderKey` returns the name unchanged when it holds a byte that is not valid in an HTTP token, so it is stored under exactly the bytes given. `Get` applies the same rule and therefore still agrees with `Set`. In practice you only meet this with headers you build in code, because the request parser rejects malformed field names arriving on the wire.
  • HTTP/2 sends field names lowercase. Does that change what you read from r.Header?
    No. Go's HTTP/2 server canonicalizes incoming names into `Request.Header` just as the HTTP/1 parser does, so `r.Header.Get("Content-Type")` and `r.Header["Content-Type"]` behave identically over both protocol versions. Handler code should never depend on the casing used on the wire.
  • How would you see every field a request actually carried, keys included?
    Range over `r.Header` — it is a map, so you get canonical keys and each field's value slice, though iteration order is randomised, so sort the keys when the output must be stable. `httputil.DumpRequest(r, false)` prints the start line and fields as one blob, which is the quickest way to see the exact key the map holds.

It is a filing cabinet where every folder is relabelled in one house style as it arrives. Ask the clerk and they translate your spelling; reach into the drawer yourself and you must spell it the cabinet's way.

saying these in an interview costs you the question

  • Calls http.Header a case-insensitive map type
  • Indexes r.Header with the client's raw spelling
  • Thinks Go lowercases field names into the map
  • Assumes Header.Get returns a []string
  • Believes HTTP/2 lowercase names change r.Header's keys