68. JSON APIs
Most Go services speak JSON. You already know encoding/json from module 11 —
this lesson is about the handler side: getting JSON out of a ResponseWriter,
getting it back out of a request body, and rejecting bad input politely.
Writing JSON
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price_cents"`
}
func listItems(w http.ResponseWriter, r *http.Request) {
items := []Item{
{ID: 1, Name: "bolt", Price: 250},
{ID: 2, Name: "washer", Price: 75},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}
func main() {
rec := httptest.NewRecorder()
http.HandlerFunc(listItems).ServeHTTP(rec, httptest.NewRequest("GET", "/items", nil))
fmt.Println("status:", rec.Code)
fmt.Println("type: ", rec.Result().Header.Get("Content-Type"))
fmt.Printf("body: %q\n", rec.Body.String())
}
status: 200
type: application/json
body: "[{\"id\":1,\"name\":\"bolt\",\"price_cents\":250},{\"id\":2,\"name\":\"washer\",\"price_cents\":75}]\n"
json.NewEncoder(w).Encode(v) writes straight to the ResponseWriter. Prefer
it over json.Marshal + w.Write: it streams instead of building the whole
document in memory first, which matters once a response is a few megabytes.
The body is printed with %q on purpose, so you can see the thing everyone
trips over: Encode appends a newline. That's harmless over the wire, but
it means a test asserting body == "{...}" fails for a reason that takes an
embarrassingly long time to spot. Compare against the decoded value, or
remember the \n.
Note the Content-Type is set before anything is written — the ordering
rule from lesson 1. Set it after Encode and it never reaches the client, and
browsers will render your JSON as plain text with no error anywhere.
Reading JSON
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type NewItem struct {
Name string `json:"name"`
Price int `json:"price_cents"`
}
func create(w http.ResponseWriter, r *http.Request) {
var in NewItem
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if in.Name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"created": in.Name, "price_cents": in.Price})
}
func main() {
bodies := []string{
`{"name":"bolt","price_cents":250}`,
`{"price_cents":250}`,
`{"name":`,
}
for _, b := range bodies {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/items", strings.NewReader(b))
http.HandlerFunc(create).ServeHTTP(rec, req)
fmt.Printf("%-34s -> %d %q\n", b, rec.Code, rec.Body.String())
}
}
{"name":"bolt","price_cents":250} -> 201 "{\"created\":\"bolt\",\"price_cents\":250}\n"
{"price_cents":250} -> 400 "name is required\n"
{"name": -> 400 "invalid JSON\n"
r.Body is an io.ReadCloser, so json.NewDecoder(r.Body).Decode(&in) is the
mirror image of the write side. You don't need to close it — the server does
that for you.
Three separate outcomes there, and they're worth keeping distinct in your head:
- Valid and complete →
201. - Valid JSON, invalid data — the second body parses fine, it just has no name. Decoding cannot catch this; only your validation can.
- Malformed JSON →
Decodereturns an error.
That second case is the one people forget. Decode leaves missing fields at
their zero value rather than failing, so a required field that's absent
arrives as "" or 0 and looks exactly like one that was sent empty. Every
field you actually require needs an explicit check.
http.Error(w, msg, code) is a shortcut for "set text/plain, write the status,
write the message" — note it adds a newline too.
A consistent error shape
Plain-text errors are fine internally, but an API whose successes are JSON and whose failures are bare strings is annoying to consume. Give errors a shape:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
type APIError struct {
Error string `json:"error"`
Details string `json:"details,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func handler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
writeJSON(w, http.StatusBadRequest, APIError{Error: "missing_id"})
return
}
if id != "1" {
writeJSON(w, http.StatusNotFound, APIError{
Error: "not_found",
Details: "no item with id " + id,
})
return
}
writeJSON(w, http.StatusOK, map[string]string{"id": id, "name": "bolt"})
}
func main() {
for _, u := range []string{"/item", "/item?id=9", "/item?id=1"} {
rec := httptest.NewRecorder()
http.HandlerFunc(handler).ServeHTTP(rec, httptest.NewRequest("GET", u, nil))
fmt.Printf("%-14s %d %s", u, rec.Code, rec.Body.String())
}
}
/item 400 {"error":"missing_id"}
/item?id=9 404 {"error":"not_found","details":"no item with id 9"}
/item?id=1 200 {"id":"1","name":"bolt"}
Two things to steal from this.
writeJSON is worth writing once. Three lines that are easy to get wrong
individually — the header, the status, the encode, in that order — become one
call that's right everywhere. Every Go service accumulates a helper like this;
write it on day one.
Error codes are machine-readable strings, not prose. "not_found" can be
switched on by a client; "Sorry, we couldn't find that item!" cannot. Put the
human-facing wording in details, and note the omitempty keeps it out of the
response entirely when there's nothing to say.
Catching typos in input
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Settings struct {
Theme string `json:"theme"`
}
func strict(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var s Settings
if err := dec.Decode(&s); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
fmt.Fprintf(w, "theme=%s", s.Theme)
}
func main() {
for _, b := range []string{
`{"theme":"dark"}`,
`{"theme":"dark","thmee":"light"}`,
} {
rec := httptest.NewRecorder()
http.HandlerFunc(strict).ServeHTTP(rec, httptest.NewRequest("POST", "/s", strings.NewReader(b)))
fmt.Printf("%-34s -> %d %s\n", b, rec.Code, rec.Body.String())
}
}
{"theme":"dark"} -> 200 theme=dark
{"theme":"dark","thmee":"light"} -> 400 bad request: json: unknown field "thmee"
By default, JSON fields your struct doesn't know about are silently
discarded. A client that misspells theme as thmee gets a cheerful 200
and no theme change, then files a bug that takes an afternoon.
DisallowUnknownFields turns that into a 400 naming the offending field.
The trade-off is real: it makes your API strict about extra fields, which can break older clients that send fields you removed. Good default for internal services and admin endpoints; think twice on a public API.
Which status code
| code | when |
|---|---|
200 OK |
read succeeded |
201 Created |
you made something; set Location to it |
204 No Content |
success, deliberately no body (deletes) |
400 Bad Request |
the client's input is wrong |
401 / 403 |
not authenticated / not allowed |
404 Not Found |
no such resource |
409 Conflict |
it exists already, or the state moved under them |
422 |
valid JSON, invalid values — optional; 400 is fine |
500 |
you broke, and the client can't fix it |
The line that matters: 4xx means they can fix it, 5xx means they can't.
Returning 400 for your own database failure sends the client off debugging
their perfectly good request.
Your turn
Write a signup handler. Decode a body with email and plan. Respond 400
with {"error":"invalid_json"} if it won't parse, 400 with
{"error":"email_required"} if the email is missing, and otherwise 201 with
the decoded value echoed back — defaulting an empty plan to "free".
Every response should be JSON.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Signup struct {
Email string `json:"email"`
Plan string `json:"plan"`
}
func signup(w http.ResponseWriter, r *http.Request) {
// decode into a Signup
// parse failure -> 400 {"error":"invalid_json"}
// empty email -> 400 {"error":"email_required"}
// empty plan -> default it to "free"
// otherwise -> 201, echo the Signup back as JSON
}
func main() {
for _, b := range []string{
`{"email":"ada@example.com","plan":"pro"}`,
`{"email":"grace@example.com"}`,
`{"plan":"pro"}`,
`nonsense`,
} {
rec := httptest.NewRecorder()
http.HandlerFunc(signup).ServeHTTP(rec, httptest.NewRequest("POST", "/signup", strings.NewReader(b)))
fmt.Printf("%d %s", rec.Code, rec.Body.String())
}
}
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Signup struct {
Email string `json:"email"`
Plan string `json:"plan"`
}
func signup(w http.ResponseWriter, r *http.Request) {
var in Signup
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_json"})
return
}
if in.Email == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "email_required"})
return
}
if in.Plan == "" {
in.Plan = "free"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(in)
}
func main() {
for _, b := range []string{
`{"email":"ada@example.com","plan":"pro"}`,
`{"email":"grace@example.com"}`,
`{"plan":"pro"}`,
`nonsense`,
} {
rec := httptest.NewRecorder()
http.HandlerFunc(signup).ServeHTTP(rec, httptest.NewRequest("POST", "/signup", strings.NewReader(b)))
fmt.Printf("%d %s", rec.Code, rec.Body.String())
}
}
Next: the work every handler needs and none of them should contain.