Building REST APIs with net/http using Go 1.22's new routing
1. Overview
Before Go 1.22, http.ServeMux only matched paths. It didn’t distinguish between GET and POST, and couldn’t read path parameters like /orders/{id}. This is why many projects had to use additional routers like gorilla/mux or chi.
Starting with Go 1.22, ServeMux supports method matching, wildcards, and explicit priority rules. This article shows you how to build a REST API for managing Order objects using only the standard library. You’ll also write tests with httptest and learn common mistakes with the new syntax.
2. Project Setup
You need Go 1.22 or later. The go line in go.mod must also be 1.22 or higher:
mkdir order-api && cd order-api
go mod init example.com/order-api
Your go.mod file after creation:
module example.com/order-api
go 1.22
Note: The new routing behavior depends on the
goline ingo.mod, not the version of the toolchain you installed. Ifgo.modsaysgo 1.21,ServeMuxstill uses the old behavior. In that case, the pattern"GET /orders/{id}"is interpreted as a path containing a space, and your request will get 404 with no error message.
This article doesn’t require external dependencies, so there’s no go get command.
3. New Pattern Syntax
3.1. Method and path parameter matching
A minimal example in main.go:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
w.Write([]byte("Hello, " + name + "\n"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
This code does the following:
"GET /hello/{name}": the part before the space is the method, the part after is the path.{name}is a wildcard that matches exactly one segment.r.PathValue("name")returns the matched value, always as astring. If the wildcard name doesn’t exist, the function returns an empty string.
Try it out:
go run .
curl -i localhost:8080/hello/Alice
curl -i -X POST localhost:8080/hello/Alice
HTTP/1.1 200 OK
...
Hello, Alice
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed
When the path matches but the method doesn’t, ServeMux automatically returns 405 with an Allow header. The GET pattern also automatically matches HEAD.
3.2. Multi-segment wildcards and exact matching
Two other special syntaxes:
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("File: " + r.PathValue("path") + "\n"))
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Home\n"))
})
{path...}must appear at the end of the pattern. It matches the rest of the path, including slashes. For/files/docs/2024/report.pdf, the value isdocs/2024/report.pdf.{$}matches only paths that end exactly. Without it, the pattern"/"matches every path. This is a common reason why an unexpected route doesn’t return 404.
3.3. Priority rules
When multiple patterns match a request, the more specific pattern wins:
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // wins for /orders/latest
/orders/latest is more specific than /orders/{id}, because every request matching the second pattern also matches the first, but not vice versa. The registration order doesn’t matter.
If two patterns overlap and neither is more specific, Go will panic immediately:
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...
The error occurs at startup, not when serving requests. This helps you catch routing errors early.
4. Building the Order API
4.1. Model and in-memory store
Create store.go:
package main
import (
"sort"
"sync"
)
type Order struct {
ID int `json:"id"`
Customer string `json:"customer"`
Total float64 `json:"total"`
}
type OrderStore struct {
mu sync.RWMutex
orders map[int]Order
nextID int
}
func NewOrderStore() *OrderStore {
return &OrderStore{orders: make(map[int]Order), nextID: 1}
}
func (s *OrderStore) Create(o Order) Order {
s.mu.Lock()
defer s.mu.Unlock()
o.ID = s.nextID
s.nextID++
s.orders[o.ID] = o
return o
}
func (s *OrderStore) Get(id int) (Order, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
o, ok := s.orders[id]
return o, ok
}
func (s *OrderStore) List() []Order {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Order, 0, len(s.orders))
for _, o := range s.orders {
result = append(result, o)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (s *OrderStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.orders[id]; !ok {
return false
}
delete(s.orders, id)
return true
}
Each request is handled on its own goroutine. Therefore, the map must be protected by sync.RWMutex. Without the mutex, running go test -race will immediately report a data race. List sorts by ID because the iteration order of a map in Go is random.
4.2. JSON helpers
Create json.go:
package main
import (
"encoding/json"
"net/http"
)
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 writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
The order of calls is crucial. You must call Header().Set before WriteHeader. Once WriteHeader or Write has been called, any header changes are ignored.
4.3. Handlers
Create handler.go:
package main
import (
"encoding/json"
"net/http"
"strconv"
)
type OrderHandler struct {
store *OrderStore
}
func (h *OrderHandler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /orders", h.list)
mux.HandleFunc("POST /orders", h.create)
mux.HandleFunc("GET /orders/{id}", h.get)
mux.HandleFunc("DELETE /orders/{id}", h.delete)
}
func (h *OrderHandler) list(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.store.List())
}
func (h *OrderHandler) create(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // max 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in Order
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if in.Customer == "" || in.Total <= 0 {
writeError(w, http.StatusUnprocessableEntity, "customer and total are required")
return
}
created := h.store.Create(in)
w.Header().Set("Location", "/orders/"+strconv.Itoa(created.ID))
writeJSON(w, http.StatusCreated, created)
}
func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
order, found := h.store.Get(id)
if !found {
writeError(w, http.StatusNotFound, "order not found")
return
}
writeJSON(w, http.StatusOK, order)
}
func (h *OrderHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if !h.store.Delete(id) {
writeError(w, http.StatusNotFound, "order not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "id must be a positive integer")
return 0, false
}
return id, true
}
A few things to note:
Routescollects all route declarations in one place. You can read it once to understand all API endpoints.GET /ordersandGET /orders/{id}don’t conflict because they have a different number of segments.ServeMuxdoesn’t check types.{id}matches evenabc, so you must validate manually withstrconv.Atoi.MaxBytesReaderblocks bodies that are too large.DisallowUnknownFieldsrejects unknown fields like"totl"(a typo).
4.4. Middleware and main function
Middleware in net/http is just a function that takes http.Handler and returns http.Handler. Update main.go:
package main
import (
"log"
"net/http"
"time"
)
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start))
})
}
func newServer() http.Handler {
mux := http.NewServeMux()
h := &OrderHandler{store: NewOrderStore()}
h.Routes(mux)
return logging(mux)
}
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: newServer(),
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
The newServer function separates handler setup from main. This way, tests can reuse the exact same configuration as the live server. http.Server has ReadHeaderTimeout to protect against clients sending headers too slowly. http.ListenAndServe doesn’t set this timeout by default.
From Go 1.23, http.Request has an additional Pattern field. You can log r.Pattern (for example, GET /orders/{id}) instead of the actual path. This helps aggregate metrics by route.
Try it out:
go run .
curl -s -X POST localhost:8080/orders -d '{"customer":"Bob","total":250000}'
curl -s localhost:8080/orders/1
curl -s localhost:8080/orders/abc
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/orders/1
{"id":1,"customer":"Bob","total":250000}
{"id":1,"customer":"Bob","total":250000}
{"error":"id must be a positive integer"}
204
5. Testing with httptest
Go includes net/http/httptest, so you don’t need to open a real port. Create handler_test.go:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateAndGetOrder(t *testing.T) {
srv := newServer()
body := strings.NewReader(`{"customer":"Alice","total":120000}`)
req := httptest.NewRequest(http.MethodPost, "/orders", body)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("POST status = %d, want 201", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/orders/1" {
t.Errorf("Location = %q, want /orders/1", loc)
}
req = httptest.NewRequest(http.MethodGet, "/orders/1", nil)
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
var got Order
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Customer != "Alice" || got.Total != 120000 {
t.Errorf("got %+v", got)
}
}
func TestRouting(t *testing.T) {
srv := newServer()
tests := []struct {
name, method, path string
want int
}{
{"id not a number", http.MethodGet, "/orders/abc", http.StatusBadRequest},
{"not found", http.MethodGet, "/orders/99", http.StatusNotFound},
{"wrong method", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
{"unknown route", http.MethodGet, "/customers", http.StatusNotFound},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
The first test checks the flow of creating and then reading an order. The second test is a table-driven test that verifies routing behavior, including the 405 that ServeMux generates itself. Run with the race detector:
go test -race -v ./...
=== RUN TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN TestRouting
=== RUN TestRouting/id_not_a_number
...
--- PASS: TestRouting (0.00s)
PASS
6. Common mistakes and when not to use it
Common mistakes:
go.modhas an old version. New routes return 404 across the board. Check thegoline ingo.modfirst.- Wrong pattern syntax. There must be exactly one space between method and path. The method must be uppercase, e.g.,
GETnotget. - Using
"/"as a fallback by accident. The pattern"/"catches every request that doesn’t match other routes. Use"/{$}"if you only want the home page. - Wildcard takes up the whole segment. The pattern
/orders/{id}.jsonis invalid. A wildcard can’t share a segment with other characters. - 405 returns plain text.
ServeMuxreturns plain text bodies for 404 and 405, not JSON. If your client needs consistent JSON, you need a wrapper layer.
Note: If an old project breaks after upgrading
go.modto 1.22, you can temporarily setGODEBUG=httpmuxgo121=1to fall back to the old behavior. This is only a temporary workaround. You should still fix your patterns.
Plain net/http may not be enough when you need:
- Type constraints in the route itself, like matching only numbers or using regex.
- Grouping routes with middleware specific to each group. You can still do this with nested
ServeMuxinstances, but the code gets verbose. - A middleware ecosystem with built-in solutions like CORS, rate limiting, or recovery.
For these cases, chi is a lightweight choice that’s fully compatible with http.Handler. Apart from these cases, the standard library is sufficient for most real REST APIs.
7. Conclusion
Go 1.22 makes http.ServeMux powerful enough for real REST APIs. You can match on method, read path parameters with r.PathValue, and let Go handle 405 and priority rules automatically. You’ve built a complete Order API with middleware, input validation, and tests using httptest, without external dependencies.
The key points to remember: set go 1.22 or higher in go.mod, use {$} for exact matching, and always validate wildcard values before using them.