net/go.book
All Parts Marketing

Building RESTful APIs with net/http

Go's standard library is unusually capable for building production APIs without a framework. This chapter puts everything from the previous two chapters together — clean routing, query parameters, JSON serialization — into a complete, working CRUD API using only net/http. No third-party router, no framework: just the tools that ship with Go.


The Resource: An In-Memory Task Store

We'll build a small task-tracking API. Tasks live in memory, guarded by a mutex, so concurrent requests don't race:

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"syscall"
	"time"
)

type Task struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
	Done bool   `json:"done"`
}

type Store struct {
	mu     sync.Mutex
	nextID int
	tasks  map[int]Task
}

func NewStore() *Store {
	return &Store{nextID: 1, tasks: make(map[int]Task)}
}

func (s *Store) Create(text string) Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	t := Task{ID: s.nextID, Text: text}
	s.tasks[t.ID] = t
	s.nextID++
	return t
}

func (s *Store) List() []Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	return out
}

func (s *Store) Get(id int) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, errors.New("not found")
	}
	return t, nil
}

func (s *Store) Update(id int, done bool) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, errors.New("not found")
	}
	t.Done = done
	s.tasks[id] = t
	return t, nil
}

func (s *Store) Delete(id int) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tasks[id]; !ok {
		return errors.New("not found")
	}
	delete(s.tasks, id)
	return nil
}

Wiring Up the Handlers

Using the Go 1.22+ http.ServeMux method-and-wildcard patterns from the previous chapter, each CRUD operation gets its own method-scoped route:

type API struct {
	store *Store
}

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 (a *API) createTask(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Text string `json:"text"`
	}
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(&body); err != nil || body.Text == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "text is required",
		})
		return
	}
	task := a.store.Create(body.Text)
	writeJSON(w, http.StatusCreated, task)
}

func (a *API) listTasks(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, a.store.List())
}

func (a *API) getTask(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid id",
		})
		return
	}
	task, err := a.store.Get(id)
	if err != nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "task not found",
		})
		return
	}
	writeJSON(w, http.StatusOK, task)
}

func (a *API) updateTask(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid id",
		})
		return
	}
	var body struct {
		Done bool `json:"done"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid body",
		})
		return
	}
	task, err := a.store.Update(id, body.Done)
	if err != nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "task not found",
		})
		return
	}
	writeJSON(w, http.StatusOK, task)
}

func (a *API) deleteTask(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid id",
		})
		return
	}
	if err := a.store.Delete(id); err != nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "task not found",
		})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	api := &API{store: NewStore()}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", api.createTask)
	mux.HandleFunc("GET /tasks", api.listTasks)
	mux.HandleFunc("GET /tasks/{id}", api.getTask)
	mux.HandleFunc("PATCH /tasks/{id}", api.updateTask)
	mux.HandleFunc("DELETE /tasks/{id}", api.deleteTask)

	http.ListenAndServe(":8080", mux)
}

Request:

POST /tasks HTTP/1.1
Content-Type: application/json

{"text": "Write chapter 4"}

Response:

HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"text":"Write chapter 4","done":false}

Middleware: Wrapping Handlers

Cross-cutting concerns — logging, recovery, request timing — belong in middleware, not scattered across every handler. In net/http, middleware is just a function that wraps a http.Handler and returns another one:

func loggingMiddleware(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 recoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic recovered: %v", err)
				http.Error(w, "internal server error",
					http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Chain them around the mux once, at the top level:

handler := loggingMiddleware(recoverMiddleware(mux))
http.ListenAndServe(":8080", handler)

Because each middleware only depends on http.Handler, you can stack as many as you need, in any order, without touching the handlers themselves.


Graceful Shutdown

Production services should stop accepting new connections and let in-flight requests finish before exiting:

srv := &http.Server{Addr: ":8080", Handler: handler}

go func() {
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatal(err)
	}
}()

ctx, stop := signal.NotifyContext(
	context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()
<-ctx.Done()

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)

Why Stick to the Standard Library

Frameworks like Gin and Fiber (covered next) add convenience — route grouping, built-in binding, richer middleware ecosystems — but net/http alone is enough for most services, has zero dependencies to audit or upgrade, and its APIs are stable across Go versions for the lifetime of your project. Many teams start here and only reach for a framework once the routing or middleware boilerplate becomes genuinely painful.

Frequently Asked Questions

Why does Store use a plain sync.Mutex instead of a sync.RWMutex for a map that's read far more often than it's written? It would be a fair optimization for a read-heavy task list, but the extra complexity isn't worth it in a teaching example — a single Mutex is easier to reason about and impossible to get wrong. Once you've profiled a real service and found the lock genuinely contended by concurrent reads, swapping in an RWMutex is a mechanical, low-risk change.

Why call dec.DisallowUnknownFields() in createTask but not in updateTask? createTask is the stricter of the two on purpose — it's the entry point where a typo in a client's JSON (like "txt" instead of "text") should fail loudly rather than silently create a task with an empty description. updateTask only touches the done flag, so the risk of a silently ignored field is lower, but you could just as reasonably tighten it the same way.

My handler returns a 404 for GET /tasks/{id} even though the task exists — what's usually wrong? Nine times out of ten it's a mismatch between the numeric ID in the URL and the one stored in the map, often because strconv.Atoi(r.PathValue("id")) succeeded but parsed a value that was never actually created. Add a quick log of the parsed id right after the Atoi call and compare it against store.List() — the bug is almost always upstream of the store itself.

Do I really need both a panic-recovery middleware and graceful shutdown, since net/http already recovers per-request panics? Yes, and they solve different problems. Per-request recovery (built into net/http and reinforced by recoverMiddleware) stops one bad request from taking down the whole process; graceful shutdown makes sure that when you do deliberately stop the process — a deploy, a SIGTERM from your orchestrator — in-flight requests get to finish instead of being cut off mid-response.

How does the routing here compare to what Gin gives me? The method-and-wildcard patterns on http.ServeMux ("POST /tasks", "GET /tasks/{id}") already get you most of what a router library provides — method matching and path parameters — which is exactly why this chapter could build a full CRUD API without a framework at all. What Gin adds on top is convenience: route grouping, built-in JSON binding and validation, and a larger middleware ecosystem, not capabilities that are fundamentally missing from the standard library.

Next, we'll see how Gin trims that boilerplate down while keeping the same REST principles.