skip to content

Why does reading req.Header["x-trace-id"] give nil when the client really did send that header?

level: middleimportance: should knowfreq 48%

answer

  1. case-insensitive protocol, case-sensitive map
  2. something rewrites the name first
  3. the methods do it, the index does not
  4. capital after each hyphen, lower-case elsewhere
  5. Id, not ID

basics

~20 s

Indexing an http.Header is an ordinary case-sensitive Go map lookup. net/http stores parsed header names in canonical form, so the key in the map is X-Trace-Id. Read it with r.Header.Get("x-trace-id"), which canonicalises the key for you.

solid answer

~40 s

`http.Header` is a `map[string][]string`, and as `net/http` parses an incoming request it runs every header name through `textproto.CanonicalMIMEHeaderKey` before storing it: the first letter and every letter after a hyphen is upper-cased, the rest lower-cased. So `x-trace-id`, `X-TRACE-ID` and `x-Trace-ID` all land on the single map key `X-Trace-Id`. `Get`, `Values`, `Set`, `Add` and `Del` apply the same rewrite to their argument, which is why they behave case-insensitively; a raw map index does not, so `r.Header["x-trace-id"]` is nil while `r.Header.Get("x-trace-id")` returns the value. The near miss catches people too — `r.Header["X-Trace-ID"]` with a capital ID is not canonical either. HTTP/2 puts names on the wire lower-cased and `net/http` still canonicalises them into the map, so handler code never has to know which protocol delivered the request.

code

go · 5 lines
go
func handle(w http.ResponseWriter, r *http.Request) {
	id := r.Header.Get("x-trace-id") // finds it: key rewritten to X-Trace-Id
	raw := r.Header["x-trace-id"]    // nil: no such key in the map
	_, _ = id, raw
}

go deeper

for a junior

Remember the practical rule before the theory: read a header with Get, never by indexing the map. Get does not care what casing you pass in.

for a middle

Explain the canonicalisation rule precisely — upper-case the first letter and each letter after a hyphen, lower-case the rest — and say which five methods apply it and which map operations do not.

for a senior

Recognise the failure signature in a running system: one hop mints a new identifier while every other hop agrees, and the sender looks perfectly healthy. Say how you would confirm which side is wrong before changing code.

for a principal

Decide how this class of bug stops recurring: a lint rule against indexing the header map, a shared accessor that every service imports, or review convention. Each has a different cost and a different half-life.

### The rule HTTP header names are case-insensitive by definition, but a Go map is not. `net/http` reconciles those two facts by normalising every name to one spelling — the canonical form — before it goes into the `http.Header` map, and by normalising the argument of every accessor method the same way. The canonical form is produced by `textproto.CanonicalMIMEHeaderKey`: upper-case the first byte and every byte immediately following a hyphen, lower-case everything else. `content-type` becomes `Content-Type`; `x-trace-id`, `X-TRACE-ID` and `x-Trace-ID` all become `X-Trace-Id`. One deliberate exception: if the name contains a byte that is not valid in a header name — a space, for instance — the function returns it unchanged rather than mangling it, so such a key is stored verbatim. ### Why the map index fails and the method does not ```go id := r.Header.Get("x-trace-id") // canonicalises the argument: finds it raw := r.Header["x-trace-id"] // plain map read: nil ``` `Get`, `Values`, `Set`, `Add` and `Del` are the five methods that canonicalise. Anything you do with the map directly — indexing, ranging, `len`, `delete` — is raw Go semantics on raw keys. Indexing with a non-canonical spelling returns the zero value for `[]string`, which is `nil`, and indexing element `[0]` of that nil slice panics with an out-of-range error. That panic, in a handler, is a much louder failure than the missing header itself. The near miss is worth calling out on its own. Engineers write `X-Trace-ID` and `X-Request-ID` by reflex, because that is how the names read in a design document. The canonical spelling is `X-Trace-Id` and `X-Request-Id` — `Id`, not `ID`. A direct map index with the wrong one silently finds nothing. ### Which side canonicalises **Inbound.** The header parser canonicalises as it reads. By the time your handler sees `r.Header`, the original casing on the wire is gone; you cannot recover it from the map, and you should not want to. **Outbound.** Whatever you put in the map is what gets written. Go through `Set` or `Add` and the key is canonical, so the wire carries `X-Trace-Id`. Assign into the map yourself and your literal casing is written on an HTTP/1 connection. Because HTTP/1 names are case-insensitive, a well-behaved peer still matches it — but your own `Get` calls in the same process will not, and neither will any middleware that indexes the map. Over HTTP/2 the transport lower-cases names on the wire regardless of what your map holds, since the protocol requires it. ### The consequence for a chain of services This is the classic single-hop break: the sender sets the header correctly, the receiver reads it with a raw map index and the wrong casing, gets nothing, treats the request as the start of a new chain, and mints a fresh identifier. Both processes look healthy in isolation. The give-away is that the identifier changes at exactly one hop and everything downstream of it agrees on the new one. The fix is a one-word rule you can enforce in review: **never index an http.Header map; always use Get or Values.** There is no case where the map index is more correct, and no performance argument for it — the canonicalisation is a cheap scan over a short name, done on the stack for names that are already canonical. ### Multiple values `Get` returns only the first value under the key. `Values` returns all of them, and repeated header lines in the request become separate entries in that slice. Go does not join those into a comma-separated string and does not split a comma-separated line into several values, so a single line carrying a list is one entry that you split yourself. For a correlation header this rarely matters — you want one value and should treat two as suspicious — but for headers each hop appends to, `Values` is the correct accessor.

  • How do you read a header that is legitimately sent more than once?
    `r.Header.Values("X-Forwarded-For")` returns every value stored under the canonical key as a `[]string`, where `Get` returns only the first. Repeated header lines become separate slice entries, but Go neither joins them into one comma-separated string nor splits a single comma-separated line, so a list on one line is a single entry you must split yourself.
  • Does canonicalisation apply to keys you write on an outbound request?
    Yes, when you go through `Set` or `Add` — the key is rewritten before it enters the map, and the header writer emits the keys as stored. If you assign into the map yourself, your literal casing is what goes out on an HTTP/1 connection; the peer still matches it because HTTP/1 names are case-insensitive, but your own `Get` calls will not. HTTP/2 lower-cases names on the wire regardless.
  • Is there any header name that does not get canonicalised?
    One class: a name containing a byte that is not valid in a header name — a space, say — is returned unchanged rather than rewritten, so it is stored exactly as given. In practice you never see this from a parsed inbound request, because such a request would be rejected as malformed; it only arises if code stuffs a malformed key into the map itself.

The map is a filing cabinet whose labels are all rewritten in one house style as documents arrive. The accessor methods rewrite your query into that style before searching; reaching into the drawer yourself does not.

saying these in an interview costs you the question

  • Claims Go treats HTTP header names as case-sensitive
  • Indexes the header map with a lower-case name
  • Writes X-Trace-ID and expects it to match the stored key
  • Blames HTTP/2 lower-casing for the missing header
  • Uses Get and expects every repeated value back