HTTP 201 is Created: the request made one or more new resources. RFC 9110 section 15.3.2 says the main one is named by the Location header or, when there’s no Location, by the request URL itself. The body usually describes or links to what was made.
The usual exchange
POST /orders HTTP/1.1
Content-Type: application/json
{"sku": "duck-07", "qty": 2}
HTTP/1.1 201 Created
Location: /orders/4182
ETag: "v1"
Content-Type: application/json
{"id": 4182, "sku": "duck-07", "qty": 2, "status": "pending"}
Location may be relative; the client resolves it against the request URL. Validators such as ETag on a 201 describe the new representation, so the client can send If-Match: "v1" with its next update.
When it applies, and when it doesn’t
POSTthat inserts. 201 withLocation.PUTto a URL that had nothing yet. RFC 9110 section 9.3.4 says the server must answer 201. APUTthat replaces something answers 200 or 204.- Work that creates something later, such as an export job: 202 Accepted with a URL to poll. 201 promises the resource exists now.
- An update. A
PATCHorPUTon an existing record changes it rather than making one: 200 or 204.
Duplicates and retries
A client whose POST timed out doesn’t know whether the row exists, and retrying blindly makes two. Two common fixes: let the client choose the id and use PUT, so a repeat finds the resource and answers 200 or 204; or accept an Idempotency-Key header, as Stripe’s API does, and replay the first response for a repeated key. A unique-constraint clash on create is 409 Conflict, not a second 201.
Caching
201 isn’t heuristically cacheable, and responses to POST are rarely stored anyway. A successful POST does make caches invalidate the stored response for the request URL, and they may invalidate the Location URL too (RFC 9111 section 4.4).
In your stack
res.status(201).location(`/orders/${order.id}`).json(order); // Express
return order, 201, {"Location": f"/orders/{order['id']}"} # Flask
@app.post("/orders", status_code=201) # FastAPI
def create_order(new: NewOrder, response: Response):
order = save(new)
response.headers["Location"] = f"/orders/{order.id}"
return order
In Django, JsonResponse(data, status=201), then set response["Location"].
w.Header().Set("Location", fmt.Sprintf("/orders/%d", order.ID)) // headers first
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
In Go, headers set after WriteHeader are silently dropped, which is the usual reason a 201 arrives without its Location.