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

GO
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"
Output
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:

GO
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", "")
}
Output
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:

GO
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")
}
Output
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:

GO
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", "")
}
Output
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:

GO
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()))
	}
}
Output
-> 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:

GO
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.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
200 3
Next: making HTTP requests as a client.