Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · The Standard Library and the Big Four
Start with net/http: What the Standard Library Already Does
Since Go 1.22 the standard library router matches methods and path parameters, so many APIs need no framework at all. Build a JSON endpoint, add middleware, and learn the Handler interface every Go framework builds on.
What you'll build
A tiny "books" API with GET /books, GET /books/{id} and POST /books, JSON in and out, plus a logging middleware, using only the standard library. Every later lesson rebuilds this API with a framework so you can compare.
The idea
Everything in Go HTTP revolves around one interface:
type Handler interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
http.HandlerFunc turns any function with that signature into a Handler. A middleware is just a function that takes a handler and returns a new handler wrapping it.
Since Go 1.22, http.ServeMux understands patterns like "GET /books/{id}": it matches the method, captures {id} (read it with r.PathValue("id")) and answers 405 for the wrong method. That covers much of what people used to install a router for.
What the standard library still leaves to you: route groups, request binding and validation, a central error handler, and convenience helpers for JSON. That's what frameworks add.
net/http/httptest lets you send requests to a handler in memory, which is how every example in this course shows real responses.
The code
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
}
type Store struct {
mu sync.Mutex
books []Book
}
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 (s *Store) list(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
writeJSON(w, http.StatusOK, s.books)
}
func (s *Store) get(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // captured by the "{id}" pattern
s.mu.Lock()
defer s.mu.Unlock()
for _, b := range s.books {
if fmt.Sprint(b.ID) == id {
writeJSON(w, http.StatusOK, b)
return
}
}
writeJSON(w, http.StatusNotFound, map[string]string{"error": "book not found"})
}
func (s *Store) create(w http.ResponseWriter, r *http.Request) {
var b Book
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
s.mu.Lock()
b.ID = len(s.books) + 1
s.books = append(s.books, b)
s.mu.Unlock()
writeJSON(w, http.StatusCreated, b)
}
// logging is a middleware: it wraps a handler and returns a new one.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("-> %s %s\n", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func main() {
store := &Store{books: []Book{{1, "The Go Programming Language", "Alan Donovan, Brian Kernighan"}}}
mux := http.NewServeMux()
mux.HandleFunc("GET /books", store.list)
mux.HandleFunc("GET /books/{id}", store.get)
mux.HandleFunc("POST /books", store.create)
app := logging(mux)
send := func(method, path, body string) {
req := httptest.NewRequest(method, path, strings.NewReader(body))
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
out, _ := io.ReadAll(rec.Body)
fmt.Printf(" %d %s", rec.Code, out)
if len(out) == 0 || out[len(out)-1] != '\n' {
fmt.Println()
}
}
send("GET", "/books/1", "")
send("POST", "/books", `{"title":"Learning Go","author":"Jon Bodner"}`)
send("POST", "/books", `{"author":"nobody"}`)
send("GET", "/books", "")
send("GET", "/books/9", "")
send("DELETE", "/books/1", "") // wrong method: the mux answers 405 by itself
}
Output
-> GET /books/1
200 {"id":1,"title":"The Go Programming Language","author":"Alan Donovan, Brian Kernighan"}
-> POST /books
201 {"id":2,"title":"Learning Go","author":"Jon Bodner"}
-> POST /books
400 {"error":"title is required"}
-> GET /books
200 [{"id":1,"title":"The Go Programming Language","author":"Alan Donovan, Brian Kernighan"},{"id":2,"title":"Learning Go","author":"Jon Bodner"}]
-> GET /books/9
404 {"error":"book not found"}
-> DELETE /books/1
405 Method Not AllowedRun this on your computer — see “Get Started” for setup.
Notice the last request: the mux knows /books/{id} exists for GET only, so it replies 405 Method Not Allowed (with an Allow header) without any code from you.
Common mistakes
- Reaching for a framework by reflex. For small services,
net/http+ a few helpers is enough and has zero dependencies. - Forgetting to
returnafter writing an error. The handler keeps running and writes a second response. - Writing headers after the body.
w.Header().Set(...)must come beforeWriteHeaderor the firstWrite. - Shared state without a lock. Handlers run concurrently; protect maps and slices with a mutex.
- Using the old pattern syntax. Before Go 1.22,
"/books/"was a prefix match and methods weren't matched; check yourgo.modGo version.
Key points
http.Handler(ServeHTTP) is the interface every Go framework builds on or wraps.- Go 1.22+
ServeMuxmatches methods and path values:"GET /books/{id}"+r.PathValue("id"). - Middleware =
func(http.Handler) http.Handler. - Frameworks add groups, binding, validation and central error handling on top.
Further reading: net/http routing enhancements in Go 1.22, net/http package.
Practice questions
What changed in Go 1.22's http.ServeMux?
Answer
Patterns can include an HTTP method and wildcards, e.g. "GET /books/{id}"; the value is read with r.PathValue("id"). The mux returns 405 for a known path with the wrong method, and more specific patterns win over general ones.
What is a middleware in Go's net/http?
Answer
A function that takes an http.Handler and returns a new http.Handler that does something before and/or after calling the wrapped one (logging, auth, recovery). They compose by wrapping: logging(auth(mux)).
Open in the app to mark lessons complete, take notes and track your progress.