Chapter 14 gave us a server that returns the exact same text for every request. Now we will look at the two handler parameters in depth: how to read what the client actually sent, and how to control exactly what goes back.
Go's HTTP model puts everything into two parameters. The *http.Request holds the data the client sent, and the http.ResponseWriter is the surface you write your response to. Every Go HTTP handler uses this exact pair. If you master these two parameters, you understand Go's HTTP model.

We have three things to cover:
http.Handler interface and the http.HandlerFunc adapter.Check out the finish branch to follow along:
bashgit checkout 15-handlers-and-responses-finish
Go defines a handler through the http.Handler interface. It requires just one method:
gotype Handler interface {ServeHTTP(ResponseWriter, *Request)}
Any type with a ServeHTTP method satisfies this interface. The pingHandler in is an explicit example. It is a struct with a method, and we register it directly using :
main.goServeHTTPhttp.Handlego// main.gotype pingHandler struct{}func (h pingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {fmt.Fprintln(w, "pong")}
go// main.gohttp.Handle("/ping", pingHandler{})
Most handlers are plain functions. A plain function does not automatically satisfy the interface, so Go provides http.HandlerFunc. This is an adapter type that wraps a function and gives it a ServeHTTP method. When you call http.HandleFunc, this conversion happens automatically:
go// main.gohttp.HandleFunc("/", helloHandler)http.HandleFunc("/greet", greetHandler)http.HandleFunc("/echo", echoHandler)
Use http.HandleFunc for plain functions, which is the most common case. Use http.Handle when you need a custom type with fields or methods, like pingHandler. Both register an http.Handler on the multiplexer (mux). Middleware and library code usually pass http.Handler values around directly, so knowing the interface behind the shortcut is helpful.
The *http.Request carries everything the client sent. The fields you will reach for most often are r.Method, r.URL.Path, r.Header, r.URL.Query(), and r.Body.
The greetHandler reads a query parameter from the URL:
go// main.gofunc greetHandler(w http.ResponseWriter, r *http.Request) {name := r.URL.Query().Get("name")if name == "" {http.Error(w, "missing 'name' query parameter", http.StatusBadRequest)return}w.Header().Set("Content-Type", "text/plain; charset=utf-8")fmt.Fprintf(w, "Hello, %s!\n", name)}
The r.URL.Query() method parses the query string into a url.Values map. Calling .Get("name") returns an empty string if the key is absent. When the parameter is missing, http.Error sends a 400 Bad Request response, and the handler returns immediately.
bashcurl 'http://localhost:8080/greet?name=Tung' # Hello, Tung!curl -si 'http://localhost:8080/greet' # 400 Bad Request
For POST requests, the body arrives through r.Body, which is an io.Reader. The echoHandler reads the entire body and sends it right back:
go// main.gofunc echoHandler(w http.ResponseWriter, r *http.Request) {defer r.Body.Close()body, err := io.ReadAll(r.Body)if err != nil {http.Error(w, "could not read request body", http.StatusBadRequest)return}w.Header().Set("Content-Type", "text/plain; charset=utf-8")w.WriteHeader(http.StatusCreated)w.Write(body)}
The defer r.Body.Close() statement runs when the function returns. Go's HTTP server actually closes the request body for you after a handler returns, so this is more a good habit than a strict requirement here. It becomes mandatory later, when you read response bodies as an HTTP client. The io.ReadAll function reads all bytes from the reader and returns a []byte slice.
bashcurl -si -X POST --data 'hello body' http://localhost:8080/echo# HTTP/1.1 201 Created# hello body
Notice that w.Write(body) writes the bytes exactly as they are, with no trailing newline. The body you send is exactly the body you get back.
The http.ResponseWriter has three main operations: setting headers, setting the status code, and writing the body. You generally need to perform them in a specific order.
Headers first. Call w.Header().Set(key, value) before anything else. Headers are part of the HTTP response preamble and usually go out before the body.
Status code second. Call w.WriteHeader(statusCode) to send the status. If you skip this step, Go automatically sends a 200 OK the first time you call w.Write.
Body last. Call w.Write([]byte) or fmt.Fprintf(w, ...) to write the actual response body.
The ordering in echoHandler is the standard pattern to follow:
gow.Header().Set("Content-Type", "text/plain; charset=utf-8")w.WriteHeader(http.StatusCreated)w.Write(body)
For error responses, http.Error handles all three steps in a single call:
gohttp.Error(w, "missing 'name' query parameter", http.StatusBadRequest)
It sets the status, writes the message as the body, and automatically adds Content-Type: text/plain; charset=utf-8 and X-Content-Type-Options: nosniff.
Here are the status codes this course uses:
| Code | Constant | When to use |
|---|---|---|
| 200 | http.StatusOK | Success with a body |
| 201 | http.StatusCreated | Resource created |
| 204 | http.StatusNoContent | Success, no body |
| 400 | http.StatusBadRequest | Bad input from the client |
| 404 | http.StatusNotFound | Resource does not exist |
| 500 | http.StatusInternalServerError | Something failed server-side |
Try to use http.StatusXxx constants rather than bare integers. They are self-documenting and much easier to search for in your codebase.

Calling WriteHeader twice. Only the first call takes effect. Go logs a "superfluous response.WriteHeader call" warning to stderr, but the handler keeps running. This usually happens when a handler calls http.Error but forgets to return afterward. The error response goes out, and a second write attempt is silently dropped.
Writing the body before the status. The first call to w.Write or fmt.Fprintf(w, ...) flushes the response with a 200 OK if WriteHeader was never called. Any WriteHeader call after that is ignored. Set the status before writing the body.
Skipping Content-Type for JSON. Go sniffs the first 512 bytes and guesses a content type when you leave it out. For plain text in a demo, that is fine. However, the next chapter returns JSON responses. You will need to set Content-Type: application/json explicitly for those, because the sniffer might not guess correctly.
Chapter 16 introduces http.NewServeMux and Go 1.22's method-plus-path patterns like "POST /tasks" and "GET /tasks/{id}", replacing the simple path strings we have used so far. That multiplexer is what the Task API will use for all its routing.