Why does r.Header.Get("Host") return an empty string inside a Go HTTP handler?
answer
- not every field stays in the map
- one of them is promoted out
- there is a struct field with that name
- HTTP/2 sends :authority into the same place
- r.URL.Host is empty for an origin-form target
basics
~10 sGo's HTTP server promotes the request's Host field into the Request.Host struct field and deletes it from the header map, so read r.Host. An HTTP/2 request's :authority pseudo-header lands in the same place.
solid answer
~40 sWhen `net/http` reads a server request it moves that one field out of the map: `r.Host` holds it — the host, plus `:port` when the client sent one — and the `Host` key is deleted from `r.Header`, so `r.Header.Get("Host")` is `""`. HTTP/2 carries `:authority` instead, and Go puts that in the same `r.Host`, so handler code is identical across protocol versions. `r.URL` is parsed from the request-target, which for a normal server request is origin-form like `/items/42`, so `r.URL.Host` is empty too and is not an alternative. On the client side the field runs the other way: `Request.Host`, when non-empty, overrides the `Host` header actually sent, and a `Header.Set("Host", ...)` entry on an outgoing request is ignored.
code
go · 5 linesfunc handler(w http.ResponseWriter, r *http.Request) {
fmt.Println(r.Host) // api.example.com:8443
fmt.Println(r.Header.Get("Host")) // "" — deleted from the map
fmt.Println(r.URL.Host) // "" — origin-form target
}go deeper
Remember that the host is a field on the request struct, r.Host, and that looking for it in the header map returns an empty string.
Explain the promotion itself and why it exists: HTTP/1.1 sends a Host line and HTTP/2 sends an :authority pseudo-header, and both end up in the same struct field so handlers need no branch.
Show that r.Host is client-controlled input with an optional port, and handle the parse cases correctly when composing absolute URLs or routing, including the missing-port error from net.SplitHostPort.
Decide where host handling belongs in your stack: which component asserts the canonical external hostname and scheme, so individual services are not each reinventing a rule from an inbound value.
## One field gets promoted Almost every field of an incoming request lands in `r.Header` under its canonical key. `Host` is the exception. Go's server parser reads it, assigns it to the `Host` field of the `http.Request` struct, and deletes the key from the header map. So inside a handler: - `r.Host` → `"api.example.com:8443"` - `r.Header.Get("Host")` → `""` The reason is that the host is not really request metadata like the rest; it identifies which virtual host the request is addressed to, and HTTP/1.1 and HTTP/2 carry it in structurally different ways. Promoting it to a struct field gives handler code one place to look regardless. ## HTTP/2 and the pseudo-header HTTP/2 does not send a `Host` line. It sends the `:authority` pseudo-header as part of the header block, and pseudo-headers never appear in `r.Header`. Go's HTTP/2 server puts `:authority` into `r.Host`, which is precisely why `r.Host` is the portable answer: the same handler serves HTTP/1.1 and HTTP/2 without a branch. ## Why r.URL.Host is not the answer either `r.URL` is parsed from the request-target on the start line. For an ordinary server request that target is **origin-form** — `GET /items/42?page=2 HTTP/1.1` — which carries no scheme and no host, so `r.URL.Scheme` and `r.URL.Host` are both empty and only `Path` and `RawQuery` are filled. (Two exceptions exist: a request sent to a **forward proxy** uses absolute-form and does populate `r.URL.Host`, and a `CONNECT` request carries an authority-form target. Neither is what a normal service receives.) The practical consequence is that building an absolute URL in a handler means composing `r.Host` with a scheme you determine some other way — `r.TLS != nil` for a directly-terminated connection, or whatever your edge tells you when it terminates TLS for you. ## What r.Host actually contains Whatever the client put on the wire, normalised only lightly: - it may or may not carry a port: `api.example.com` or `api.example.com:8443`; - it may be an IP literal, including a bracketed IPv6 literal such as `[2001:db8::1]:8443`; - it is entirely client-controlled, so it is input, not a fact about your deployment. Because the port is optional, `net.SplitHostPort` on `r.Host` **errors** when there is no port (`missing port in address`). If you only want the hostname, either handle that error or use `strings.Cut` on the last colon after checking for the bracketed form. ## The client side runs the other way On an outgoing `*http.Request` the same field is an override rather than a readout. `Request.Write` uses `Request.Host` if it is non-empty, and otherwise falls back to `Request.URL.Host`. An entry you place in `Request.Header` under `Host` is ignored — which is exactly the trap when someone tries `req.Header.Set("Host", "internal.svc")` to reach a virtual host by IP. Set `req.Host` instead. ## Routing on the host `http.ServeMux` patterns may be host-qualified: register `"api.example.com/items/"` and the mux will match only requests whose host matches, preferring a host-qualified pattern over a host-less one. That is the built-in way to serve several hostnames from one server without switching on `r.Host` by hand in every handler. ## The diagnostic When someone insists the host is missing, dump the request: `httputil.DumpRequest(r, false)` reconstructs the start line and fields, and you will see the `Host` line rendered from `r.Host` even though the map no longer holds the key. That reconstruction is a good reminder of the shape: the value exists, it simply does not live where the rest of the fields live.
- How do you serve several hostnames from a single http.ServeMux?Register host-qualified patterns: `mux.Handle("api.example.com/items/", h)` matches only that host, and the mux prefers a host-qualified pattern over an equivalent host-less one. The hand-rolled alternative is a single handler that switches on `r.Host`, which is fine for two hosts and unpleasant beyond that.
- What can r.Host contain besides a bare hostname?Whatever the client sent: a name with `:port`, an IPv4 literal, or a bracketed IPv6 literal such as `[2001:db8::1]:8443`. Since the port is optional, `net.SplitHostPort` returns a missing-port error when it is absent, so either handle that error or split only after confirming a port is present.
- On an outgoing client request, how do you send a Host header that differs from the URL's host?Set `req.Host`. `Request.Write` uses it when non-empty and falls back to `req.URL.Host` otherwise. Putting a `Host` entry in `req.Header` does nothing, because the transport writes the host line from the struct field, not from the map.
saying these in an interview costs you the question
- Reads the host out of r.Header on a server request
- Expects r.URL.Host to be filled for an origin-form request
- Sets a Host header with Header.Set on a client request
- Assumes r.Host always carries a port
- Thinks HTTP/2 requests carry no host information