Skip to content
beginner Phase 13 · HTTP Services, JSON, and Middleware

Reading Requests in Handlers

Parse method, path values, query strings, headers, and request bodies correctly.

45m
0 problems
Topic Progress 0%

The URL Is Three Views, Not One String

A handler for GET /orders/8841/items?status=shipped&limit=50 needs four facts: the verb, the order id, the status filter, and the limit. Treating the URL as a string and splitting on punctuation is how off-by-one bugs enter routers. Go hands you each view already parsed.

func handleItems(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		w.Header().Set("Allow", "GET")
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	orderID := r.PathValue("order") // from pattern "GET /orders/{order}/items"

	q, err := url.ParseQuery(r.URL.RawQuery)
	if err != nil {
		http.Error(w, "malformed query string", http.StatusBadRequest)
		return
	}
	status := q.Get("status") // "" when absent — absence is not an error
	limit := 25
	if raw := q.Get("limit"); raw != "" {
		n, err := strconv.Atoi(raw)
		if err != nil || n < 1 || n > 100 {
			http.Error(w, "limit must be 1..100", http.StatusBadRequest)
			return
		}
		limit = n
	}
	// ... query with orderID, status, limit
}

Three distinctions in that code matter more than the code itself.

Missing versus malformed. q.Get("limit") returns "" for both ?status=shipped and ?limit=. That is correct for optional parameters — absence is a state, not a failure (The error Interface). But r.URL.Query() silently discards parse errors: with ?tag=%zz&page=2, an invalid escape in tag is dropped without complaint, so a handler that must reject bad input uses url.ParseQuery(r.URL.RawQuery) and branches on its error.

Decoded versus raw. r.PathValue and r.URL.Path give percent-decoded values, so orders/8%2F42 arrives as orders/8/42 — path segments that contain slashes are one segment, not two. When you need the client's exact bytes (signature verification, for example), r.URL.EscapedPath() preserves them.

Captured versus free-form. Path values are guaranteed present by the pattern that routed the request; query parameters are optional and untrusted. Mixing the two styles — parsing ids out of the query when the id belongs in the path — breaks bookmarkability and every proxy that caches by path.

Notice the method check. If your ServeMux patterns already carry GET , the mux answered 405 before your handler ran and this branch is redundant; keep it only for handlers registered without method patterns. What none of this covers is the second half of the request — headers and body — where the mistakes are quieter.

Headers: Case-Insensitive in Spec, Case-Tricky in Code

A partner integration sends content-type: application/json; charset=utf-8 in lowercase (legal — header names are case-insensitive per RFC 9110). Your check does this:

if r.Header["Content-Type"] == nil {
	http.Error(w, "missing content type", http.StatusUnsupportedMediaType)
	return
}

The request is rejected even though the header exists. The reason: http.Header is a map[string][]string, and direct indexing performs no canonicalization. Header.Get and Header.Set convert the key with textproto.CanonicalMIMEHeaderKey first, which is why they work for any casing. Direct map access only finds keys already stored in canonical form — and you do not control what the server-side parser stored for unusual inputs.

The working pattern:

func requireJSON(r *http.Request) error {
	ct := r.Header.Get("Content-Type")
	mediaType, params, err := mime.ParseMediaType(ct)
	if err != nil {
		return fmt.Errorf("unparseable Content-Type %q: %w", ct, err)
	}
	if mediaType != "application/json" {
		return fmt.Errorf("unsupported media type %s", mediaType)
	}
	return nil // params["charset"] available if you need it
}

mime.ParseMediaType splits application/json; charset=utf-8 into type and parameters, so comparing the whole raw string against "application/json" — a common silent bug — never happens. An empty Content-Type parses to text/plain; charset=utf-8 by spec, which surprises people who assume empty means missing; check ct == "" first when absence should be a different error than wrongness.

