Handling HTTP Requests and Routing

Every Go web server starts with the same two building blocks: a function that answers one HTTP request, and a router that decides which function answers which request. Go’s standard library ships a complete, production-capable HTTP stack in net/http — you do not need a third-party framework to build a real API. This lesson covers how requests flow through a Go server, how to register routes with the built-in ServeMux (including the modern method- and parameter-aware patterns added in Go 1.22), and the mistakes that trip up almost everyone the first time they write a Go HTTP handler.

Overview / How It Works

At the center of net/http is a single interface:

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

Anything with a ServeHTTP method can handle a request. Because Go interfaces are satisfied implicitly — there is no implements keyword — you rarely define this interface by hand. Instead you write an ordinary function with the signature func(w http.ResponseWriter, r *http.Request), and wrap it with http.HandlerFunc, an adapter type whose ServeHTTP method just calls the underlying function. mux.HandleFunc does this wrapping for you automatically.

The *http.Request gives you everything about the incoming request: method, URL, headers, query string, and body (an io.ReadCloser you can decode JSON from, or read as raw bytes). The http.ResponseWriter is how you build the reply: call w.Header().Set(...) before writing any body to set response headers, then write status codes with w.WriteHeader(code) and body bytes with w.Write or fmt.Fprint. If you write body content without calling WriteHeader first, Go implicitly sends a 200 OK for you on the first write.

A bare handler only knows how to answer, not which requests it should answer. That is the router’s job. The standard library’s router is http.ServeMux: you create one with http.NewServeMux(), register patterns against handlers with mux.HandleFunc(pattern, fn), and then pass the mux to http.ListenAndServe(addr, mux) (or pass nil to use a shared default mux — generally avoid this in real programs since it is global, mutable state shared across your whole binary). ListenAndServe blocks forever, accepting connections and dispatching each request to mux.ServeHTTP, which looks up the best-matching pattern and invokes its handler. It only returns when the server stops, and it always returns a non-nil error in that case (for example, "address already in use"), which is why you almost always see it wrapped in log.Fatal.

Since Go 1.22, ServeMux patterns can include an HTTP method and path wildcards: "GET /users/{id}" matches only GET requests to paths like /users/42, and the handler reads the captured segment with r.PathValue("id"). Before 1.22, a pattern like "/users/" matched every method and you had to branch on r.Method yourself inside the handler. Method-specific patterns are strictly better where they apply: an unmatched method now automatically produces a 405 Method Not Allowed instead of silently running the wrong branch of your own if-statement.

Pattern matching itself has two modes. A pattern ending in a trailing slash, like "/static/", is a subtree match: it matches that path and everything below it (/static/css/site.css, /static/anything). A pattern with no trailing slash, like "/health", is an exact match: it matches only that literal path. The special pattern "/" is a subtree match too, which is why it silently catches every unmatched path in your program — a common surprise covered under Common Mistakes below. Go 1.22 added {$} to force an exact match at the end of an otherwise subtree-shaped pattern, so "/{$}" matches only the literal root path.

Under the hood, each incoming connection is handled on its own goroutine, so a slow handler for one client does not block other clients — goroutines are cheap enough that a Go server comfortably handles thousands of concurrent connections this way. This also means your handler code must be safe for concurrent use: if two requests mutate a shared package-level variable at the same time, you need a sync.Mutex or another synchronization primitive, exactly as you would for any other concurrent Go code.

Syntax

mux := http.NewServeMux()

// Register a handler for a pattern.
// Pattern form: "[METHOD ][HOST]/path[/{wildcard}]"
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
}
log.Fatal(srv.ListenAndServe())
Part Meaning
METHOD Optional HTTP verb (GET, POST, PUT, DELETE, …). If omitted, the pattern matches any method. Requires Go 1.22+.
HOST Optional hostname to match before the path, useful when one server answers multiple domains.
/path The literal path, or a prefix if it ends with /.
{name} A wildcard path segment, retrieved in the handler with r.PathValue("name").
{$} Forces an exact match at that point instead of a subtree match.
http.Server A struct that lets you configure timeouts, TLS, and the address, instead of calling the package-level http.ListenAndServe shortcut.

