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 go line in go.mod, not the version of the toolchain you installed. If go.mod says go 1.21, ServeMux still 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 a string. 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 is docs/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:

  • Routes collects all route declarations in one place. You can read it once to understand all API endpoints.
  • GET /orders and GET /orders/{id} don’t conflict because they have a different number of segments.
  • ServeMux doesn’t check types. {id} matches even abc, so you must validate manually with strconv.Atoi.
  • MaxBytesReader blocks bodies that are too large. DisallowUnknownFields rejects 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.mod has an old version. New routes return 404 across the board. Check the go line in go.mod first.
  • Wrong pattern syntax. There must be exactly one space between method and path. The method must be uppercase, e.g., GET not get.
  • 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}.json is invalid. A wildcard can’t share a segment with other characters.
  • 405 returns plain text. ServeMux returns 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.mod to 1.22, you can temporarily set GODEBUG=httpmuxgo121=1 to 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 ServeMux instances, 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.

golangnet/httprest-apigo-1.22httptest