Skip to content
Control Plane Labs

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/widgets

What 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.

Other 2xx codes