Reading the Request Without Getting Burned
Pulling data out of an *http.Request: query params with r.URL.Query, forms with FormValue and the ParseForm it hides, headers, reading the raw body with io.ReadAll and why you close it, and bounding it with http.MaxBytesReader. Compiled and run against Go 1.26.5.
A handler’s whole job is to turn a request into a response, and the request half is a *http.Request with more accessors than you’ll ever need in one handler. This chapter is about getting data out of it cleanly: the query string, form values, headers, and the raw body. The body is where the danger lives, because a request body is an open network stream of unbounded size, and reading it naively is how a service falls over. Everything below was compiled and run against Go 1.26.5.
Query parameters
The query string lives on the parsed URL, and r.URL.Query() gives you a map of its values:
func search(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
limit := r.URL.Query().Get("limit")
missing := r.URL.Query().Get("nope") // absent key -> "", no error
fmt.Fprintf(w, "q=%q limit=%q missing=%q", q, limit, missing)
}
Query() parses the raw query string into a url.Values (a map[string][]string), and .Get(key) returns the first value for a key, or "" if the key is absent. A hit against /search?q=go+http&limit=10:
GET /search -> 200 q="go http" limit="10" missing=""
Note two things. The + in q=go+http decoded to a space, because Query() does URL-decoding for you. And a missing key is an empty string, not an error and not a distinct “not present” signal. If you need to tell “absent” from “present but empty” — ?limit= versus no limit at all — reach past .Get and index the map directly, since r.URL.Query()["limit"] gives you the slice and nil means absent. One caveat worth knowing: Query() re-parses the raw string on every call, so if you’re reading several params, call it once and keep the result.
Forms, and the ParseForm you didn’t call
HTML form posts and query strings share machinery. For a form, the values arrive URL-encoded in the request body under Content-Type: application/x-www-form-urlencoded, and Go reads them for you:
func order(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title") // body OR query string
qty := r.PostFormValue("qty") // body only
ua := r.Header.Get("User-Agent")
fmt.Fprintf(w, "title=%q qty=%q ua=%q", title, qty, ua)
}
The convenient part is that FormValue and PostFormValue call ParseForm for you the first time, so you rarely invoke it directly. The difference between the two matters: FormValue looks in both the POST body and the URL query, while PostFormValue looks only in the body. A form POST to /order:
POST /order -> 200 title="Go in Practice" qty="3" ua="Go-http-client/1.1"
Headers come off r.Header, which is the same Get-returns-first-or-empty shape as query values. r.Header.Get("User-Agent") gave Go-http-client/1.1 here, the default sent by Go’s own HTTP client. Header names are canonicalized, so Get is case-insensitive: r.Header.Get("user-agent") finds the same value.
Two sharp edges on forms. ParseForm reads the body to populate the values, so if you call FormValue you have already consumed the body — a later io.ReadAll(r.Body) gets nothing. Pick one approach per request: forms or raw body, not both. And FormValue swallows any parse error; if malformed input matters to you, call r.ParseForm() yourself and check what it returns.
The raw body
For anything that isn’t a form (JSON, most obviously, which is the next chapter), you read the body yourself. The body is an io.ReadCloser, and io.ReadAll drains it into a byte slice:
data, err := io.ReadAll(r.Body)
Two habits go with this. First, close it: defer r.Body.Close(). The server will close the body for you when the handler returns, so this is belt-and-suspenders rather than strictly required on the server side, but it costs nothing and it is mandatory on the client side (next series), so building the reflex now is worth it. Second, and this is the one that actually matters: io.ReadAll reads until EOF with no size limit. A request body is a network stream you don’t control, so a hostile or buggy client can stream gigabytes and io.ReadAll will faithfully try to hold all of it in memory until your process is OOM-killed. Never io.ReadAll an untrusted body unbounded.
Bounding the body with MaxBytesReader
The fix ships in the standard library. http.MaxBytesReader wraps the body in a reader that refuses to yield more than a set number of bytes, and it does the right thing on both ends: it caps the read, and it signals the client with a 413. Here is a handler capped at a tiny 16 bytes to force the failure:
func raw(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 16) // cap at 16 bytes
defer r.Body.Close()
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body too large: "+err.Error(), http.StatusRequestEntityTooLarge)
return
}
fmt.Fprintf(w, "got %d bytes: %q", len(data), string(data))
}
A small body reads fine; an oversized one fails cleanly:
POST /raw (8B) -> 200 got 8 bytes: "hi there"
POST /raw (100B) -> 413 body too large: http: request body too large
The 8-byte body sailed through. The 100-byte body tripped the cap: io.ReadAll returned an error with the message http: request body too large, and because we passed w into MaxBytesReader, it also arranged for the connection to signal the client rather than hang. We turned that error into a 413 Request Entity Too Large. Pick a real limit for your service — a few megabytes is typical for a JSON API — and wrap every untrusted body. This one line is the difference between a bounded server and a memory-exhaustion DoS.
File uploads
A form that carries a file arrives as multipart/form-data rather than URL-encoded, and it has its own accessor. r.FormFile("field") returns the file, a header with its metadata, and an error:
func upload(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("doc")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
data, _ := io.ReadAll(file)
fmt.Fprintf(w, "filename=%q size=%d", header.Filename, header.Size)
}
Posting a small text file as the doc field:
POST /upload -> 200 filename="notes.txt" size=12 content="hello upload"
The returned file is an io.Reader you can stream, and header.Filename / header.Size come from the multipart envelope. The same size warning as the raw body applies, only more so: an upload endpoint is the single most common way a service gets memory-exhausted, so bound it. r.ParseMultipartForm(maxMemory) (which FormFile calls for you) spills anything over maxMemory to temp files rather than RAM, but pairing the handler with http.MaxBytesReader to cap the whole request is still the real defense.
The request’s context
Every request carries a context.Context, reachable as r.Context(), and it is the part of the request most people forget exists. Its job is cancellation: when the client hangs up or its own deadline passes, the server cancels that context, and any work you started on the request’s behalf can notice and stop. A handler doing slow work should watch it:
select {
case <-time.After(2 * time.Second):
fmt.Fprint(w, "finished work")
case <-r.Context().Done():
return // client went away; abandon the work
}
Point a client with a 200ms timeout at that 2-second handler and the handler sees the abandonment:
handler observed cancellation: context canceled
The client’s Do call returned an error, and the server-side handler’s r.Context().Done() channel closed with context canceled. This is how you avoid burning CPU and database time on a response nobody is waiting for. Pass r.Context() down into every database query and outbound HTTP call you make, and cancellation propagates the whole way. A handler that ignores its context keeps grinding on requests the client gave up on minutes ago.
Checking the method by hand
Once you’re on the 1.22 mux from the last chapter, method matching is part of the route (POST /order) and you rarely test r.Method yourself. But it’s still there when you need it — a single handler that behaves differently per method without registering separate patterns:
switch r.Method {
case http.MethodGet:
// ...
case http.MethodPost:
// ...
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
Use the http.MethodGet constants rather than the bare string "GET"; they’re there to keep a typo from becoming a route that silently never matches.
Final thoughts
Getting data out of a request is a handful of accessors with consistent shapes. r.URL.Query().Get reads query params and returns "" for absent keys, decoding as it goes, but re-parses on every call so cache it. FormValue and PostFormValue read form fields and quietly call ParseForm for you — which consumes the body, so don’t also read it raw. Headers come off r.Header.Get, case-insensitively. And the body is the part to respect: it’s an unbounded network stream, so wrap every untrusted one in http.MaxBytesReader before io.ReadAll, turn the resulting error into a 413, and never trust a client to send a reasonable size. Next we stop reading bytes by hand and let encoding/json turn the body into a struct, and a struct back into a response.
Next: structs in, JSON out — decoding request bodies and encoding responses with encoding/json.
Comments