Other header habits worth forming:

  • Multi-value headers: r.Header.Values("X-Forwarded-For") returns each occurrence; Get returns only the first. Proxies append rather than merge, so forwarded chains arrive as repeated values or one comma-joined value depending on the sender.
  • Negotiation: compare against Accept with care — */* and q= weights mean a naive equality check rejects valid clients. For a JSON API, checking that json appears in the parsed accept list is enough.
  • Never trust position-based headers: X-Forwarded-For's first element is client-supplied and spoofable unless your edge proxy overwrites it. Identity comes from TLS or your gateway's authenticated header, not from whatever the caller typed.

Request headers are read-side contract; response headers are the mirror image, and both go through the same Header().Get/Set accessor — the map itself is an implementation detail you should not reach into. The body is where reading gets genuinely dangerous.

The Body Is a One-Shot Stream With No Memory

Two bugs share one root cause. The first: a handler logs the body for auditing, then passes r.Body to the JSON decoder, which finds zero bytes and reports EOF. The second: a webhook endpoint calls io.ReadAll(r.Body) with no limit; a hostile client sends a 6 GB body and the process dies on allocation.

The body is a stream that moves in one direction. The first reader consumes it; there is no rewind. And unlike a file, its size is not known in advance — chunked encoding means Content-Length may be absent entirely.

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	const maxBody = 1 << 20 // 1 MiB is plenty for a webhook envelope
	r.Body = http.MaxBytesReader(w, r.Body, maxBody)

	var payload Event
	dec := json.NewDecoder(r.Body)
	if err := dec.Decode(&payload); err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}
	if err := payload.Validate(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	// ... process payload
}

http.MaxBytesReader wraps the stream with a counting limiter. On overflow it returns a *http.MaxBytesError your decoder propagates, and — useful detail — it also tells the server to close the connection after responding, because the client's unread bytes make connection reuse unsafe. 413 is the status that says "your body, not your syntax."

If you genuinely need the raw bytes (HMAC verification over the exact payload), read once into a bounded buffer and decode from memory:

raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
// verify signature over raw, then json.Unmarshal(raw, &payload)

io.LimitReader silently truncates rather than erroring — prefer MaxBytesReader when truncation must be a 413, LimitReader only when a short read is detectable another way.

Who closes the body? On the server side the net/http server drains and closes it after your handler returns; calling Close yourself is permitted but unnecessary. What you must not do is hold it: a handler that stores r.Body for a background goroutine is reading from a stream the server is about to recycle. Copy the bytes first, then hand the copy off.

Reading the body correctly sets up the real decision: what shape should the bytes have? That is JSON encoding and decoding's problem, and decoding is where type mismatches turn into 400s instead of panics.

The Boundary Decides Status Codes, Not the Database

A bug report says the API returns 500s for requests like ?limit=99999999999999999999. The handler passed limit straight into a query builder, an integer overflowed somewhere deep, and the panic surfaced as a generic 500. The database did nothing wrong; the boundary failed to do its job.

A handler boundary has exactly three jobs on the way in: parse bytes into typed values, validate those values against the rules the domain cares about, and convert every failure into the status code that truthfully describes it.

type ItemFilter struct {
	Status string
	Limit  int
}

func parseFilter(r *http.Request) (ItemFilter, error) {
	q, err := url.ParseQuery(r.URL.RawQuery)
	if err != nil {
		return ItemFilter{}, fmt.Errorf("query string: %w", err)
	}
	f := ItemFilter{Status: q.Get("status"), Limit: 25}
	if f.Status != "" && !validStatuses[f.Status] {
		return ItemFilter{}, fmt.Errorf("status %q: %w", f.Status, errInvalidValue)
	}
	if raw := q.Get("limit"); raw != "" {
		f.Limit, err = strconv.Atoi(raw)
		if err != nil || f.Limit < 1 || f.Limit > 100 {
			return ItemFilter{}, fmt.Errorf("limit: %w", errInvalidValue)
		}
	}
	return f, nil
}

func handleList(w http.ResponseWriter, r *http.Request) {
	f, err := parseFilter(r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest) // the client can fix this
		return
	}
	items, err := store.List(r.Context(), f) // already valid; failure is ours
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError) // the client cannot
		return
	}
	writeJSON(w, http.StatusOK, items)
}

The split is the point. Failures the caller can fix (bad limit, unknown status, malformed JSON) are 400-family and may echo what was wrong. Failures the caller cannot fix (connection lost, constraint violated by concurrent writes) are 5xx and must not leak internals — a raw driver error in a response body is both a confusing message and a reconnaissance gift.

One more input runs through every handler: r.Context(). The client's disconnect and any deadline the server set cancel it, and every downstream call — database, HTTP client, queue publish — should receive it so abandoned work stops burning resources (Context and Cancellation). A handler that parses carefully but ignores context trades one class of production incident for another: the pile of work nobody is waiting for.

With parsing, headers, body, and status discipline in place, the remaining question is the shape of the data itself — in and out — which is where struct tags and the encoder earn their keep.

Quiz

1. A handler needs `limit` from `?tag=%zz&limit=20` and must reject malformed input. Which read is correct?

Question 1 options

2. A client sends `content-type: application/json` (lowercase). The handler checks `r.Header["Content-Type"]` and rejects the request. What is wrong?

Question 2 options

3. A handler first logs `io.ReadAll(r.Body)` for auditing, then passes `r.Body` to json.NewDecoder(...).Decode. What happens?

Question 3 options

4. A webhook handler uses io.ReadAll(r.Body) with no limit and the service OOMs when a client sends 6 GB. Which change fixes it while keeping the status code honest?

Question 4 options

Flashcards

Question

When is a missing query parameter an empty string versus an error?

Answer

r.URL.Query().Get returns "" for both absent and empty parameters — treat absence as a default value, not a failure. It is an error only when the raw query string itself is malformed, which requires url.ParseQuery(r.URL.RawQuery) because Query() hides that error.

Question

Why does r.Header.Get("x-api-key") find a header sent as `X-API-Key`, but r.Header["x-api-key"] does not?

Answer

Get canonicalizes the lookup key through textproto.CanonicalMIMEHeaderKey before touching the map, while direct indexing is an exact string match on a map[string][]string. The stored key is canonical form, so lowercase direct lookups miss.

Question

What are the three jobs of a handler boundary, and which status code does each failure deserve?

Answer

Parse bytes into typed values, validate them against domain rules, and map failures to truthful statuses: anything the caller can fix (bad syntax, bad values) is 400/413/415; anything the caller cannot fix (DB down, panic) is 5xx with internals kept out of the body.

Question

Who closes r.Body on the server side, and why must a handler not hand it to a background goroutine?

Answer

The net/http server drains and closes the body after the handler returns, so an explicit Close is optional. A goroutine that reads r.Body after return is racing the server's cleanup — copy the bytes into memory first, then pass the copy off.

Question

What is the difference between http.MaxBytesReader and io.LimitReader for request bodies?

Answer

MaxBytesReader returns a typed *http.MaxBytesError when the cap is exceeded (so you can answer 413) and tells the server to close the connection. LimitReader silently truncates at the cap, which is only acceptable when short reads are detectable another way.

Revision Notes

Key Takeaways

  • 1. URL.Query() hides parse errors — use url.ParseQuery when malformed input must be rejected
  • 2. Header access goes through Get/Set for case-insensitive canonicalization; direct map indexing is exact-match and fragile
  • 3. The request body is a forward-only stream: read it once, bound it with MaxBytesReader, and distinguish 413 from 400 via the typed error
  • 4. The handler boundary owns status codes: fixable client mistakes are 4xx, everything else is 5xx without leaked internals
  • 5. r.Context() carries disconnects and deadlines into every downstream call — ignoring it strands abandoned work

Interview Tips

  • • "How do you parse a query parameter safely?" — ParseQuery on RawQuery for the error, Get for optional values, Atoi plus range checks before use
  • • "Your endpoint OOMs on large uploads — walk me through the fix" — MaxBytesReader, typed error to 413, connection close, and why LimitReader silently truncates
  • • "Why can't I read the body twice?" — forward-only stream; show the read-once-into-buffer pattern when logging and decoding both need the bytes
  • • "Where does validation live in your service?" — at the boundary before the domain layer, with status mapping: 400 for caller-fixable, 5xx otherwise

Cheat Sheet

Reading Requests in Handlers

Where each fact lives:

Need Accessor Failure mode to handle
method r.Method 405 + Allow (or let ServeMux patterns do it)
path capture r.PathValue("id") guaranteed by pattern; still validate format
query url.ParseQuery(r.URL.RawQuery) error = malformed; missing = default
header r.Header.Get("K") canonicalizes case; Values for repeats
content type mime.ParseMediaType(...) never compare the raw string
body MaxBytesReader + one decode *http.MaxBytesError -> 413

Code template:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
	http.Error(w, "bad query", http.StatusBadRequest)
	return
}
var in Payload
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { /* 413 vs 400 */ }

Decision rules: body read once (copy if reused); parse/validate at the boundary, never downstream; 4xx messages may echo client mistakes, 5xx must not echo internals; pass r.Context() to every downstream call.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading