Handling 405 vs 501 for Unsupported Methods
This page sits under HTTP Method Mapping Guidelines in the API Design Fundamentals & Architecture reference. Both codes say “I will not do that with this method”, and they are not interchangeable: one is about the resource, the other about the server. Getting it wrong misleads clients about whether retrying elsewhere could ever work.
The decision trigger
| Request | Correct response |
|---|---|
DELETE /accounts/{id}/settings where deletion is meaningless |
405 + Allow: GET, PUT, PATCH |
POST /orders/{orderId} on a member resource |
405 + Allow: GET, PUT, PATCH, DELETE |
PROPFIND /orders on a non-WebDAV server |
501 |
TRACE /orders where the server disables it globally |
405 (understood, disallowed) or 501 (never implemented) |
GET /nonexistent-path |
404 — nothing exists there |
PATCH /orders/{id} where patching is planned but unbuilt |
405 today; the roadmap is not a status code |
Spec snippet: what the specification says
RFC 9110 §15.5.6 — 405 Method Not Allowed
"the method received in the request-line is known by the origin server but
not supported by the target resource … The origin server MUST generate an
Allow header field in a 405 response containing a list of the target
resource's currently supported methods."
RFC 9110 §15.6.2 — 501 Not Implemented
"the server does not support the functionality required to fulfill the
request … the appropriate response when the server does not recognize the
request method and is not capable of supporting it for any resource."
Two words settle every case: 405 is scoped to the target resource, 501 is scoped to any resource. A method you support somewhere in the API can never legitimately produce 501, because by definition the server is capable of supporting it.
Step-by-step
Step 1 — Build Allow from the route table, not by hand
A hand-written Allow header drifts the moment a method is added. Derive it:
// method-not-allowed.ts — Allow generated from the registered routes
import type { Express, Request, Response, NextFunction } from "express";
const KNOWN_METHODS = new Set([
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT",
]);
export function methodGuard(app: Express) {
return (req: Request, res: Response, next: NextFunction) => {
if (!KNOWN_METHODS.has(req.method)) {
// The server does not implement this method for ANY resource.
return res.status(501).type("application/problem+json").json({
type: "urn:api:errors:v1:method-not-implemented",
title: "Method not implemented",
status: 501,
detail: `This server does not support the ${req.method} method.`,
});
}
const allowed = methodsForPath(app, req.path); // reads the router table
if (allowed.length === 0) return next(); // no route: let 404 handle it
if (allowed.includes(req.method)) return next();
// HEAD is implied by GET; OPTIONS is always answerable.
const advertised = [...new Set([...allowed, ...(allowed.includes("GET") ? ["HEAD"] : []), "OPTIONS"])];
res.setHeader("Allow", advertised.join(", "));
res.status(405).type("application/problem+json").json({
type: "urn:api:errors:v1:method-not-allowed",
title: "Method not allowed for this resource",
status: 405,
detail: `${req.method} is not supported here. Supported: ${advertised.join(", ")}.`,
allowed_methods: advertised,
});
};
}
Echoing the allowed methods in the problem body as well as the header is redundant on purpose: the header is what specification-aware tooling reads, and the body is what a developer sees in a terminal.
Step 2 — Answer OPTIONS from the same source
// options-handler.ts — OPTIONS should never be a 405
app.options("*", (req, res) => {
const allowed = methodsForPath(app, req.path);
if (allowed.length === 0) return res.status(404).end();
const advertised = [...new Set([...allowed, ...(allowed.includes("GET") ? ["HEAD"] : []), "OPTIONS"])];
res.setHeader("Allow", advertised.join(", "));
res.status(204).end();
});
OPTIONS is the discovery mechanism that makes Allow useful before a failed request rather than after it. Deriving both from the same route table guarantees they agree.
Step 3 — Know what your framework does by default
Defaults differ, and several are wrong for an API:
| Framework | Default for a known path, unsupported method |
|---|---|
| Express (no guard) | falls through to the 404 handler |
| Fastify | 404 unless a method-not-allowed hook is added |
| FastAPI / Starlette | 405 with Allow |
| Django REST Framework | 405 with Allow |
| Spring MVC | 405 with Allow |
Go net/http (manual mux) |
whatever the handler writes — often 404 |
The Express and Go rows are the common source of confusion: a POST to a path that only has a GET route returns 404, telling the client the resource does not exist when it plainly does. Adding the guard above changes it to a 405 that names the fix.
Step 4 — Document it once in the specification
# openapi.yaml — a shared 405 response, referenced from operations that need it
components:
responses:
MethodNotAllowed:
description: >
The method is not supported for this resource. The `Allow` header
lists the methods that are.
headers:
Allow:
description: Comma-separated list of supported methods.
schema: { type: string }
example: "GET, PUT, PATCH, DELETE, HEAD, OPTIONS"
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
paths:
/accounts/{accountId}/settings:
get: { operationId: getAccountSettings }
put: { operationId: replaceAccountSettings }
patch: { operationId: updateAccountSettings }
# POST and DELETE deliberately absent — the guard returns 405 + Allow.
Standard compliance
| Element | Standard | Clause | Requirement |
|---|---|---|---|
405 Method Not Allowed |
RFC 9110 | §15.5.6 | Allow header is required |
501 Not Implemented |
RFC 9110 | §15.6.2 | Method unrecognised for any resource |
Allow header syntax |
RFC 9110 | §10.2.1 | Comma-separated method list |
OPTIONS |
RFC 9110 | §9.3.7 | Communicates supported methods |
404 Not Found |
RFC 9110 | §15.5.5 | No representation exists for the target |
501 is cacheable by default |
RFC 9110 | §15.6.2 | Unlike 405 — see below |
The last row catches people out. 501 responses are cacheable by default, so a 501 emitted transiently — during a rolling deploy where one node lacks a route — can be stored by an intermediary and served after the deploy completes. That alone is a reason to reserve 501 for genuinely permanent conditions and to send Cache-Control: no-store if you must return it for a temporary one.
The header is what turns the rejection into something a developer can act on without opening the reference:
Retry and safety implications
Both codes are non-retryable in the sense that repeating the identical request cannot succeed: nothing about the server’s state will change to make DELETE suddenly valid on a settings singleton. Clients should therefore treat both as terminal and not schedule a retry, which is the classification described in Retryable vs Non-Retryable Errors. The exception worth documenting is a 501 emitted during a partial deployment, which is a genuine transient — and the honest fix there is to return 503 with Retry-After instead, since that is the code that means “temporarily unable”.
SDK / codegen downstream effect
// Generated client — the method simply does not exist for unsupported verbs
await client.accounts.getAccountSettings({ accountId });
await client.accounts.replaceAccountSettings({ accountId, body });
- await client.accounts.deleteAccountSettings({ accountId }); // not generated
+ // ^ compile error: the operation is absent from the specification
This is the quiet benefit of modelling method support accurately in the document: unsupported operations never appear in the SDK, so a caller cannot make the mistake at all. Runtime 405s then only arise from hand-written HTTP calls — which is a useful signal that someone bypassed the client.
Common mistakes
| Mistake | Correct approach |
|---|---|
405 without an Allow header |
Required by the specification; generate it from the routes |
404 for a known path with an unsupported method |
Add a method guard that returns 405 |
501 for a method supported elsewhere in the API |
Use 405; the server clearly implements it |
501 during a rolling deploy |
Use 503 with Retry-After for transient conditions |
Hand-maintained Allow lists |
Derive from the router so they cannot drift |
OPTIONS returning 405 |
Answer OPTIONS from the same route table |
400 for an unsupported method |
The request is well-formed; 405 names the actual problem |
FAQ
When should an API return 501 instead of 405?
Only when the server does not implement the method for any resource — an unrecognised or exotic verb such as PROPFIND on a server that has never spoken WebDAV. In an ordinary REST API this is rare, because the methods clients send are the ones the API already supports somewhere. 405 covers the normal case: the method is perfectly well understood and used elsewhere, it just does not apply to this resource. A useful test is to ask whether the method appears anywhere in your OpenAPI document; if it does, 501 is the wrong answer.
Is the Allow header mandatory on a 405?
Yes — RFC 9110 §15.5.6 states that the origin server MUST generate an Allow header field listing the target resource’s currently supported methods. This is one of the few genuinely mandatory headers on an error response, and the reason is discoverability: without it, the client knows only that its request failed, with no indication of what would have worked. Generate the list from your router rather than writing it by hand, and remember that HEAD is implied wherever GET is supported and OPTIONS should always be listed.
Should an unknown path return 404 or 405?
404. The two codes describe different failures: 404 means no resource is identified by that URL, while 405 means a resource exists there but does not accept this method. Returning 405 for an unknown path is both misleading and mildly leaky, because it implies something exists at a URL an attacker was probing. The ordering in your middleware should therefore be path matching first, then method recognition, then per-resource permission — and frameworks whose default is to fall through to the 404 handler on a method mismatch need an explicit guard to get this right.
Related
- HTTP Method Mapping Guidelines — up-link: the section mapping operations onto HTTP methods
- API Design Fundamentals & Architecture — up-link: the parent reference for HTTP semantics and resource design
- Collection vs Singleton Resource Naming — which methods each resource shape should support
- HTTP Status Code Mapping — where these codes sit in the wider mapping
- When to Use PUT vs PATCH for Partial Updates — choosing between the update methods a resource does allow