201 Created
Success response defined by RFC 9110 §15.3.2.
Last updated July 27, 2026
Common causes at a glance
- A POST to a collection endpoint that created a new record
- A PUT to a URL that had no resource mapped to it before
- An object storage upload completing successfully
- A framework returning 201 by default for any create action, whether or not one happened
Reproduce it
curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"widget"}' https://api.example.com/v1/widgetsWhat 201 Created means
201 is the only 2xx that makes a claim about state: something now exists
that did not before. That makes it the correct answer to a POST that creates
a record, a PUT to a previously unmapped URL, and an upload that
materialises a new object.
The header carrying the value is Location. Without it, a client that just
POSTed to a collection endpoint has no machine-readable way to find what it created and
has to guess by parsing an id out of the body.
What the spec says
RFC 9110 §15.3.2 requires the primary created resource
to be identified by Location (§10.2.2) or, failing
that, by the target URI. Validator fields such as ETag
(§8.8.3) sent on a 201 apply to the new representation, letting a
client issue conditional updates immediately. PUT has a wrinkle:
§9.3.4 says a successful PUT returns 201 if it created
the target resource and 200 or 204 if it replaced one, and it
restricts when validators may be sent. 201 is not 202: it asserts
the resource exists now, not that it will.
What actually causes it
The recurring problem with 201 is not seeing it, it is seeing it when
nothing was created. Idempotent create endpoints that dedupe on a client-supplied key
often keep returning 201 on the second call, which tells the caller a new resource
exists when it is being handed the old one — return 200 with the existing resource
instead. The mirror image is a framework configured to return 200 for everything, which
strips clients of the ability to distinguish create from update on a PUT.
Plenty of APIs send 201 with no Location at all, common enough that most
SDKs no longer look for it. A relative Location is legal but gets mangled by
clients that join it against the wrong base behind a path-rewriting proxy.
How to debug it
Check the Location header first, then follow it:
loc=$(curl -sD - -o /dev/null -X POST -d '{"name":"w"}' \
-H 'Content-Type: application/json' https://api.example.com/v1/widgets \
| awk 'tolower($1)=="location:"{print $2}' | tr -d '\r')
curl -sI "$loc"
If the follow-up GET returns 404, either the write has not propagated to a
read replica yet or the URL is wrong; a couple of seconds of retry tells you which. If
Location is missing, check whether the proxy strips response headers before
blaming the application. When testing idempotency, POST the same payload twice with the
same key and confirm the second call returns 200 rather than a second 201 with a
different resource URL.
Working examples
curl
# -i shows the status line and headers; Location is the point of a 201.
curl -i -X POST \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 9f1c2b7a' \
-d '{"name":"widget","size":3}' \
https://api.example.com/v1/widgets
# Expected:
# HTTP/2 201
# location: https://api.example.com/v1/widgets/8412
# etag: "a1b2c3"
Python (requests)
import requests
resp = requests.post(
"https://api.example.com/v1/widgets",
json={"name": "widget", "size": 3},
headers={"Idempotency-Key": "9f1c2b7a"},
timeout=10,
)
print(resp.status_code) # 201
location = resp.headers.get("Location")
if resp.status_code == 201 and not location:
print("warning: 201 without Location, falling back to body id")
location = f"https://api.example.com/v1/widgets/{resp.json()['id']}"
# urljoin handles a relative Location correctly.
from urllib.parse import urljoin
print(requests.get(urljoin(resp.url, location), timeout=10).status_code)
Node (fetch)
const res = await fetch("https://api.example.com/v1/widgets", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": "9f1c2b7a" },
body: JSON.stringify({ name: "widget", size: 3 }),
});
console.log(res.status, res.headers.get("location"), res.headers.get("etag"));
if (res.status === 201) {
// new URL(loc, res.url) resolves a relative Location against the request URL
const created = new URL(res.headers.get("location"), res.url);
console.log(await (await fetch(created)).json());
}Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.