CHAPTER 30 · STANDARD LIBRARY

Networking with net/http

Go's net/http is production-grade out of the box. Many companies run real-world HTTP services against it with no framework layered on top. This chapter gives you enough to ship a real JSON API.

Learning objectives

  • Write a minimal HTTP server.
  • Understand http.Handler and http.HandlerFunc.
  • Route requests with http.ServeMux (including Go 1.22+ method + path-param patterns).
  • Parse JSON bodies and write JSON responses.
  • Compose middleware with a clean handler-wrapping pattern.
  • Make HTTP calls with http.Client.

Hello server

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "hi,", r.URL.Query().Get("name"))
    })
    http.ListenAndServe(":8080", nil)
}

Hit http://localhost:8080/hello?name=Alice. That's all it takes, no framework, no config file.

http.Handler

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

Everything HTTP in Go boils down to this interface. http.HandlerFunc is a type that adapts a func(w, r) into a Handler:

var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello"))
})

Routing with http.ServeMux

mux := http.NewServeMux()

mux.HandleFunc("/users", listUsers)        // any method
mux.HandleFunc("/users/{id}", getUser)     // Go 1.22+ path params
mux.HandleFunc("POST /users", createUser)  // Go 1.22+ method binding

http.ListenAndServe(":8080", mux)

Since Go 1.22, the built-in mux supports method verbs and path wildcards, fewer teams now need a 3rd-party router for basic cases.

Path params (Go 1.22+)

mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "user %s\n", id)
})

JSON in / JSON out

type User struct { Name string `json:"name"` }

func createUser(w http.ResponseWriter, r *http.Request) {
    var u User
    if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    // ... save u ...
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(u)
}

Middleware

Middleware is a func(http.Handler) http.Handler: a handler that wraps another handler. Log every request:

func logger(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))
    })
}

// Chain them, innermost runs first:
handler := logger(auth(mux))
http.ListenAndServe(":8080", handler)

http.Client

client := &http.Client{Timeout: 5 * time.Second}

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil { /* handle */ }
defer resp.Body.Close()

var users []User
json.NewDecoder(resp.Body).Decode(&users)
!
Always set a timeout The default http.Client has no timeout, a hanging server will keep your goroutine blocked forever. Always create your own client with a timeout, or use http.NewRequestWithContext with a context deadline.

Graceful shutdown

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

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

// Wait for SIGINT/SIGTERM
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop

// Give in-flight requests 10 seconds to finish
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)

Production notes

  • Always set read/write timeouts on http.Server: ReadHeaderTimeout at minimum.
  • Don't reinvent the wheel. chi or gorilla/mux are popular if you need more routing power. For most services Go 1.22's mux is plenty.
  • Pass the request context (r.Context()) into any call that could outlive the request, DB queries, HTTP calls, goroutines.
  • Use httptest for fast, deterministic handler tests (Chapter 31).

Check your understanding

Practice exercises

EXERCISE 1

Tiny JSON API

Build a server that stores an in-memory list of "notes" (a note has an ID, Title, Body). Support GET /notes (list), POST /notes (create), and GET /notes/{id} (fetch one). Use sync.Mutex around the store.

Show sketch solution
package main

import (
    "encoding/json"
    "net/http"
    "strconv"
    "sync"
)

type Note struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Body  string `json:"body"`
}

var (
    mu    sync.Mutex
    notes = map[int]Note{}
    nextID = 1
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /notes", func(w http.ResponseWriter, r *http.Request) {
        mu.Lock(); defer mu.Unlock()
        out := make([]Note, 0, len(notes))
        for _, n := range notes { out = append(out, n) }
        json.NewEncoder(w).Encode(out)
    })

    mux.HandleFunc("POST /notes", func(w http.ResponseWriter, r *http.Request) {
        var n Note
        if err := json.NewDecoder(r.Body).Decode(&n); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest); return
        }
        mu.Lock(); defer mu.Unlock()
        n.ID = nextID; nextID++
        notes[n.ID] = n
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(n)
    })

    mux.HandleFunc("GET /notes/{id}", func(w http.ResponseWriter, r *http.Request) {
        id, _ := strconv.Atoi(r.PathValue("id"))
        mu.Lock(); defer mu.Unlock()
        n, ok := notes[id]
        if !ok { http.NotFound(w, r); return }
        json.NewEncoder(w).Encode(n)
    })

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

Further reading

Shippable web service, done.