Status Codes

Which number to return when, and why returning 200 for everything throws away something every client already understands.

Overview

The class matters more than the number

Before the specific code, a client reads the class.

2xx — it worked. 3xx — look elsewhere. 4xx — the caller must change something. 5xx — the server must.

That first digit drives real behaviour in code you did not write: HTTP libraries retry 5xx and not 4xx, caches store 2xx, monitoring alerts on 5xx, and a client's error handling branches on it before looking at anything else.

Returning 200 with {"error": "not found"} throws all of that away. Every caller now has to parse your body to discover something the protocol had a field for, retries do the wrong thing, and your error rate looks like zero on every dashboard.

Worth knowing

The class is the first thing a client reads: 2xx worked, 4xx the caller must change something, 5xx the server must.
status_code= sets the route default; a Response parameter lets one call differ without returning a Response object.
204 must carry no body. Declare it and return None.
401 means not authenticated — who are you? 403 means authenticated and not allowed. Clients act on the difference.
409 is for a valid request that conflicts with current state, such as a duplicate. A 422 would be wrong, because nothing about the payload was malformed.
responses= documents the non-200 shapes, which is what lets a generated client type its errors.

Status Codes

Which number to return when, and why 200-for-everything discards information the client already understands.

The route's default, and overriding it

status_code on the decorator sets the default. A Response parameter lets one call differ.

example_01.pyFastAPI
Output

204 carries nothing

The one rule with teeth. Declare it and return None; anything else produces a malformed response.

example_02.pyFastAPI
Output

The named constants

fastapi.status spells every code. Readable at the call site and autocompleted, which matters for the ones you use rarely.

example_03.pyFastAPI
Output

401 and 403 are different questions

One means “I do not know who you are”, the other “I do, and no”. Clients act on the difference.

example_04.pyFastAPI
Output

409 for a conflict with current state

Not a validation failure and not a missing thing: the request was fine and the world disagrees.

example_05.pyFastAPI
Output

Documenting the ones that are not 200

responses= puts the failure shapes in the schema, so a generated client knows what an error looks like.

example_06.pyFastAPI
Output

The 2xx ones worth using

200 OK for a successful read or update that returns something.

201 Created when something new exists. Conventionally with a Location header pointing at it.

202 Accepted when you have taken the request but not finished it — a queued job. The body should say how to check on it.

204 No Content for a success with nothing to say. A delete, usually.

204 has the one rule with teeth: no body at all. Declare status_code=204 and return None. Returning a value produces a malformed response, and some clients will error on it while others silently ignore the body, which is worse.

The 4xx ones worth distinguishing

400 Bad Request — malformed in a way your own code determined.

401 Unauthorized — badly named: it means *unauthenticated*. I do not know who you are. Should carry a WWW-Authenticate header.

403 Forbidden — I know who you are, and no.

404 Not Found — no such thing.

409 Conflict — the request was fine and conflicts with current state: a duplicate, a version mismatch, an action already taken.

410 Gone — it existed and deliberately does not any more. Rare, and useful when you want callers to stop asking.

422 Unprocessable Entity — well-formed and did not fit the declared shape. FastAPI produces this automatically and you rarely raise it.

429 Too Many Requests — rate limited. Should carry Retry-After.

The 401/403 distinction is the one most often collapsed, and it is worth keeping. A client seeing 401 should prompt for credentials or refresh a token; one seeing 403 should not, because retrying with the same identity will fail again. Collapsing them makes a login loop where there should be an error message.

The 404/403 choice has a security dimension. Returning 403 for a resource that exists but is not yours confirms it exists. For anything sensitive, 404 for both is the safer answer — deliberately, and consistently, or the timing gives it away anyway.

422 versus 400 versus 409

These three get confused, and the rule is about *who determined the problem*.

422 — validation determined it from your declared types and constraints. Automatic; you do not write it.

400 — your code determined the request was malformed in a way the schema could not express.

409 — the request was entirely valid and the current state makes it impossible.

A duplicate slug is 409, not 422: nothing about the payload was wrong, and the same payload would have succeeded a minute earlier.

Setting them

status_code= on the decorator sets the route's default, and that default appears in the documentation.

For one call to differ, declare a Response parameter and assign to response.status_code. That keeps serialisation and the response model, unlike returning a Response object.

HTTPException(status_code, detail) raises one, and it accepts headers= — which is how you attach WWW-Authenticate to a 401 or Retry-After to a 429.

Use the status module rather than bare integers. status.HTTP_409_CONFLICT is readable and autocompleted; 409 requires the reader to know it.

Documenting the failures

response_model describes the success case only. Everything else is undocumented unless you say so:

responses={404: {"model": Problem, "description": "No such module"}}

Now the schema describes the error shape, and a generated client can type it. Without this, consumers know they will get *something* on failure and have to discover what by causing one.

Worth doing for the failures a caller is expected to handle — 404 on a lookup, 409 on a create. Not worth doing for every conceivable code.

Mistakes people make

200 with an error body. The one that costs most. Retries, caches, monitoring and every client's error handling branch on the status class, and a 200 tells all of them the call succeeded.