Examples

Example 1: A minimal handler and router

This example registers one handler on the root path and simulates a request against it using httptest, so the output is deterministic and does not require an actual network connection.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func homeHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Welcome to the homepage!")
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", homeHandler)

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	fmt.Println("Status:", rec.Code)
	fmt.Println("Body:", rec.Body.String())
}

Output:

Status: 200
Body: Welcome to the homepage!

httptest.NewRequest builds a fake *http.Request and httptest.NewRecorder gives us a ResponseWriter that records everything written to it instead of sending bytes over a socket — this is exactly how Go’s own handler tests work, and it is a convenient way to exercise routing logic without starting a real listener. Because homeHandler never calls WriteHeader explicitly, the recorder reports the implicit 200.

Example 2: Method and path-parameter routing

Go 1.22 patterns can require a specific method and capture path segments as named parameters.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func getUserHandler(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	fmt.Fprintf(w, "User ID: %s", id)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", getUserHandler)

	req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	fmt.Println("Status:", rec.Code)
	fmt.Println("Body:", rec.Body.String())
}

Output:

Status: 200
Body: User ID: 42

The {id} segment in the pattern captures whatever text appears in that position of the path. Had the request instead been a POST to /users/42, the mux would return 405 Method Not Allowed automatically, because no registered pattern matches a POST to that path — you get this behavior for free without writing an if r.Method != "GET" check yourself.

Example 3: A small JSON API with middleware

Realistic handlers branch on method, encode and decode JSON, and are usually wrapped with cross-cutting middleware such as logging. This example builds a tiny in-memory to-do list and wraps the mux with a logging middleware.

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
)

type Todo struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
}

var todos = []Todo{
	{ID: 1, Text: "Learn Go"},
	{ID: 2, Text: "Build an HTTP API"},
}

func todosHandler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(todos); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	case http.MethodPost:
		var t Todo
		if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
			http.Error(w, "invalid JSON body", http.StatusBadRequest)
			return
		}
		todos = append(todos, t)
		w.WriteHeader(http.StatusCreated)
	default:
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
	}
}

func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Printf("%s %s", r.Method, r.URL.Path)
		next.ServeHTTP(w, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/todos", todosHandler)
	handler := loggingMiddleware(mux)

	req := httptest.NewRequest(http.MethodGet, "/todos", nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println("Status:", rec.Code)
	fmt.Println("Body:", rec.Body.String())
}

Output:

Status: 200
Body: [{"id":1,"text":"Learn Go"},{"id":2,"text":"Build an HTTP API"}]

Here todosHandler switches on r.Method the older, manual way (this pattern still matters when a single path needs several methods and you want to share logic between branches). loggingMiddleware is itself just a function that takes a http.Handler and returns a new one that wraps it — a very common Go idiom for adding behavior around a handler without modifying it. Its log.Printf call writes to standard error, not standard out, so it does not appear mixed into the fmt.Println output above; in a real terminal you would see the log line separately.

How It Works Step by Step

When a real client sends a request to a running Go server, the following happens in order:

  1. The server’s listener accepts the TCP connection and hands it off to a new goroutine, so the accept loop is immediately free to handle the next client.
  2. That goroutine parses the raw bytes into an *http.Request — method, URL, headers, and a body reader.
  3. The configured http.Handler (typically your ServeMux) receives a call to ServeHTTP(w, r).
  4. The mux compares the request’s method and path against every registered pattern and picks the most specific match (exact matches beat subtree matches, and longer subtree prefixes beat shorter ones).
  5. The matched handler function runs, reading from r and writing to w. Any wildcard segments are available via r.PathValue.
  6. The first call to w.Write (directly, or indirectly through fmt.Fprint or a JSON encoder) flushes the status line and headers, then streams the body back to the client.
  7. Once the handler returns, the server either keeps the connection open for another request (HTTP keep-alive) or closes it, depending on the protocol version and headers involved.

Common Mistakes

Mistake 1: Assuming "/" matches only the root path

A pattern with a trailing slash is a subtree match, so "/" matches every path that was not claimed by a more specific pattern — not just the literal root.

// Wrong: intended to match only the exact root path "/"
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "home")
})
// Any unmatched path, e.g. "/anything/at/all", also reaches this
// handler, because "/" is a catch-all subtree pattern.

// Correct: match only the exact root path (Go 1.22+)
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "home")
})

The fix is to use the {$} suffix, added in Go 1.22, which forces an exact match. If you need to support older Go versions, check r.URL.Path inside the handler and respond with http.NotFound for anything other than "/".

Mistake 2: Discarding the error from ListenAndServe

ListenAndServe blocks for the lifetime of the server and only returns once it has stopped, always with a non-nil error. Calling it as a bare statement throws that error away.

// Wrong: the error from ListenAndServe is silently discarded
http.ListenAndServe(":8080", mux)
// If the port is already in use, the program exits immediately
// with no indication of why the server never started.

// Correct: always check (or fatal-log) the returned error
log.Fatal(http.ListenAndServe(":8080", mux))

This is the same explicit-error-handling discipline Go expects everywhere else: routine failures are return values, not exceptions, and ignoring a returned error is a choice you have to make deliberately, not a default you fall into by accident.

Best Practices

  • Prefer Go 1.22+ method-and-path patterns ("GET /users/{id}") over manual if r.Method != ... checks — the mux handles wrong-method requests with a correct 405 automatically.
  • Configure an http.Server with explicit ReadTimeout, WriteTimeout, and IdleTimeout instead of the bare http.ListenAndServe shortcut, to protect against slow or hanging clients.
  • Keep handlers thin: parse input, call into ordinary application/business-logic functions, and translate the result back to HTTP. This makes the logic testable without spinning up a server.
  • Use middleware (functions that wrap an http.Handler) for logging, authentication, and recovery from panics, rather than repeating that logic in every handler.
  • Always check the error returned by json.NewEncoder(w).Encode(...) and json.NewDecoder(r.Body).Decode(...); a malformed client body should produce a 400, not a panic or a silently empty response.
  • Pass r.Context() down to any downstream calls (database queries, outbound HTTP requests) so that a client disconnecting cancels the work you are doing on their behalf.
  • Use httptest to unit-test handlers directly, without opening real sockets — it is faster and more deterministic than spinning up a live server in tests.

Practice Exercises

  • Create a ServeMux with a route "GET /ping" that writes the body "pong". Verify it with httptest and print the recorded status and body.
  • Add a route "GET /greet/{name}" that reads the name wildcard with r.PathValue and responds with "Hello, <name>!". Test it with a request to /greet/Ada and confirm the output is "Hello, Ada!".
  • Write a middleware function that adds a response header X-Powered-By: Go to every response, wrap an existing mux with it, and use rec.Header().Get("X-Powered-By") in a test to confirm the header is present.

Summary

  • net/http provides a complete HTTP server built around the Handler interface, satisfied implicitly by any type (or, via HandlerFunc, any function) with a matching ServeHTTP method.
  • http.ServeMux is the standard library’s router: register patterns with HandleFunc, then hand the mux to http.ListenAndServe or an http.Server.
  • Since Go 1.22, patterns can include an HTTP method and {wildcard} path segments, read in the handler with r.PathValue.
  • Patterns ending in / are subtree matches and can unintentionally catch more paths than expected; use {$} for an exact match.
  • Each connection is served on its own goroutine, so handler code touching shared state must be synchronized.
  • Always check the error returned by ListenAndServe, and handle every error from encoding, decoding, and writing inside your handlers explicitly.