AlgoMaster Logo

Building JSON APIs

High Priority11 min readUpdated June 6, 2026

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.

Writing a JSON Response

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.

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.

Decoding a JSON Request Body

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.

Limiting Request Body Size

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.

Disallowing Unknown Fields

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.

A Consistent Error Envelope

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.

Validating the Request

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.

Status Codes for the CRUD API

A REST-style CRUD API has a small set of conventional status codes. Following them keeps clients predictable.

OperationSuccessFailure cases
GET /products200 OK with array body500 on database failure
GET /products/{id}200 OK with object body404 if missing
POST /products201 Created + Location header400 on bad JSON or validation fail
PUT /products/{id}200 OK with updated object400, 404
DELETE /products/{id}204 No Content, empty body404 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.

A Full CRUD Product API

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.

Streaming Large JSON Arrays

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.

Quiz

JSON APIs Quiz

10 quizzes