Reading Requests, Writing JSON
Here's the mental model to hold for this whole phase, because everything else hangs off it: a handler reads from one thing and writes to another. It reads from *http.Request — the incoming request, with its path, query string, headers, and body. It writes to http.ResponseWriter — the outgoing response, where you set headers, a status code, and the body. That's the entire conversation.
Notice what's not in that sentence: no framework, no "context object," no magic binding layer. *http.Request and http.ResponseWriter are plain standard-library types. JSON isn't special either — it's encoding/json applied to the body on the way in and to the writer on the way out. Once you see a handler as "read from r, write to w, with encoding/json doing the translation on each side," every Go web handler you'll ever read becomes legible.
💡 You already met
r.PathValuein Phase 2. This phase fills in the other three sources of input (query, headers, body) and the full story of writing a response. The running example stays the messages service: aMessageis just{id, text}.
Reading from the request
Let's gather every kind of input a handler typically needs. Imagine a route registered as GET /messages/{id} — we want the path value, a query flag, and an auth header.
What just happened: Three different inputs, three different accessors, all from the standard library. r.PathValue("id") reads the {id} segment the mux captured. r.URL.Query() parses the query string into a map-like value and .Get("verbose") returns the first value (or "" if absent — it never panics on a missing key). r.Header.Get("Authorization") looks up a header case-insensitively, so authorization or AUTHORIZATION resolve the same. Every one of these returns an empty string when the thing isn't there, so you check for "" rather than guarding against a nil.
Decoding a JSON body
For a POST or PUT, the interesting data lives in the request body — a stream of bytes you decode with encoding/json. The idiom is to declare a struct for the shape you expect and decode into it.
What just happened: json.NewDecoder(r.Body) wraps the body stream, and .Decode(&in) reads it and fills the struct — matching JSON keys to fields via the json:"text" tags. The part people skip and then regret: decoding can fail (malformed JSON, a number where a string was expected, an empty body), so you check err and, when it's non-nil, return 400 Bad Request and return immediately. Forgetting that return is a classic bug — without it the handler keeps running on garbage data.
⚠️ By default the decoder silently ignores JSON keys that don't match any struct field. A client typo like
{"txt": "hi"}decodes happily into aMessagewith an emptyTextand no error. If you'd rather reject unknown fields, opt in:json dec if dec; err != nilNow an unexpected key is an error you can catch instead of a confusing empty value later.
Writing JSON back
Writing a response has three moving parts, and — this is the one thing to burn into memory — they happen in a fixed order: set headers, then write the status code, then write the body. Get the order wrong and Go quietly ignores half of what you asked for.
Because you'll do this on every endpoint, write it once as a helper:
What just happened: One function captures the whole ritual. w.Header().Set(...) declares the content type so the client parses the body as JSON. w.WriteHeader(status) sends the status line (e.g. 201 Created). json.NewEncoder(w).Encode(v) serializes v straight to the response stream — no intermediate []byte, no Marshal then Write. From here on, returning JSON is a one-liner: writeJSON(w, http.StatusOK, msg).
⚠️ The #1 net/http gotcha: order matters
This trips up nearly everyone once. The rules, stated plainly:
w.Header().Set(...)must come beforew.WriteHeader(...).w.WriteHeader(...)must come before you write any body.- The first call to
w.Write(whichEncodedoes for you) implicitly sends200 OKif you haven't calledWriteHeaderyet.
That last rule is the trap. Look at this wrong version:
What just happened: The Encode call writes to the body, and because no status was set yet, Go automatically commits 200 OK and flushes the headers. The next line tries to set 400, but the status line already went out the door — so the client receives 200, your 400 is silently dropped, and Go logs a http: superfluous response.WriteHeader call warning to stderr. Nothing crashes; you just get the wrong status and a log line that's easy to miss. The fix is always the same order: header, status, body — exactly what writeJSON enforces.
💡 Mnemonic: headers and status are an envelope, the body is the letter. You can't change the address after the envelope is sealed and mailed.
Status codes, and the no-body case
The status constants live in net/http — use the named ones (http.StatusCreated) over magic numbers (201); they read better and the compiler catches typos. A quick map for the messages service:
200 OK—http.StatusOK, a successful read.201 Created—http.StatusCreated, you just made a resource.400 Bad Request—http.StatusBadRequest, the client sent something invalid.404 Not Found—http.StatusNotFound, no such message.204 No Content—http.StatusNoContent, success with nothing to return (e.g. a delete).
That last one is special: 204 means there is no body. You send the status and stop.
What just happened: For a 204 you call WriteHeader and then write nothing. No Content-Type, no encoder. Writing a body after a 204 contradicts the status (and earns you another superfluous-WriteHeader-style complaint), so resist the urge to be "helpful" with a {"ok": true}. Silence is the correct response.
Validation by hand
Here's a truth that surprises people from framework backgrounds: net/http has no built-in validation. Decoding fills the struct; it does not check that Text is non-empty, or under some length, or anything else. That's your job, in plain Go, right after the decode.
Let's put the whole pipeline together — decode, check, respond — for creating a message:
What just happened: The handler reads top to bottom as the request's life story. Decode into in, bailing with 400 if the JSON is broken. Validate with an ordinary if — strings.TrimSpace(in.Text) == "" rejects empty or whitespace-only text, again with 400 and an immediate return. Only once the input is trustworthy do we build the Message, store it, and reply 201 Created with the new resource as JSON. Notice there's no validation library and no annotations — just if statements you can read and reason about. That directness is the net/http philosophy: nothing happens that you didn't write.
📝 Each guard ends in
return. Skipping it means the handler keeps going and may write a second response — and now you've written headers twice, which produces (you guessed it) the superfluous-WriteHeader warning. "Check, respond, return" is the rhythm.
Recap
- A handler reads from
*http.Request, writes tohttp.ResponseWriter— both plain stdlib, withencoding/jsondoing the translation on each side. - Inputs come from four places: path (
r.PathValue("id")), query (r.URL.Query().Get("q")), headers (r.Header.Get(...), case-insensitive), and the body (json.NewDecoder(r.Body).Decode(&in)). A failed decode means400; addDisallowUnknownFields()to reject typos. - Writing JSON has a fixed order:
Header().Set→WriteHeader(status)→Encode(body). Wrap it in awriteJSONhelper so you never get it wrong. - The first body write implicitly sends
200 OK, so aWriteHeaderafter writing is ignored and logssuperfluous response.WriteHeader call. Order is everything. - Use named status constants;
204 No Contentcarries no body. There's no built-in validation — check fields with ordinaryifstatements and return400, always followed byreturn.
Quick check
[
{
"q": "In writeJSON, what is the correct order of the three calls?",
"choices": [
"WriteHeader, then Header().Set, then Encode",
"Header().Set, then WriteHeader, then Encode",
"Encode, then Header().Set, then WriteHeader",
"Order doesn't matter as long as all three run"
],
"answer": 1,
"explain": "Headers must be set before the status, and the status before the body. The first body write implicitly commits the status, so anything set afterward is ignored."
},
{
"q": "What does Go do when you call w.Write (or Encode) without having called w.WriteHeader first?",
"choices": [
"Returns an error you must handle",
"Panics with a missing-status error",
"Implicitly sends 200 OK before writing the body",
"Buffers the body until you set a status"
],
"answer": 2,
"explain": "The first write implicitly commits 200 OK. That's why a later WriteHeader is ignored and logs a 'superfluous response.WriteHeader call' warning."
},
{
"q": "A client POSTs {\"text\": \" \"} (only spaces). How does net/http reject it as invalid?",
"choices": [
"json.Decode returns an error for blank fields",
"It doesn't — you validate by hand with an if and return 400",
"A required:true struct tag enforces it automatically",
"The mux rejects it before the handler runs"
],
"answer": 1,
"explain": "net/http has no built-in validation. Decoding succeeds with an empty Text; you check it yourself (e.g. strings.TrimSpace == \"\") and return 400."
}
]
← Phase 2: Handlers & Routing by Hand · Guide overview · Phase 4: Middleware Is Just a Wrapper →
Check your understanding
1. In writeJSON, what is the correct order of the three calls?
2. What does Go do when you call w.Write (or Encode) without having called w.WriteHeader first?
3. A client POSTs {"text": " "} (only spaces). How does net/http reject it as invalid?