Skip to content
Happy Programming Guide
Start learning
Web Development

REST API Error Handling and Status Codes: Best Practices

Which status code for which failure, a consistent error body, what to log versus what to return, and the mistakes that make an API painful to use. Language-agnostic, with examples.

Several black power adapters and tangled cables on a light surface

An API’s errors are part of its interface. A client that gets a 200 with an error message in the body, or a 500 for a bad request, cannot handle failures correctly, and its developers will resent you. The rules are short: use the status code to say what kind of failure it was, use one consistent body shape to say what specifically went wrong, put detail in the log rather than the response, and never expose internals. This article gives the code-by-code guide and the body format that has become standard.

4xx or 5xx: whose fault is it?#

The single most important rule. A 4xx says the client sent something wrong and retrying the same request will fail again. A 5xx says the server failed and the same request might succeed later. Clients, proxies and monitoring all act on that distinction: retry logic, alerting, caching. Getting it wrong in either direction causes real damage — a 500 for invalid input pages the on-call engineer for a typo; a 400 for a database outage tells the client to give up.

The codes you need#

Code Use when
200 Success with a body
201 Something was created; include its location or the resource
204 Success with nothing to return, typically after DELETE
400 The request is malformed or fails validation
401 Not authenticated: no credentials, or bad ones
403 Authenticated, but not allowed to do this
404 The resource does not exist
409 Conflict with current state: duplicate, version mismatch
422 Well-formed but semantically invalid; many APIs use 400 for this too
429 Rate limited; include Retry-After
500 An unexpected server error
503 Temporarily unavailable; include Retry-After

That is enough for almost any API. Exotic codes add ambiguity without adding information. Pick 400 or 422 for validation and use it everywhere.

One body shape#

RFC 9457, Problem Details, defines a JSON shape that every client library understands and that most frameworks can produce:

JSON
{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 409,
  "detail": "Balance is 20.00; the transfer requires 35.00.",
  "instance": "/accounts/12345/transfers/987",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
  • type: a stable identifier for this kind of error, so clients can branch on it without parsing text. A URL by convention, though it need not resolve.
  • title: short and fixed per type.
  • status: repeats the HTTP code, for clients that lose it.
  • detail: a human-readable explanation of this occurrence.
  • instance: the request path or resource this concerns.
  • Extra fields as needed: a trace id, a list of field errors.

Send it with Content-Type: application/problem+json. Use the same shape for every error the API emits, including 404s from the router and 500s from the exception handler. Clients then need one parser.

Validation errors#

Report every invalid field at once. A client that fixes one field, resubmits, and learns about the next one is being made to do your job:

JSON
{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 400,
  "errors": {
    "email": ["Must be a valid email address."],
    "quantity": ["Must be greater than zero."]
  }
}

Field names in the error should match the field names in the request body, so a form can attach messages to inputs.

What to log, what to return#

Log everything: the exception, the stack trace, the request id, the user id, the input that caused it. Return only what the client can act on. In particular, never return:

  • Stack traces or file paths.
  • Database errors, SQL, table or column names.
  • Internal service names or hostnames.
  • Whether a username exists, on a login failure. “Invalid credentials” covers both cases.

The trace id is the bridge: the client shows it to the user, the user quotes it to support, support finds the full log entry. For 5xx responses, the detail field should say something like “An unexpected error occurred” and nothing more.

Retries and idempotency#

Clients retry 5xx and 429 responses; they must not retry 4xx. Make that safe by making the retried operations idempotent: a PUT applied twice has the same effect as once; a POST that creates something should accept an Idempotency-Key header so a retry does not create a duplicate. Include Retry-After on 429 and 503 so clients back off correctly rather than hammering.

Consistency#

Every endpoint in the API should fail the same way. That means one place in the code that turns exceptions into responses — middleware, an exception handler, a filter — rather than try/catch blocks in each handler with their own ideas. Frameworks provide this: exception handlers in ASP.NET Core, exception handlers in FastAPI, error middleware in Express, @ControllerAdvice in Spring. Use it, and write the mapping from your domain errors to status codes once.

Mistakes to avoid#

  • 200 with an error in the body. Breaks every client’s success check and every proxy’s caching assumption.
  • 500 for everything. Hides whether the client can fix it.
  • Different shapes per endpoint. Forces clients to special-case.
  • Human text as the only signal. Clients cannot branch on “Item not found” reliably; give them a type or code.
  • HTML error pages from a JSON API. Usually a proxy or default handler in front of the application. Configure it to return JSON, or at least the right status.
  • Leaking the existence of resources the caller may not see. A 403 on a private resource tells them it exists; a 404 does not. Decide which you want.

Questions people ask#

400 or 422 for validation?

Either, consistently. 422 signals “syntactically fine, semantically wrong” and some ecosystems prefer it; 400 is universally understood. Pick one for the whole API.

Should errors be localised?

The title and detail can be, based on Accept-Language. The type must not change, since clients branch on it.

What about GraphQL?

GraphQL returns 200 with an errors array by convention, which is a different contract. The principles — consistent shape, no internals, machine-readable codes — still apply inside that array.

Is Problem Details required?

No, but it is the standard, and the alternative is inventing an equivalent that no library understands. Use it unless you have a strong reason.

Where to go next#

Build a REST API with FastAPI, with proper status codesRead next

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *