303 See Other
Redirection response defined by RFC 9110 §15.4.4.
Last updated July 27, 2026
Common causes at a glance
- Post/redirect/get after a form submission, done correctly
- An async job endpoint pointing at the status or result resource it created
- A framework redirect helper configured to emit 303 instead of the 302 default
- Content negotiation on a non-information resource in a linked-data API
Reproduce it
curl -sSI -X POST -d 'name=widget' https://example.com/orders | grep -iE '^(HTTP|location)'What 303 See Other means
303 makes the method rewrite intentional. When a browser turns a
POST into a GET after a 302 it is
following convention rather than specification. With 303 the switch is defined
behaviour, so every conforming client does it and you do not have to guess.
The other signal, easy to miss, is that the target is not a new location for
the original resource. It is a different resource that describes or results from it.
That distinction is the whole post/redirect/get pattern: the confirmation page at
/orders/1234 is not the /orders collection you posted to, and
reloading the confirmation must not resubmit the order.
What the spec says
RFC 9110 §15.4.4 states the new URI is not
equivalent to the target URI, that the code applies to any method, and that its primary
use is redirecting the output of a POST to a separately identifiable,
bookmarkable, cacheable resource. It also covers the semantic-web case: a 303 answering
a GET means the server has no HTTP-transferable representation of the
target, only something descriptive of it. Note the asymmetry with
307 at §15.4.8, which forbids changing the
method where 303 requires it.
What actually causes it
Nearly every 303 is deliberate, which makes it the least troublesome redirect. The problems are client-side. HTTP/1.0 clients never learned 303 and treat it as 302, which happens to produce the same behaviour, so that mismatch is harmless. Less harmless is a client configured not to follow redirects: it reports a bare 303 as a failure even though the write succeeded, and retry logic then submits the order twice. The other real problem is the wrong target — pointing a 303 at the collection you posted to rather than the created item defeats the pattern, because a refresh re-renders the list and the user has no bookmarkable receipt.
How to debug it
Confirm the code and the target without following it:
curl -sSI -X POST -d 'name=widget' https://example.com/orders \
| grep -iE '^(HTTP|location)'
You want a 303 and a Location pointing at a specific, newly created
resource. If the target is the URL you posted to, that is a bug waiting to resubmit an
order.
When a client reports failure but the data was written, check whether it follows
redirects at all. Python’s requests and browser fetch() do;
many generated API clients do not. Compare against 201 Created
with a Location header, often the better answer for a JSON API, since a
303 makes a machine client fetch a page it did not ask for.
Working examples
curl
# The raw redirect: 303 plus the resource that was created.
curl -sSI -X POST -d 'name=widget' https://example.com/orders \
| grep -iE '^(HTTP|location)'
# Follow it and watch the method flip to GET, which is correct here:
curl -sSv -X POST -d 'name=widget' -L https://example.com/orders 2>&1 \
| grep -E '^[<>] (POST|GET|HTTP|location)'
Python (requests)
import requests
resp = requests.post("https://example.com/orders", data={"name": "widget"}, timeout=10)
for hop in resp.history:
print(hop.status_code, hop.request.method, "->", hop.headers.get("Location"))
print("final:", resp.status_code, resp.request.method, resp.url) # GET
# Inspect the 303 without following it, e.g. to store the receipt URL.
raw = requests.post(
"https://example.com/orders", data={"name": "widget"},
allow_redirects=False, timeout=10,
)
print(raw.status_code, raw.headers.get("Location"))
Node (fetch)
// fetch() follows a 303 as GET, exactly as the spec requires.
const resp = await fetch("https://example.com/orders", {
method: "POST",
body: new URLSearchParams({ name: "widget" }),
});
console.log(resp.status, resp.url); // 200 and the created order's URL
// Capture the Location instead of following it.
const raw = await fetch("https://example.com/orders", {
method: "POST",
body: new URLSearchParams({ name: "widget" }),
redirect: "manual",
});
console.log(raw.status, raw.headers.get("location"));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.