Build a REST API in Go with net/http and the New ServeMux
Ship a dependency-free JSON API with method-aware routing, middleware, and graceful shutdown using only Go's standard library.
What you'll build
A JSON task API with five routes, request logging, panic recovery, and clean shutdown on Ctrl-C — using only the Go standard library. No router package, no framework, no go.sum.
Prerequisites
- Go 1.22 or newer. The method-and-wildcard routing patterns landed in Go 1.22; this tutorial was verified on Go 1.25.7 and the code uses nothing newer than 1.22 APIs, so it runs unchanged on the current stable release, Go 1.27.0 (released August 19, 2026). Check with
go version. curlfor testing. macOS and Linux ship it; on Windows use PowerShell'scurl.exe(not thecurlalias, which isInvoke-WebRequest).- Port 8080 free on localhost.
Step 1: Initialize the module
mkdir taskapi && cd taskapi
go mod init example.com/taskapi
Open the generated go.mod and confirm the go directive is 1.22 or higher. This matters more than it looks: the new mux semantics are gated on that directive, and a go 1.21 line silently reverts to the old literal-path matching (see Troubleshooting).
Step 2: Write the store and JSON helpers
An in-memory map behind an RWMutex is enough to exercise the router. The two write* helpers keep every handler's response shape consistent, and setting Content-Type before WriteHeader matters — headers written after the status line are silently dropped.
// store.go
package main
import (
"encoding/json"
"net/http"
"sync"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type store struct {
mu sync.RWMutex
nextID int
tasks map[int]Task
}
func newStore() *store {
return &store{nextID: 1, tasks: make(map[int]Task)}
}
func (s *store) list() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
func (s *store) create(title string) Task {
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title}
s.tasks[t.ID] = t
s.nextID++
return t
}
func (s *store) get(id int) (Task, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
return t, ok
}
func (s *store) delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.tasks[id]
delete(s.tasks, id)
return ok
}
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})
}
Step 3: Add middleware chaining
Middleware in net/http is just a function from http.Handler to http.Handler. The chain helper applies them in reverse so the first one you list is the outermost — the order you'd read it in. statusRecorder exists because http.ResponseWriter gives you no way to read back the status a handler wrote; wrapping WriteHeader is the standard-library-only answer.
// middleware.go
package main
import (
"log/slog"
"net/http"
"time"
)
type Middleware func(http.Handler) http.Handler
// chain wraps h so that the first middleware listed is the outermost.
func chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// statusRecorder captures the status code so the logger can report it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
)
})
}
func recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic", "err", err, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
Step 4: Register routes and wire up graceful shutdown
Three things to notice in the ServeMux patterns below. A pattern is [METHOD ][HOST]/[PATH], so "GET /tasks/{id}" matches on method and path together. {id} is a single-segment wildcard you read back with r.PathValue("id"). And a GET pattern also matches HEAD for free. Precedence is "most specific wins": "GET /tasks" and "GET /tasks/{id}" coexist because neither matches a superset of the other's requests. One trap worth knowing before you add a root route: "/" matches every path, so use "/{$}" when you mean exactly /.
Shutdown works by turning SIGINT/SIGTERM into a cancelled context with signal.NotifyContext, then calling srv.Shutdown, which stops accepting connections and waits for in-flight requests to finish (bounded by the 10-second context).
// main.go
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
)
func main() {
s := newStore()
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.list())
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" {
writeError(w, http.StatusBadRequest, "body must be JSON with a non-empty title")
return
}
writeJSON(w, http.StatusCreated, s.create(in.Title))
})
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
t, ok := s.get(id)
if !ok {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
})
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
if !s.delete(id) {
writeError(w, http.StatusNotFound, "task not found")
return
}
w.WriteHeader(http.StatusNoContent)
})
srv := &http.Server{
Addr: ":8080",
Handler: chain(mux, recoverPanic, logRequests),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed", "err", err)
os.Exit(1)
}
slog.Info("stopped")
}
The explicit timeouts on http.Server aren't optional hygiene — the zero values mean no timeout, so a slow client can hold a connection open indefinitely. ListenAndServe always returns http.ErrServerClosed after a successful Shutdown, which is why that case is filtered out before treating the error as fatal.
Verify it works
Start the server in one terminal:
go run .
2026/08/25 07:36:01 INFO listening addr=:8080
In a second terminal, walk the full lifecycle:
curl -s -X POST localhost:8080/tasks -d '{"title":"write the tutorial"}'
curl -s -X POST localhost:8080/tasks -d '{"title":"ship it"}'
curl -s localhost:8080/tasks/1
curl -si -X DELETE localhost:8080/tasks/1 | head -1
curl -s localhost:8080/tasks
Expected output, line for line:
{"id":1,"title":"write the tutorial","done":false}
{"id":2,"title":"ship it","done":false}
{"id":1,"title":"write the tutorial","done":false}
HTTP/1.1 204 No Content
[{"id":2,"title":"ship it","done":false}]
Now confirm the mux is doing method matching for you. A PUT to a path that only has GET and DELETE handlers gets a 405 with a correct Allow header — no code of yours involved:
curl -si -X PUT localhost:8080/tasks/2 | head -2
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, GET, HEAD
Finally, press Ctrl-C in the server terminal. You should see the shutdown sequence complete and the process exit 0:
2026/08/25 07:36:02 INFO request method=PUT path=/tasks/2 status=405 duration=36.416µs
^C2026/08/25 07:36:02 INFO shutting down
2026/08/25 07:36:02 INFO stopped
Troubleshooting
Every route returns 404 page not found, even GET /tasks. Your go.mod says go 1.21 (or lower). The Go toolchain reads that directive and defaults GODEBUG=httpmuxgo121=1, which makes braces literal and treats "GET /tasks" as a path named GET /tasks. Change the directive to go 1.22 or later and rebuild. To confirm which mode a binary was built with, run go version -m ./taskapi | grep httpmuxgo121 — an affected build lists httpmuxgo121=1 in its DefaultGODEBUG line; a correct one prints nothing.
panic: pattern "GET /tasks/{slug}" (registered at main.go:6) conflicts with pattern "GET /tasks/{id}" (registered at main.go:5). Two patterns match the same set of requests, so neither is more specific and the mux refuses to guess. This fires at registration, not at request time. Rename or remove one; if you want /tasks/latest alongside /tasks/{id}, that's fine — the literal segment is strictly more specific and wins.
ERROR server failed err="listen tcp :8080: bind: address already in use". A previous run is still holding the port — usually one you stopped with Ctrl-Z instead of Ctrl-C, or an IDE debug session. Find it with lsof -i :8080 (macOS/Linux) and kill it, or change Addr to ":8081".
405 Method Not Allowed on a request you expected to work. Check the method casing in your pattern. The mux accepts "get /tasks" without complaint but registers a method literally named get, which never matches an incoming GET. The giveaway is the response header, Allow: get. Methods in patterns must be uppercase.
Next steps
- Read the Go 1.22 routing enhancements post for the precedence rules and the
{path...}and{$}wildcards you'll want for file serving and exact-root matches. - Swap the map for a real database and add a
PUT /tasks/{id}handler — the pattern will slot in next toDELETEwithout conflict. - Write handler tests with
net/http/httptest:httptest.NewRequestplusmux.ServeHTTPexercises routing andPathValuewith no network. - On Go 1.27 the standard
encoding/jsonis now backed by the newencoding/json/v2engine; tryjson.UnmarshalRead(r.Body, &in)from v2 for stricter decoding.
Sources & further reading
- net/http ServeMux - pattern syntax, precedence and compatibility — pkg.go.dev
- Routing Enhancements for Go 1.22 — go.dev
- os/signal NotifyContext — pkg.go.dev
- Go 1.27 Release Notes — go.dev
- Go Release History — go.dev
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 1
the no-deps angle is solid—i migrated a cli tool to stdlib routing last year and immediately stopped fielding bug reports about conflicting versions in the ecosystem. my only addition: please test with keyboard nav and screen readers if this gets a web frontend, since i've seen too many go apis ship with admin dashboards that are completely unusable without a mouse.