Most HTTP services you'll write in Go speak JSON: clients send JSON request bodies, servers respond with JSON payloads, and both sides agree on status codes and error shapes. This chapter wires the pieces together into a small product CRUD API, focusing on the parts that are easy to get wrong: streaming responses, decoding request bodies safely, validating input, and returning consistent error envelopes.
A JSON response needs two things: the right Content-Type header so clients parse it as JSON, and the encoded payload in the body. The idiomatic way to write JSON from a handler is json.NewEncoder(w).Encode(v), not json.Marshal followed by w.Write.
Set headers before writing to the body. Once any byte is written, the status and headers are locked in. The encoder streams: it writes JSON bytes directly into the http.ResponseWriter as it walks the value, so you don't hold the full payload in memory the way Marshal would.
Cost: json.Marshal followed by w.Write allocates the full output as a []byte before sending anything. For a 5 KB product that's harmless; for a 50 MB report it doubles peak memory. The encoder writes incrementally, so the working set stays small.
The encoder also appends a trailing newline, which is friendly for tools like curl and line-based log parsers. If you don't want the newline, switch to Marshal and write the bytes yourself.
The mirror image is reading JSON from a request body. json.NewDecoder(r.Body).Decode(&payload) parses the body straight into a destination. Always check the returned error and respond with 400 Bad Request if the JSON is malformed, because anything else (logging the bytes, retrying, ignoring the error) leaks broken state into your handler.
Decode reads exactly one JSON value from the stream. If the body has extra data after the first value (a second JSON object, garbage bytes), Decode returns nil for the first value and the second call would surface the trailing input. For strict APIs, see the DisallowUnknownFields section below.
The request body is an io.ReadCloser. The net/http package closes it for you when the handler returns, so you don't need to call r.Body.Close() yourself in normal handlers. Calling it doesn't hurt, it's just noise.
A naive decoder will happily read whatever the client sends. If an attacker (or a buggy client) streams a 4 GB request body, your process tries to buffer it. http.MaxBytesReader caps the body before the decoder ever sees it.
MaxBytesReader wraps the body so reads past the limit return an error and also signals the server to close the connection cleanly. The 1<<20 is 1 MiB, which is plenty for a product payload and tiny compared to what a hostile client might try to send.
Cost: Without MaxBytesReader, a single 1 GB POST can pin a full gigabyte of RAM per request. With 100 concurrent uploads that's 100 GB. The wrapper adds essentially zero overhead for legitimate small bodies.
By default, Decode silently ignores JSON fields that don't map to anything in the destination struct. That's lenient and forwards-compatible, but for strict APIs you want clients to fail loudly when they send the wrong shape (typo'd field name, deprecated field, copy-paste from a different endpoint). The decoder has a switch for that.
Now a body like {"name":"Pen","price":1.5,"discount":0.1} returns 400 instead of being quietly accepted. Whether you want this depends on your API contract: public APIs with many client versions usually stay lenient, internal APIs with one or two callers usually go strict.
The full request-handling flow has four checkpoints, and each one can short-circuit with an error:
The handler only reaches the business logic if the body fits, parses, and validates. Anything that fails earlier returns a 400 with a useful message and skips the rest.
http.Error writes a text/plain body, which clashes with the rest of a JSON API. The convention is to return JSON for errors too, using a small consistent shape so clients can parse failures the same way they parse successes.
Error is the human-readable message; Code is a stable machine-readable string like "invalid_json" or "product_not_found" that clients can switch on without parsing English. The omitempty tag drops Code when it's blank so simpler errors don't carry empty fields.
A handler now reports failures with the same shape it uses for success:
The client always parses {"error": "...", "code": "..."} regardless of which endpoint failed or which step blew up.
JSON decoding only checks types and shape. It doesn't know that Price shouldn't be negative or that Name can't be empty. Add a validate method on the request struct and call it after decoding.
The handler calls validate between decoding and the actual business logic:
Keeping validation on the request struct (not the domain Product struct) is a useful split: the request type owns "what the client sent looks reasonable", and the domain type owns "this is a valid product in the system". They drift apart over time as you add fields like CreatedAt that the domain has but clients can't set.
A REST-style CRUD API has a small set of conventional status codes. Following them keeps clients predictable.
| Operation | Success | Failure cases |
|---|---|---|
GET /products | 200 OK with array body | 500 on database failure |
GET /products/{id} | 200 OK with object body | 404 if missing |
POST /products | 201 Created + Location header | 400 on bad JSON or validation fail |
PUT /products/{id} | 200 OK with updated object | 400, 404 |
DELETE /products/{id} | 204 No Content, empty body | 404 if missing |
The two non-obvious ones are 201 and 204. 201 says "I created a new resource", and the convention is to include a Location header pointing at the new resource's URL. 204 says "the request succeeded and there's nothing to send back", so the body must be empty; encoding null or {} is wrong.
Putting it all together: list, get, create, update, delete. The routing uses the Go 1.22 method-and-path patterns.
The shape is consistent: success bodies have the domain object, error bodies have {error, code}, and the status codes tell the client what happened before it even reads the body.
r.PathValue("id") is the Go 1.22 way to read a wildcard segment out of a routed pattern. It's part of the standard net/http story now, so there's no router dependency.
Cost: A handler that calls json.NewEncoder(w) once per request creates one small encoder per call, which is fine. A hot loop that builds an encoder per element inside a single response (for _, p := range items { json.NewEncoder(w).Encode(p) }) is wasteful and confusing; build the encoder once and call Encode repeatedly, or encode the whole slice.
When a list endpoint returns thousands of records, building the full slice in memory and then encoding it doubles the memory cost: once for the Go slice, once for the JSON bytes. The encoder lets you stream the array element by element by writing the brackets yourself.
Each Encode call writes one element and flushes it to the network. Memory stays roughly constant regardless of how long the list gets. In practice, you'd source rows from a *sql.Rows cursor or a channel rather than a pre-built slice, so the upstream is also streaming.
The encoder appends a newline after each Encode, which is fine for the JSON Lines format but produces ugly JSON when you're trying to build one array. If that matters, drop the newline by trimming or use Marshal per element.
Default to the simple Encode(rows) and use streaming only when profiles show the response buffering is the bottleneck.
10 quizzes