HTTP Servers
Go's standard library includes a production-grade HTTP server. With net/http alone you can route requests by method and path, read parameters and JSON bodies, write responses, add middleware, and shut down gracefully — no framework required. This lesson builds a small JSON API step by step.
A minimal server
package main import ( "fmt" "log" "net/http" ) func main() { mux := http.NewServeMux() mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") if name == "" { name = "world" } fmt.Fprintf(w, "Hello, %s!\n", name) }) log.Println("listening on http://localhost:8080") log.Fatal(http.ListenAndServe(":8080", mux)) }
Run it with go run . and open http://localhost:8080/hello?name=Ana in a browser, or use curl:
curl "localhost:8080/hello?name=Ana"
Hello, Ana!
A handler receives a ResponseWriter to write the response to and a *Request describing what the client asked for. Every request runs in its own goroutine, so the server handles many clients at once — which also means handlers must protect any shared data (lesson 54).
Testing handlers in one program
So that this lesson can show real responses, the remaining examples send requests to their handlers with net/http/httptest instead of opening a network port. httptest.NewRecorder records what a handler writes; you'll use the same technique in tests (lesson 65). Here's a helper used below:
package main import ( "fmt" "io" "net/http" "net/http/httptest" "strings" ) // do sends one request to h and prints the response, like a tiny curl. func do(h http.Handler, method, target, body string) { req := httptest.NewRequest(method, target, strings.NewReader(body)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) res := rec.Result() data, _ := io.ReadAll(res.Body) fmt.Printf("%s %s -> %d %s\n", method, target, res.StatusCode, strings.TrimSpace(string(data))) } func main() { mux := http.NewServeMux() mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello, %s!", r.URL.Query().Get("name")) }) do(mux, "GET", "/hello?name=Ana", "") do(mux, "POST", "/hello", "") do(mux, "GET", "/missing", "") }
GET /hello?name=Ana -> 200 Hello, Ana! POST /hello -> 405 Method Not Allowed GET /missing -> 404 404 page not found
Because the pattern says GET /hello, the router answers other methods with 405 and unknown paths with 404 automatically.
Routing patterns
Patterns can include a method, wildcards in braces, and a trailing {name...} that matches the rest of the path. r.PathValue reads a wildcard:
package main import ( "fmt" "io" "net/http" "net/http/httptest" "strings" ) func do(h http.Handler, method, target string) { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) body, _ := io.ReadAll(rec.Result().Body) fmt.Printf("%-6s %-22s %d %s\n", method, target, rec.Code, strings.TrimSpace(string(body))) } func main() { mux := http.NewServeMux() mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "user %s", r.PathValue("id")) }) mux.HandleFunc("DELETE /users/{id}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "deleted user %s", r.PathValue("id")) }) mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "file %s", r.PathValue("path")) }) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "home page") // {$} matches "/" exactly }) do(mux, "GET", "/users/42") do(mux, "DELETE", "/users/42") do(mux, "GET", "/files/docs/2026/report.pdf") do(mux, "GET", "/") do(mux, "GET", "/users/42/extra") }
GET /users/42 200 user 42 DELETE /users/42 200 deleted user 42 GET /files/docs/2026/report.pdf 200 file docs/2026/report.pdf GET / 200 home page GET /users/42/extra 404 404 page not found
When several patterns match, the most specific one wins, so the order you register them in doesn't matter.
A JSON API
Real APIs read and write JSON, set status codes, and validate input. Writing small helper functions keeps handlers short:
package main import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "sync" ) type Task struct { ID int `json:"id"` Title string `json:"title"` Done bool `json:"done"` } type Store struct { mu sync.Mutex tasks map[int]Task nextID int } 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}) } func (s *Store) create(w http.ResponseWriter, r *http.Request) { var in struct { Title string `json:"title"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } if strings.TrimSpace(in.Title) == "" { writeError(w, http.StatusUnprocessableEntity, "title is required") return } s.mu.Lock() s.nextID++ t := Task{ID: s.nextID, Title: in.Title} s.tasks[t.ID] = t s.mu.Unlock() writeJSON(w, http.StatusCreated, t) } func (s *Store) get(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "id must be a number") return } s.mu.Lock() t, ok := s.tasks[id] s.mu.Unlock() if !ok { writeError(w, http.StatusNotFound, "no such task") return } writeJSON(w, http.StatusOK, t) } func do(h http.Handler, method, target, body string) { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(method, target, strings.NewReader(body))) fmt.Printf("%s %s -> %d %s", method, target, rec.Code, rec.Body.String()) } func main() { s := &Store{tasks: map[int]Task{}} mux := http.NewServeMux() mux.HandleFunc("POST /tasks", s.create) mux.HandleFunc("GET /tasks/{id}", s.get) do(mux, "POST", "/tasks", `{"title": "write docs"}`) do(mux, "POST", "/tasks", `{"title": " "}`) do(mux, "POST", "/tasks", `{oops`) do(mux, "GET", "/tasks/1", "") do(mux, "GET", "/tasks/9", "") }
POST /tasks -> 201 {"id":1,"title":"write docs","done":false}
POST /tasks -> 422 {"error":"title is required"}
POST /tasks -> 400 {"error":"invalid JSON"}
GET /tasks/1 -> 200 {"id":1,"title":"write docs","done":false}
GET /tasks/9 -> 404 {"error":"no such task"}Headers must be set before calling WriteHeader or writing the body; after that they're already on their way to the client. io.LimitReader stops a client from sending an enormous body. Methods like s.create can be used as handlers directly, which is a neat way to give handlers access to shared state.
Middleware
Middleware wraps a handler to add behaviour around every request — logging, authentication, recovering from panics. It's just a function from http.Handler to http.Handler:
package main import ( "fmt" "net/http" "net/http/httptest" "strings" ) func requireKey(key string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-API-Key") != key { http.Error(w, "unauthorized", http.StatusUnauthorized) // adds a newline return } next.ServeHTTP(w, r) }) } func logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Println("->", r.Method, r.URL.Path) next.ServeHTTP(w, r) }) } func main() { mux := http.NewServeMux() mux.HandleFunc("GET /report", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "secret numbers") }) handler := logRequests(requireKey("s3cret", mux)) for _, key := range []string{"", "s3cret"} { req := httptest.NewRequest("GET", "/report", nil) req.Header.Set("X-API-Key", key) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String())) } }
-> GET /report 401 unauthorized -> GET /report 200 secret numbers
Production settings and graceful shutdown
http.ListenAndServe has no timeouts, so a slow client can hold a connection open forever. In real deployments, create an http.Server with timeouts, and on Ctrl+C or a termination signal let in-flight requests finish before exiting:
package main import ( "context" "errors" "log" "net/http" "os" "os/signal" "syscall" "time" ) func main() { mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } }() log.Println("listening on :8080") <-ctx.Done() // wait for Ctrl+C or SIGTERM log.Println("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Println("forced shutdown:", err) } }
For HTML pages, combine handlers with html/template, which escapes data automatically; for static files, http.FileServer(http.Dir("static")) serves a whole folder.
Try it yourself
Add a GET /tasks route to the JSON API above that returns all tasks as a list, sorted by ID. Here's a smaller starting point: a counter API with two routes.
200 3