Status Codes That Mean Something
Choosing the HTTP status code that tells the client what actually happened, and what to send alongside a 201.
w.Header().Set("Location", "/books/42"); w.WriteHeader(http.StatusCreated)The status code is a three digit number at the front of every response. It is the first thing a client reads, and for most clients it is the only part they branch on. A wrong code sends the caller down the wrong path even when your message is perfect.
The pairs that get confused
| Code | Means |
|---|---|
| 400 Bad Request | The request itself is malformed. Broken JSON, a header you cannot parse. |
| 422 Unprocessable Entity | It parsed. A value inside it is unacceptable. |
| 401 Unauthorized | No credentials, or credentials that were not accepted. Say how to authenticate in a WWW-Authenticate header. |
| 403 Forbidden | We know who you are. You may not do this. Re-authenticating will not help. |
| 404 Not Found | No such resource, or we will not confirm there is one. |
| 410 Gone | It existed and is permanently gone. Tells caches and crawlers to stop asking. |
| 201 Created | Something new exists. Send its address in a Location header. |
Go's http.StatusText(422) returns "Unprocessable Entity", the older name. RFC 9110 calls the same code Unprocessable Content.
Shorthand and longhand
http.Error(w, msg, code) is a shorthand. The long form is: delete Content-Length, set Content-Type to text/plain; charset=utf-8, set X-Content-Type-Options to nosniff, call WriteHeader(code), write the message. Prefer the named constant to the bare number too: http.StatusUnprocessableEntity and 422 are the same integer, and only one is searchable.
What trips people up
A 200 carrying an error in its body breaks every client that checks the code. If it failed, say so in the code.
Example
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func createBook(w http.ResponseWriter, r *http.Request) {
title := strings.TrimSpace(r.URL.Query().Get("title"))
if title == "" {
// The request parsed fine, the value is unusable: 422, not 400.
http.Error(w, "title is required", http.StatusUnprocessableEntity)
return // without this, the lines below run as well
}
id := "42" // the id the store handed back
w.Header().Set("Location", "/books/"+id)
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "created %s\n", id)
}
func main() {
// curl -i -X POST '/books?title=Dune' and then the same URL without a title.
for _, target := range []string{"/books?title=Dune", "/books"} {
rec := httptest.NewRecorder()
createBook(rec, httptest.NewRequest(http.MethodPost, target, nil))
fmt.Printf("%d %s location=%q\n",
rec.Code, http.StatusText(rec.Code), rec.Header().Get("Location"))
}
}
// Output:
// 201 Created location="/books/42"
// 422 Unprocessable Entity location=""When to use it
- Decide between 400 and 422 when a POST body is valid JSON but a date is in the past.
- Return 410 for records a user deleted, so search engines drop the URLs instead of retrying.
- Give a newly created order a Location header so the client can fetch it without building the URL itself.
More examples
One place that maps errors to codes
Handlers call statusFor instead of each deciding for itself, so the whole service answers the same way and a new error type is wired up in one edit. The default arm matters: an error you did not plan for is a 500, and its text stays in your logs rather than going to the client.
// Sentinel errors the storage layer returns. errors.Is reports whether an
// error is one of these, following any wrapping done on the way up.
var (
ErrNotFound = errors.New("not found")
ErrDeleted = errors.New("deleted")
ErrConflict = errors.New("conflict")
)
func statusFor(err error) int {
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound // 404
case errors.Is(err, ErrDeleted):
return http.StatusGone // 410: it was here, it will not come back
case errors.Is(err, ErrConflict):
return http.StatusConflict // 409: the current state forbids it
default:
return http.StatusInternalServerError // 500: our fault, say nothing more
}
}401 against 403
The two codes answer different questions: 401 is "who are you", 403 is "not you". Clients act on the difference, usually by prompting for login on a 401 and showing a message on a 403. If you leak existence through 403, use 404 instead and say nothing.
func handler(w http.ResponseWriter, r *http.Request) {
user, ok := userFrom(r.Context())
if !ok {
// No usable credentials. Tell the client what scheme to use;
// RFC 9110 requires this header on a 401.
w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
if !user.IsAdmin {
// Credentials were fine. This user is simply not allowed.
// Sending 401 here makes clients loop: log in, retry, fail, log in.
http.Error(w, "admin only", http.StatusForbidden)
return
}
// ...
}201 with Location, and the URL in the body
201 says a resource now exists, and Location says where. Both matter: a generated client can follow the header without parsing anything, and a human reading the body gets the same address. Note the order, because a Location set after WriteHeader never reaches the client.
type created struct {
ID int64 `json:"id"`
URL string `json:"url"`
}
func createBook(w http.ResponseWriter, r *http.Request) {
id, err := store.Insert(r.Context(), newBook)
if err != nil {
http.Error(w, "could not create book", statusFor(err))
return
}
loc := fmt.Sprintf("/books/%d", id)
buf, err := json.Marshal(created{ID: id, URL: loc})
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Location", loc) // headers first
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // then the status
_, _ = w.Write(buf) // then the body
}
Discussion