68. JSON APIs

📖 Reading · 13 min
💡 Every code box below is live — edit it and hit Run.

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:

  1. Valid and complete201.
  2. Valid JSON, invalid data — the second body parses fine, it just has no name. Decoding cannot catch this; only your validation can.
  3. Malformed JSONDecode returns 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.