Building a REST API
Reviewed & published by Brayan K
A REST API exposes resources over HTTP, and Go's standard net/http — with the 1.22 method-and-wildcard routing in http.ServeMux — gives you everything you need to route requests, read path values, decode and encode JSON, and return the right status codes.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Create a router with http.NewServeMux
- Route by method and path: "GET /items/{id}"
- Read URL parameters with r.PathValue
- Decode request JSON and encode responses
- Return correct status codes (200/201/204/400/404)
- Test handlers with httptest.NewRecorder
1️⃣ Routing by method and path value
http.NewServeMux is the standard router. Since Go 1.22 a pattern can name a method and use wildcards: GET /items/{id} matches only GETs and captures id, which you read with r.PathValue("id"). We exercise the handlers entirely with httptest so it runs with no port.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
}
// In-memory "database".
var items = map[int]Item{
1: {ID: 1, Name: "Notebook"},
2: {ID: 2, Name: "Pen"},
}
func main() {
mux := http.NewServeMux()
// Go 1.22+ patterns include the METHOD and named wildcards like {id}.
mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
list := []Item{items[1], items[2]} // stable order for a clean demo
json.NewEncoder(w).Encode(list)
})
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // pull the {id} segment from the path
var key int
fmt.Sscanf(id, "%d", &key)
item, ok := items[key]
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
})
// Drive the handlers with httptest.NewRecorder — no port, it just records
// what the handler writes so we can print it.
for _, path := range []string{"/items", "/items/2", "/items/99"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
res := rec.Result()
fmt.Printf("GET %s -> %d %s", path, res.StatusCode, rec.Body.String())
}
}2️⃣ Creating resources: decode, validate, 201
A POST handler reads the JSON body with json.NewDecoder(r.Body).Decode(&in), validates it, then writes a response. Set the status with w.WriteHeader(http.StatusCreated) before encoding the body — once you write bytes, the status is locked to whatever was sent.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
nextID := 3
mux := http.NewServeMux()
// Decode the JSON body, validate, assign an ID, return 201 Created.
mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
var in Item
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if in.Name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
in.ID = nextID
nextID++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // 201 — set status BEFORE the body
json.NewEncoder(w).Encode(in)
})
// Valid create.
req := httptest.NewRequest(http.MethodPost, "/items", strings.NewReader(`{"name":"Stapler"}`))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("POST valid -> %d %s", rec.Code, rec.Body.String())
// Missing name -> 400.
req2 := httptest.NewRequest(http.MethodPost, "/items", strings.NewReader(`{}`))
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
fmt.Printf("POST no name -> %d %s", rec2.Code, rec2.Body.String())
}3️⃣ Keeping handlers DRY
Every handler repeats the same three lines: set the content type, write the status, encode JSON. Factor that into a tiny writeJSON helper. Note 204 No Content for a successful DELETE — a body-less success — which never calls Encode at all.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
// writeJSON centralises the boilerplate: set the header, write the status,
// encode the value. Every handler can call it instead of repeating itself.
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 main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("DELETE /items/{id}", func(w http.ResponseWriter, r *http.Request) {
// 204 No Content: success, nothing to return in the body.
w.WriteHeader(http.StatusNoContent)
})
for _, tc := range []struct{ method, path string }{
{"GET", "/health"},
{"DELETE", "/items/5"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%s %s -> %d %q\n", tc.method, tc.path, rec.Code, rec.Body.String())
}
}🎯 Your Turn
Register a route and dispatch a request to it. Fill in the two blanks marked ___, then run it.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
// 🎯 YOUR TURN — fill in the blanks marked ___
// 1) Register a handler for GET requests to /ping
mux.HandleFunc("___", func(w http.ResponseWriter, r *http.Request) { // 👉 "GET /ping"
fmt.Fprint(w, "pong")
})
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
rec := httptest.NewRecorder()
// 2) Run the request through the mux into the recorder
mux.___(rec, req) // 👉 ServeHTTP
fmt.Printf("%d %s", rec.Code, rec.Body.String())
// ✅ Expected output:
// 200 pong
}Common Errors (and the fix)
❌ Calling w.WriteHeader after writing the body — the status is already 200; the second call logs "superfluous WriteHeader".
✅ Set headers and status before the first Write/Encode.
❌ Patterns without a method on Go < 1.22, then expecting method routing.
✅ Use Go 1.22+ and write "GET /path"; the mux returns 405 for other methods automatically.
❌ Ignoring the Decode error — a malformed body leaves your struct half-filled.
✅ Check it and return 400; validate required fields too.
❌ Not setting Content-Type — clients may not treat the body as JSON.
✅ w.Header().Set("Content-Type", "application/json") before writing.
🧠 Quick Recall
pattern "POST /items"; a GET arrives -> status?
405 Method Not Allowed — the path matches but the method doesn't, and the 1.22 mux rejects it for you.
read {id} from "GET /items/{id}"?
r.PathValue("id") — it returns the matched segment as a string; convert with strconv.Atoi if you need a number.
🎯 Mini-Challenge: A Greeting Endpoint
Register GET /greet/{lang}, look up the language in the map, and write the greeting — or respond 404 for an unknown one. Then dispatch /greet/fr and /greet/de.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
var greetings = map[string]string{
"en": "Hello",
"fr": "Bonjour",
}
func main() {
// 🎯 MINI-CHALLENGE: a greeting endpoint with a path value
// Register GET /greet/{lang}. Read the {lang} path value, look it up in
// greetings, and write it. If the language is unknown, respond 404.
// Then send requests for /greet/fr and /greet/de and print the results.
//
// ✅ Expected output:
// GET /greet/fr -> 200 Bonjour
// GET /greet/de -> 404 unknown language
mux := http.NewServeMux()
fmt.Println("languages loaded:", len(greetings))
// your code here:
// mux.HandleFunc("GET /greet/{lang}", func(w, r){ ... r.PathValue("lang") ... })
// then build two httptest requests and print rec.Code + rec.Body.String()
}🎉 Lesson Complete!
- ✅ http.NewServeMux routes by method and path: "GET /items/{id}"
- ✅ Read wildcards with r.PathValue("id"); wrong method auto-returns 405
- ✅ Decode the body, validate, and set the status before writing
- ✅ Use the right codes: 200, 201, 204, 400, 404, 500
- ✅ Test handlers in-memory with httptest.NewRecorder
Practice quiz
Which http.ServeMux pattern matches only GET requests for a single item by id (Go 1.22+)?
- "/items/{id}"
- "GET /items/:id"
- "GET /items/{id}"
- "/items/(id)"
Answer: "GET /items/{id}". Since Go 1.22 a pattern can include the method and a named wildcard like {id}.
How do you read the {id} wildcard inside the handler?
- r.PathValue("id")
- r.URL.Query().Get("id")
- r.FormValue("id")
- mux.Param("id")
Answer: r.PathValue("id"). r.PathValue("id") returns the matched path segment as a string.
A request with the wrong HTTP method to a matched path returns what status automatically?
- 400 Bad Request
- 404 Not Found
- 500 Internal Server Error
- 405 Method Not Allowed
Answer: 405 Method Not Allowed. The 1.22 mux returns 405 when the path matches but the method does not.
When must you call w.WriteHeader(status)?
- After the first Write/Encode
- Before writing any body bytes
- Only inside a defer
- It is optional and ignored
Answer: Before writing any body bytes. Once you write bytes the status is locked to 200, so set it before the first write.
Which status code is conventional after a successful POST that creates a resource?
- 201 Created
- 200 OK
- 202 Accepted
- 204 No Content
Answer: 201 Created. 201 Created (http.StatusCreated) signals a new resource was created.
Which status fits a successful DELETE that returns no body?
- 200 OK
- 201 Created
- 204 No Content
- 404 Not Found
Answer: 204 No Content. 204 No Content means success with nothing to return in the body.
How do you decode a JSON request body straight from the stream?
- json.Unmarshal(r.Body, &v)
- json.NewDecoder(r.Body).Decode(&v)
- json.Decode(r.Body, &v)
- r.Body.JSON(&v)
Answer: json.NewDecoder(r.Body).Decode(&v). json.NewDecoder(r.Body).Decode(&v) reads JSON directly from the body stream.
How do you test a handler without binding a port?
- http.ListenAndServe on :0
- exec.Command("curl")
- net.Dial to localhost
- httptest.NewRecorder with httptest.NewRequest
Answer: httptest.NewRecorder with httptest.NewRequest. httptest.NewRecorder is an in-memory ResponseWriter you drive via ServeHTTP.
What does http.NewServeMux return?
- A *http.Client
- A request router (*http.ServeMux)
- An http.ResponseWriter
- A net.Listener
Answer: A request router (*http.ServeMux). http.NewServeMux creates a new request multiplexer (router).
Why set w.Header().Set("Content-Type", "application/json")?
- It is required to compile
- It selects the HTTP method
- So clients treat the body as JSON
- It enables gzip automatically
Answer: So clients treat the body as JSON. Setting the Content-Type tells clients the response body is JSON.
Continue this course
- Previous: HTTP Clients (net/http)
- Next: HTTP Middleware — Wrap handlers for logging, auth, and recovery, and chain them together
Frequently asked questions
What changed about routing in Go 1.22?
Before 1.22 the standard http.ServeMux only matched on path prefixes, so people reached for third-party routers for method-based routing and URL parameters. Go 1.22 added both to the standard mux: patterns can include an HTTP method ('GET /items') and named wildcards ('GET /items/{id}'), and you read the wildcard with r.PathValue("id"). For many APIs you no longer need an external router at all.
How do I read a URL path parameter like {id}?
Register the route with a wildcard segment — mux.HandleFunc("GET /items/{id}", handler) — then inside the handler call r.PathValue("id"). It returns the matched segment as a string, so convert it (strconv.Atoi or fmt.Sscanf) if you need a number, and handle the error or a missing value gracefully.
Why must I call w.WriteHeader before writing the body?
Once you write any bytes to the ResponseWriter, Go automatically sends a 200 OK status and the headers, and you can't change them afterwards. So set your headers with w.Header().Set(...) and choose your status with w.WriteHeader(201) BEFORE the first Write or Encode. If you only ever want 200, you can skip WriteHeader entirely.
How do I test a handler without starting a server?
Use httptest. Build a fake request with httptest.NewRequest(method, path, body), create an httptest.NewRecorder() (an in-memory ResponseWriter), then call your handler or mux with mux.ServeHTTP(rec, req). Afterwards inspect rec.Code, rec.Body.String(), and rec.Header(). No port is bound, so tests are fast and isolated.
Which status codes should a REST API return?
Common ones: 200 OK for a successful GET, 201 Created after a successful POST that creates a resource, 204 No Content for a successful DELETE with nothing to return, 400 Bad Request for malformed input, 404 Not Found when the resource doesn't exist, and 500 Internal Server Error for unexpected failures. Use http.StatusOK, http.StatusCreated, etc. instead of magic numbers.
Should I decode JSON with Decode or Unmarshal?
For a request body, json.NewDecoder(r.Body).Decode(&v) reads straight from the stream, which is convenient and memory-friendly. json.Unmarshal works on a byte slice you already have. For encoding the response, json.NewEncoder(w).Encode(v) streams directly to the ResponseWriter. All three are fine; pick whichever matches whether you're holding bytes or a stream.