{} The Go Reference

Apis · Web · Intermediate

Building REST APIs

JSON over HTTP done right — resources and methods, idempotency, decoding/validating requests and encoding responses, the status codes that matter, consistent error envelopes, and versioning.

Apis Intermediate ⏱ 4 min read Complete

🗂️ Analogy

A REST API treats your data as resources at stable addresses — /todos, /todos/42 — and uses the HTTP verb to say what to do: GET to read, POST to create, PUT/PATCH to update, DELETE to remove. It’s the filing cabinet metaphor: the URL names the drawer and folder, the method is the action.

Resources, methods, status codes

graph TD
A["GET /todos"] --> R1["200 · list"]
B["POST /todos"] --> R2["201 · created"]
C["GET /todos/{id}"] --> R3["200 · one  /  404 · missing"]
D["DELETE /todos/{id}"] --> R4["204 · gone"]

Map each method to the right status: 2xx success (200 OK, 201 Created, 204 No Content), 4xx the client’s fault (400 Bad Request, 401/403 auth, 404 Not Found, 409 Conflict, 422 Unprocessable), 5xx the server’s fault (500). Returning the correct code is half of a good API.

MethodPurposeIdempotent?Success
GETreadyes (safe)200
POSTcreateno201 (+ Location)
PUTreplaceyes200 / 204
PATCHpartial updateno200
DELETEremoveyes204

Idempotency matters for retries: a client or proxy may safely re-send a GET/PUT/DELETE, but re-sending a POST can create duplicates.

Reading a resource

Decode JSON in, encode JSON out, pick the status — driven in-process so it runs anywhere:

rest.go — editable & runnable
package main

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)

type Todo struct {
ID    int    "json:\"id\""
Title string "json:\"title\""
Done  bool   "json:\"done\""
}

func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}

func main() {
store := map[int]Todo{1: {ID: 1, Title: "write Go", Done: false}}

mux := http.NewServeMux()
mux.HandleFunc("GET /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
	var id int
	fmt.Sscanf(r.PathValue("id"), "%d", &id)
	t, ok := store[id]
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
		return
	}
	writeJSON(w, http.StatusOK, t)
})

// hit /todos/1 in-process
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/todos/1", nil))
fmt.Println("GET /todos/1 ->", rec.Code)
fmt.Print(rec.Body.String()) // {"id":1,"title":"write Go","done":false}

// a miss returns a JSON 404
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, httptest.NewRequest("GET", "/todos/9", nil))
fmt.Println("GET /todos/9 ->", rec2.Code)
fmt.Print(rec2.Body.String()) // {"error":"not found"}
}

Creating a resource

A POST handler is the mirror image: decode, validate, store, 201. Validation is non-optional — Decode happily accepts partial or unexpected JSON, so check the result and return 422 on bad input. Set a Location header pointing at the new resource:

create.go — editable & runnable
package main

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)

type Todo struct {
ID    int    "json:\"id\""
Title string "json:\"title\""
}

func main() {
nextID := 2
mux := http.NewServeMux()
mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
	var in Todo
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "{\"error\":\"bad json\"}", http.StatusBadRequest)
		return
	}
	if strings.TrimSpace(in.Title) == "" { // validate!
		http.Error(w, "{\"error\":\"title required\"}", http.StatusUnprocessableEntity)
		return
	}
	in.ID = nextID
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Location", fmt.Sprintf("/todos/%d", in.ID))
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(in)
})

// valid create → 201 + Location
rec := httptest.NewRecorder()
body := strings.NewReader("{\"title\":\"ship it\"}")
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/todos", body))
fmt.Println("status:  ", rec.Code)                       // 201
fmt.Println("location:", rec.Header().Get("Location"))   // /todos/2
fmt.Print("body:    ", rec.Body.String())                // {"id":2,"title":"ship it"}

// empty title → 422
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, httptest.NewRequest("POST", "/todos", strings.NewReader("{}")))
fmt.Println("\nempty title ->", rec2.Code)               // 422
}

A consistent error envelope

Pick one error shape and use it everywhere, so clients decode responses uniformly:

type APIError struct {
	Error   string `json:"error"`           // machine-stable code or message
	Detail  string `json:"detail,omitempty"`
}
func writeErr(w http.ResponseWriter, code int, msg string) {
	writeJSON(w, code, APIError{Error: msg})
}

Return it with the matching status (writeErr(w, 404, "todo not found")), and never leak internal details — see web security.

Reference

ConcernChoice
Routemux.HandleFunc("GET /todos/{id}", h)
Decode bodyjson.NewDecoder(r.Body).Decode(&v) + validate
Encode responsejson.NewEncoder(w).Encode(v) with Content-Type
Cap bodyhttp.MaxBytesReader(w, r.Body, n)
Reject unknown fieldsdec.DisallowUnknownFields()
Created201 + Location header
Client error400 / 404 / 409 / 422
Wrong method405 (mux automatic)
Versioningprefix routes (/v1/...)

⚠️ Validate input, limit the body, version your API

Decoding succeeds on partial or unexpected JSON, so always validate the decoded value before trusting it. Cap request bodies with http.MaxBytesReader so a giant payload can’t exhaust memory, and consider decoder.DisallowUnknownFields() for strict APIs. Put cross-cutting concerns (auth, logging, recovery) in middleware, keep handlers thin, and version your routes (/v1/...) from day one — it’s far easier than migrating clients later.

See also

Next: typed, high-performance APIs with gRPC.

Check your understanding

Score: 0 / 5

1. Which status code should a successful resource creation (POST) return?

200 OK is fine for reads and updates; a successful POST that creates a resource should return 201 Created. 204 No Content suits a successful DELETE; 400/404/409/422 cover client errors; 500 is a server fault.

2. How do you decode a JSON request body into a struct in a handler?

Stream the body straight into your struct with json.NewDecoder(r.Body).Decode(&v). Always check the error (malformed JSON, wrong types) and validate the result before using it.

3. Why return errors as JSON instead of plain text from a JSON API?

A predictable error envelope like {"error":"..."} lets every client decode responses the same way. Set Content-Type: application/json and an accurate status code on errors too.

4. What does it mean that PUT and DELETE are idempotent but POST is not?

PUT /todos/42 (full replace) and DELETE /todos/42 reach the same state no matter how many times you send them. POST /todos creates a new resource each time, so a client/proxy must not blindly retry it. Idempotency is what makes safe retries possible.

5. A request hits a known path with an unsupported method (e.g. PATCH on a GET-only route). What's the right response?

404 means the resource path doesn't exist; 405 means the path exists but not for that method. Since Go 1.22 the standard mux distinguishes these and returns 405 (with an Allow header) when a path matches but the method doesn't.

Comments

Sign in with GitHub to join the discussion.