We've covered versioning, pagination, auth, and logging — but never the thing an API does most visibly when it goes wrong: the shape of the error body. That gap is worth closing, because it's the one design decision whose cost lands entirely on other people.
Here's the problem in one sentence: every API invents its own error envelope, so every client writes a bespoke parser. One service returns {"error": "not found"}. Another returns {"message": ..., "code": 4041}. A third nests everything under {"errors": [...]}. None are wrong, exactly, and that's precisely why it's expensive — a client talking to six services writes six error paths, and the seventh service will surprise it too.
There is a standard for this, it's short, and it's very likely you can adopt it this afternoon.
What RFC 9457 Actually Specifies
RFC 9457, “Problem Details for HTTP APIs,” published July 2023, is the current standard. It obsoletes RFC 7807, which is the number you'll still see in older blog posts and library READMEs — if you're citing a spec in 2026, cite 9457.
The whole thing rests on five members, and that's the entire vocabulary:
The five members
type — a URI identifying the kind of problem. title — a short, human-readable summary of that kind. status — the HTTP status code. detail — a human-readable explanation specific to this occurrence. instance — a URI identifying this specific occurrence. That's all of them. Anything else you add is an extension.
Two rules make the format genuinely durable rather than merely tidy. The spec says “Clients consuming problem details MUST ignore any such extensions that they don't recognize” — so you can add fields without breaking anyone. And “If a member's value type does not match the specified type, the member MUST be ignored” — so a malformed field degrades one field instead of the whole response.
The media type is application/problem+json (with application/problem+xml for the XML world). That header is what actually tells a client “this body follows the standard” — without it you've just produced JSON that happens to have five familiar keys.
errors is not a standard member
This trips people up constantly, because the RFC's own examples include an errors array and it looks official. It isn't — it appears there as an illustration of a fictional problem type's extension. There are five standard members. If you return errors, you are returning an extension you defined, and you should treat it as yours to document. Same goes for code, trace_id, or anything else useful you attach.
What FastAPI Gives You by Default
Two shapes, neither of them problem details. Raising HTTPException produces:
{"detail": "Item not found"}
And a validation failure produces the array shape — this is Pydantic v2's format, which looks like this:
{
"detail": [
{
"type": "int_parsing",
"loc": ["path", "item_id"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "foo"
}
]
}
Both are perfectly reasonable defaults. Neither carries the application/problem+json media type, and the second one is the shape most teams leak to clients unchanged — a Pydantic-specific structure that becomes part of your public contract by accident.
Neither FastAPI nor Starlette ships problem details. That absence is the entire reason this article exists: there's no flag to flip, so you write two exception handlers. They're short.
Step 1: The Model
Five optional fields, plus permission to carry extensions:
from pydantic import BaseModel, ConfigDict
class ProblemDetail(BaseModel):
# extra="allow" lets you attach extension members like invalid_params
model_config = ConfigDict(extra="allow")
type: str | None = None
title: str | None = None
status: int | None = None
detail: str | None = None
instance: str | None = None
Serialize with model_dump(exclude_none=True). That matters more than it looks: an omitted type is meaningfully different from "type": null, because the spec says “When this member is not present, its value is assumed to be 'about:blank'.” Serializing nulls throws that default away and hands clients a field they have to special-case.
Step 2: The HTTPException Handler
from http import HTTPStatus
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(StarletteHTTPException)
async def problem_http_handler(request: Request, exc: StarletteHTTPException):
# about:blank is the implied type, so title SHOULD be the status phrase
problem = ProblemDetail(
title=HTTPStatus(exc.status_code).phrase, # "Not Found" for 404
status=exc.status_code,
detail=exc.detail if isinstance(exc.detail, str) else None,
instance=request.url.path,
)
return JSONResponse(
problem.model_dump(exclude_none=True),
status_code=exc.status_code,
media_type="application/problem+json",
headers=getattr(exc, "headers", None),
)
Register on Starlette's exception, not FastAPI's
FastAPI's HTTPException subclasses Starlette's, so a handler registered on the Starlette class catches both — including the 404s and 405s that Starlette's router raises before your code ever runs. Register on FastAPI's subclass instead and those routing-level errors will slip past your handler in the default shape, which is a confusing bug to chase later.
Note the headers pass-through. A 401 that loses its WWW-Authenticate header, or a 429 that loses Retry-After, is a genuine regression — and it's an easy one to introduce when you replace a framework's response with your own.
Step 3: The Validation Handler
This is where you decide what your API says about bad input. FastAPI's array is genuinely useful information; it just needs to live in an extension member rather than masquerading as the standard:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def problem_validation_handler(request: Request, exc: RequestValidationError):
problem = ProblemDetail(
type="https://api.example.com/probs/validation-error",
title="Your request parameters didn't validate.",
status=422,
instance=request.url.path,
# invalid_params is an EXTENSION — yours to define and document
invalid_params=[
{
"name": ".".join(str(p) for p in err["loc"]),
"reason": err["msg"],
}
for err in exc.errors()
],
)
return JSONResponse(
problem.model_dump(exclude_none=True),
status_code=422,
media_type="application/problem+json",
)
Flattening loc into a dotted string is a deliberate choice — ["body", "user", "email"] becomes body.user.email, which a client can display without understanding Pydantic's internals. And dropping input is worth considering: echoing the rejected value back is convenient in development and a quiet way to reflect user-supplied data into logs and error trackers in production.
This extension is yours, not the spec's
invalid_params is not defined by RFC 9457 — the name comes from a well-known example, but adopting it means you own it. Document it wherever you document your API, keep its shape stable, and remember that conforming clients are explicitly permitted to ignore it entirely. Your error responses must still make sense to someone reading only the five standard members.
Step 4: Choosing type URIs
Two defensible approaches, and one common mistake.
The simplest is to omit type entirely, which implies about:blank and means “this is just the HTTP status code, nothing more specific.” For a plain 404 that's honest and complete. Pair it with a title matching the status phrase, exactly as the first handler above does.
The richer approach is a URI per problem kind — /probs/insufficient-funds, /probs/validation-error — which gives clients something stable to branch on that isn't a status code or a human-readable string. The mistake is assuming that URI must resolve to a real page. It doesn't have to; RFC 9457 explicitly added guidance for type URIs that aren't dereferenceable. That said, pointing it at documentation is a small kindness with a large payoff, because the URI shows up in a developer's console at the exact moment they want an explanation.
There's also an IANA registry of common HTTP problem types, established alongside the RFC. Worth a look before minting your own URI for something genuinely generic.
What Changed From RFC 7807 (Almost Nothing)
If you already implemented 7807, you are not behind. RFC 9457 lists exactly three changes: it introduced the registry for common problem type URIs, clarified how to handle multiple problems, and added guidance for non-dereferenceable type URIs. It added no new members. The five are the same five.
On multiple problems, the spec's guidance is deliberately modest: “When an API encounters multiple problems that do not share the same type, it is RECOMMENDED that the most relevant or urgent problem be represented in the response.” Pick the one that matters most rather than inventing a container format for all of them — which is exactly what the validation handler above does by reporting one problem with the individual failures attached as an extension.
The Client Side
Adopting this is only half the value; the other half is consuming it correctly. Two rules, both already quoted above, and both worth writing into your client code deliberately:
- Ignore extensions you don't recognize. Don't validate strictly against a closed schema and reject responses carrying fields you haven't seen — that turns a server's additive, backwards-compatible change into your outage.
- Ignore members whose value type is wrong. If
statusarrives as a string, drop that member and carry on. You still have the HTTP status line, which was authoritative anyway. - Branch on
type, never ondetail.detailis prose meant for humans; it can be reworded or localized at any time. Code that string-matches it is code that breaks on a copy edit.
Is It Worth Doing?
Honestly: if you have one client and it's your own frontend, the benefit is mostly hygiene. You could keep your bespoke envelope and be fine.
It becomes clearly worth it the moment errors cross an organizational boundary — a public API, a partner integration, a service mesh where one team's failures are another team's Tuesday. At that point the standard stops being tidiness and starts being the thing that means nobody has to ask you what your error format is. It also happens to be one of the cheaper items on the operational bar we wrote about yesterday: “can you tell what broke” gets meaningfully easier when every service in the estate answers in the same shape.
Two handlers, one model, one media type. It's a genuinely small change for something that has to be agreed on exactly once.
A FastAPI Foundation Without the Boilerplate
ShipKit is our production-ready FastAPI starting point — structured app layout, JWT auth, Stripe Checkout, and an admin foundation — so the week you'd spend on plumbing goes to your product instead. The error handlers above drop straight into it.
Explore ShipKit