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:
- 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.
- That goroutine parses the raw bytes into an
*http.Request— method, URL, headers, and a body reader. - The configured
http.Handler(typically yourServeMux) receives a call toServeHTTP(w, r). - 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).
- The matched handler function runs, reading from
rand writing tow. Any wildcard segments are available viar.PathValue. - The first call to
w.Write(directly, or indirectly throughfmt.Fprintor a JSON encoder) flushes the status line and headers, then streams the body back to the client. - 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 manualif r.Method != ...checks — the mux handles wrong-method requests with a correct405automatically. - Configure an
http.Serverwith explicitReadTimeout,WriteTimeout, andIdleTimeoutinstead of the barehttp.ListenAndServeshortcut, 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(...)andjson.NewDecoder(r.Body).Decode(...); a malformed client body should produce a400, 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
httptestto 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
ServeMuxwith a route"GET /ping"that writes the body"pong". Verify it withhttptestand print the recorded status and body. - Add a route
"GET /greet/{name}"that reads thenamewildcard withr.PathValueand responds with"Hello, <name>!". Test it with a request to/greet/Adaand confirm the output is"Hello, Ada!". - Write a middleware function that adds a response header
X-Powered-By: Goto every response, wrap an existing mux with it, and userec.Header().Get("X-Powered-By")in a test to confirm the header is present.
Summary
net/httpprovides a complete HTTP server built around theHandlerinterface, satisfied implicitly by any type (or, viaHandlerFunc, any function) with a matchingServeHTTPmethod.http.ServeMuxis the standard library’s router: register patterns withHandleFunc, then hand the mux tohttp.ListenAndServeor anhttp.Server.- Since Go 1.22, patterns can include an HTTP method and
{wildcard}path segments, read in the handler withr.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.