A body on a 204. Malformed. Some clients error, others silently ignore it, which is worse because it works until it does not.

Collapsing 401 and 403. A client seeing 401 re-authenticates; seeing 403 it should not. Merging them produces a login loop where there should be a message.

422 for a missing resource. The request was well-formed. Nothing with that id exists, which is 404.

422 for a duplicate. The payload was valid and would have worked a minute earlier. That is 409.

500 for a caller's mistake. If they could have avoided it, it is a 4xx. A 500 should mean your code failed, and should page somebody.

Bare integers. 409 requires the reader to know it; status.HTTP_409_CONFLICT does not, and your editor completes it.

The header a status implies

Several codes are incomplete without a header, and omitting it makes a technically-correct response practically useless.

401 should carry WWW-Authenticate, naming the scheme. Without it a client knows it must authenticate and not how.

429 should carry Retry-After. Without it a client's only strategy is guessing, which usually means retrying immediately and making things worse.

405 should carry Allow, listing the methods that do work.

201 conventionally carries Location, pointing at what was created, so a client does not have to construct the URL itself.

HTTPException takes headers= for exactly this, and it is the sort of detail that separates an API somebody enjoys using from one they merely tolerate.

Choosing between 404 and 403

A decision with a security dimension, worth making deliberately.

Returning 403 for a resource that exists but is not yours confirms that it exists. For a sequential id that is an enumeration oracle: a caller can walk the range and learn how many records you have and which ids are real.

Returning 404 for both cases hides that, at the cost of a slightly less helpful message for a legitimate user who has genuinely lost access.

For anything sensitive - other users' data, private documents, anything under a permissions model - 404 for both is the safer default. For an internal API where enumeration tells an attacker nothing they do not already have, 403 is friendlier.

Whichever you choose, be consistent. Returning 403 sometimes and 404 other times leaks exactly the information the 404 was meant to hide, and timing differences will give it away even if the status does not.

Redirects, briefly

The 3xx codes come up less in an API than in a website, and two are worth recognising.

307 preserves the method and body; 308 is its permanent equivalent. These are the ones FastAPI uses for the trailing-slash redirect, and the reason it matters is that some clients still drop the body, turning a POST into an empty one.

301 and 302 historically allowed clients to change the method to GET on redirect, which is why they are the wrong choice for anything that is not a plain read.

If an API needs to move a resource permanently, 308 with a Location header is the honest answer. More often the better answer is to keep the old path working and document the new one.

The ones you will not write

Some codes exist and are produced by infrastructure rather than by your handlers.

502, 503 and 504 come from a proxy or load balancer when your app is unreachable, overloaded or slow. Seeing them in production means the problem is in front of your code, not in it.

413 may be returned by a reverse proxy before a request reaches you, which is why an upload limit belongs there as well as in the handler.

Knowing which layer produces which saves time when something breaks: a 500 is yours, a 502 is not.

A closing thought

A status code is the smallest piece of an API and the one most consumed by machines.

Every client library, cache, proxy, gateway and dashboard reads it, and none of them read your response body. Returning the accurate one costs a keyword argument and buys correct behaviour from all of them.

Returning 200 for everything costs nothing to write and moves the work onto every caller, forever.

A short reference

Created something: 201, with Location. Deleted something: 204, no body. Queued something: 202, with a way to check. Read something: 200.

Caller sent nonsense: 422 if validation caught it, 400 if you did. Not signed in: 401 with WWW-Authenticate. Signed in, not allowed: 403 - or 404 if existence itself is sensitive. Not there: 404. Conflicts with current state: 409. Asking too often: 429 with Retry-After.

Your code failed: 500, logged in full, returned as a generic message with an id.

Summary

The class is what a client reads first, and it drives retries, caching and alerting in code you did not write. Returning 200 with an error body discards all of it.

201 for created, 204 for done-with-nothing-to-say and no body, 202 for accepted-but-not-finished. 401 for unauthenticated and 403 for not-allowed - they are different questions and clients act on the difference. 404 for missing, 409 for a valid request that conflicts with current state, 422 for a payload that did not fit.

Set the route default with status_code=, vary it through a Response parameter, attach the headers a status implies, and document the failures with responses=.

Next

The mechanics behind most of the codes above: HTTPException, custom exception handlers, and the arrangement that keeps HTTP concerns out of the code that does the actual work.

A final note

Status codes are also a form of documentation that nobody has to read.

An endpoint returning 201 with a Location header has told a caller that something was created and where to find it, without a sentence of prose. One returning 200 with a body they must inspect has told them nothing, and the prose now has to exist.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Why is returning 200 with an error body a problem?

  2. What must a 204 response contain?

  3. A signed-in reader tries an admin-only delete. Which code?

  4. A create request is valid but the slug already exists. Which code?

Cheat sheet

Status Codes

That first digit drives real behaviour in code you did not write: HTTP libraries retry 5xx and not 4xx, caches store 2xx, monitoring alerts on 5xx, and a client's error handling branches on it before looking at anything else.

FASTAPI · vizlearn.in/fastapi/status_codes.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.