What each verb promises, why idempotency is worth caring about, and how the router decides which handler runs.
Overview
A resource is one name and several operations
HTTP methods are the verbs of a web request. GET asks for data. POST creates something. PUT replaces it, PATCH changes part of it, and DELETE removes it. Routing is the step where the server looks at the method and the path together and decides which function should answer.
FastAPI gives you one decorator for each method, and the router matches requests against them in order. The combination of method and path picks the handler. Two handlers can share a path as long as their methods differ, which is how a single resource supports several operations.
This article begins with that idea: a resource is one name with several operations attached. A path like /orders can list orders with GET and create one with POST. It then covers the promises each verb makes. Some are safe, meaning they change nothing. Some are idempotent, meaning repeating them has the same effect as doing them once. Clients, caches, and proxies rely on those promises, so breaking them causes real trouble.
The next section explains the difference between a 404 and a 405. One says the path does not exist. The other says the path exists but not for that method. Mixing them up wastes debugging time.
The article ends with route order and how to inspect the routes your app registered, which is useful when a request lands somewhere you did not expect.
These ideas are older than FastAPI, and they apply to any web framework. Learning them here gives you a base that carries over. It also helps you design endpoints that other developers can guess without reading the docs, which is the mark of a well-built API. Each section is short and ends with a habit you can apply straight away, so you finish with working knowledge of how requests reach their handlers.
The same path with other methods is the central idea of an HTTP API. /modules/7 names a thing. GET, PUT and DELETE are what you can do to it.
FastAPI gives you a decorator per verb. Registering a few against one path is normal rather than a special case.
Worth knowing
One path can carry a handler per verb. That is what a resource is: one name, several operations.
A known path with an unregistered verb gives 405, not 404. The distinction tells you the URL was right.
GET is safe — it must not change anything, because browsers prefetch and proxies cache.
PUT and DELETE are idempotent: repeating them leaves the same result, so a client can safely retry after a timeout. POST is not.
Routes match in registration order, first match wins. app.routes prints the table when a route is not behaving.
api_route(methods=[...]) registers one function for several verbs. Useful for health checks; two verbs usually deserve two functions.
HTTP Methods and Routing
What each verb promises, and how the router decides which handler runs.
One path, several methods
The same URL can carry a different handler per verb. That is the shape of a resource: one name, several things you can do to it.
A path that matches a route registered for another verb gives 405, not 404. The difference tells you the URL was right.
example_02.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules")
def read():
return []
c = TestClient(app)
print("GET :", c.get("/modules").status_code)
print("POST :", c.post("/modules", json={}).status_code, "<- 405, path exists")
print("GET /x :", c.get("/nope").status_code, "<- 404, path does not")
print()
print("405 means: right URL, wrong verb. Worth distinguishing when debugging.")
Output
Idempotency, demonstrated
Sending the same request twice should mean something different for POST than for PUT. Here is that difference, counted.
example_03.pyFastAPI
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
STORE = {}
NEXT = {"id": 1}
class ModuleIn(BaseModel):
title: str
@app.post("/modules", status_code=201)
def create(body: ModuleIn):
i = NEXT["id"]; NEXT["id"] += 1
STORE[i] = body.title
return {"id": i}
@app.put("/modules/{i}")
def replace(i: int, body: ModuleIn):
STORE[i] = body.title
return {"id": i}
c = TestClient(app)
c.post("/modules", json={"title": "Vectors"})
c.post("/modules", json={"title": "Vectors"})
print("after two POSTs:", STORE, "<- two records")
STORE.clear()
c.put("/modules/9", json={"title": "Norms"})
c.put("/modules/9", json={"title": "Norms"})
print("after two PUTs :", STORE, "<- one record")
Output
Registration order, and how to inspect it
The router walks its table in order and takes the first match. You can print that table, which settles most routing arguments.
example_04.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules/latest")
def latest(): return {"route": "latest"}
@app.get("/modules/{i}")
def by_id(i: int): return {"route": "by_id", "i": i}
@app.post("/modules")
def create(): return {"route": "create"}
print("the table, in match order:")
for r in app.routes:
methods = getattr(r, "methods", None)
if methods:
print(" %-8s %s" % (",".join(sorted(methods)), r.path))
c = TestClient(app)
print()
print("/modules/latest ->", c.get("/modules/latest").json())
print("/modules/7 ->", c.get("/modules/7").json())
Output
Several methods on one function
api_route registers one handler for a list of verbs, and Request.method tells you which arrived.
example_05.pyFastAPI
from fastapi import FastAPI, Request
app = FastAPI()
@app.api_route("/ping", methods=["GET", "HEAD"])
def ping(request: Request):
return {"method": request.method}
c = TestClient(app)
print("GET :", c.get("/ping").json())
print("HEAD :", c.request("HEAD", "/ping").status_code)
print()
print("Useful for health checks, and rarely the right tool otherwise -")
print("two verbs usually want two functions.")
Output
A resource, end to end
The five verbs on one collection, with the status codes each should return. This is the shape most CRUD endpoints converge on.
example_06.pyFastAPI
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI()
DB, NEXT = {}, {"id": 1}
class ModuleIn(BaseModel):
title: str = Field(min_length=3)
class ModuleOut(ModuleIn):
id: int
@app.get("/modules", response_model=list[ModuleOut])
def list_all():
return list(DB.values())
@app.post("/modules", response_model=ModuleOut, status_code=201)
def create(body: ModuleIn):
i = NEXT["id"]; NEXT["id"] += 1
DB[i] = {"id": i, **body.model_dump()}
return DB[i]
@app.get("/modules/{i}", response_model=ModuleOut)
def read(i: int):
if i not in DB:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Module not found")
return DB[i]
@app.delete("/modules/{i}", status_code=204)
def remove(i: int):
if i not in DB:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Module not found")
del DB[i]
c = TestClient(app)
print("create :", c.post("/modules", json={"title": "Vectors"}).status_code)
print("list :", c.get("/modules").json())
print("read :", c.get("/modules/1").json())
print("missing:", c.get("/modules/99").status_code)
print("delete :", c.delete("/modules/1").status_code)
print("list :", c.get("/modules").json())
Output
The promises each verb makes
These aren't rules you may skip. Browsers, proxies, CDNs, load balancers and HTTP client libraries all act on them. Breaking one makes action you didn't write.
GET is safe. It must not change anything. Browsers prefetch links, proxies cache responses, monitoring replays requests. A "click here to delete" link behind a GET will eventually be followed by a thing that wasn't a person.
POST isn't idempotent. Sending it twice does it twice. That is correct for creating. It's why a client that times out mid-POST truly doesn't know whether it succeeded.
PUT is idempotent. It replaces a resource at a known URL. Repeating it leaves the same state. A client can retry it safely.
DELETE is idempotent. Deleting twice leaves the thing deleted. The second call returning 404 is acceptable. Many APIs return 204 both times. This Is friendlier to a retrying client.
PATCH isn't always idempotent. "Set the title to X" is. "increment the counter" isn't.
That retry property is the hands-on payoff. It's what lets a client library on its own retry a failed PUT and refuse to retry a POST, without knowing anything about your use.
404 versus 405
A path nothing matched is a 404. A path that matched a route registered for a other verb is a 405 Method Not Allowed.
The gap is truly handy when debugging: a 405 means the URL is right and the method is wrong. This Is often a typo in the decorator or a client sending POST where PUT was meant. A 404 means the path never matched at all. This Is a other search.
Order, and inspecting it
Routes match in registration order and the first match wins. The rule from the first tier. The reason a fixed path must be declared before a var one that would also match.
app.routes is worth knowing about. It settles args. Printing the table shows just what the router will try and in what order. This Is faster than thinking about it.
Routes registered through an APIRouter appear in the order the routers were included. This Turns relevant once an app is split up. A catch-all in an early router can shadow a exact route in a later one.
One function, several verbs
api_route takes a list of methods:
@app.api_route("/ping", methods=["GET", "HEAD"])
The honest use is a health check. Here GET and HEAD should behave the same way. Beyond that it's often the wrong tool. Two verbs mean two other steps with two other meanings. One function holding if request.method == "POST" is two functions that haven't been split yet.
Designing the set
Most sets end up with five endpoints. There's little value in being creative about them.
GET /modules lists. POST /modules creates and returns 201. GET /modules/{id} reads or 404s. PUT or PATCH /modules/{id} updates. DELETE /modules/{id} drops and returns 204.
The temptation is to add verbs to paths for anything that doesn't fit. /modules/{id}/publish, /modules/search. at times that is right. An action that isn't a create, read, update or delete truly needs a name. POST /modules/{id}/publish is clearer than inventing a field whose mutation has a side effect.
What to avoid is the halfway house: POST /getModules, or GET /modules/delete/{id}. The first copies what the method already says. The second puts a destructive action behind a safe verb. This Is the one mistake in this module with real results.
Trailing slashes, once more
/modules and /modules/ are other paths, and FastAPI redirects between them with a 307.
That is mostly invisible. Where it bites is a POST. A 307 preserves the method and body . Some clients drop the body making a request that arrives empty for no reason the caller can see. Pick one rule across your API and hold it.
Mistakes people make with HTTP Methods and Routing
A mutating GET. The one with real results. Browsers prefetch, proxies cache, monitoring replays. Link scanners follow. A "delete" behind a GET will eventually run without a person involved.
Using POST for all. It works. It throws away idempotency. A client that times out on a PUT can safely retry. On a POST it truly can't know whether the write landed. Choosing the verb right gives each HTTP library in the world handy data for free.
Verbs in paths.POST /createModule copies what the method already says. GET /modules/delete/7 puts a destructive action behind a safe verb.
given 404 means the route is wrong. A 405 means the path matched and the method didn't - often a typo in the decorator or a client using POST where PUT was meant.
Registering a var route before a fixed one. Still the most common routing bug. Across routers it's harder to see because the two live in other files.
Inconsistent trailing slashes./modules and /modules/ differ. The 307 between them loses the body in some clients - making a POST that arrives empty for no visible reason.
Idempotency in practice
The property is worth one more paragraph because it's the one people skip.
A network is unreliable in a exact way. A request can succeed while the response is lost. The client sees a timeout and has no idea whether the write happened.
For a PUT or DELETE that doesn't matter - retry. The end state is the same. For a POST it matters a great deal. The standard answer is an idempotency key. The client makes one, sends it as a header, and the server records it alongside the result so a repeat returns the first response instead of creating a second record.
Payment APIs all do this, for obvious reasons. Most other APIs should and don't. It's much easier to add before the copies appear than after.
Designing a URL space
Beyond single routes, a few choices shape how an API feels to use.
Nouns for resources, and plural./modules, not /module or /getModules. The method supplies the verb.
Nest only for ownership./modules/{id}/lessons is right when a lesson belongs to just one module and has no split identity. When it does have one, /lessons/{id} alongside is kinder - deep nesting forces callers to know a parent id they may not have.
Actions that aren't CRUD get a sub-resource.POST /modules/{id}/publish is clearer than inventing a field whose mutation has side effects. Keep them few. An API that is mostly actions is an RPC interface wearing REST's clothes. Would be more honest as one.
Filters go in the query string./modules?track=maths narrows a set. /tracks/maths/modules claims a module is reachable only through one track.
Be steady about case and separators. Lower case, hyphens where a word break is needed. Paths are case-sensitive in the standard and inconsistently handled . Agreement drops a class of bug that only appears on somebody else's server.
What the router does not do
Two things worth knowing are absent.
No auto redirect between methods. A GET to a POST-only route is a 405, not a redirect to one spot sensible.
No wildcard fallback by default. An unmatched path is a 404 from the router with a plain body. If you want a catch-all - to serve a single-page app, say - you register one. It must come last or it shadows all after it.
That last point is the sorting rule at its most severe. A catch-all in an early router makes each route in each later router unreachable. This Is a confusing morning.
A closing thought
The verbs are the oldest and best-given part of an HTTP API. The part most often treated as any.
Choosing them right isn't pedantry. It's what lets a client library retry safely, a proxy cache right, a monitoring system set apart a failure from a rejection. A newcomer guess what an endpoint does before reading its docs.
None of that needs anything from you except using the verb that matches what the endpoint really does - which is data you already have.
The set worth memorising
Five endpoints per set, and there's little value in deviating.
GET /things lists, with filters in the query string and a capped limit. POST /things creates, returns 201. Isn't idempotent. GET /things/{id} reads or 404s. PUT or PATCH /things/{id} updates - the first replacing, the second partial. DELETE /things/{id} drops and returns 204.
Anything that doesn't fit turns a sub-resource with a name: POST /things/{id}/publish. Keep those few. An API that is mostly named actions is an RPC interface. Would be clearer written as one than disguised as REST.
HTTP Methods and Routing: summary
One path, a handler per verb. GET is safe and must change nothing. PUT and DELETE are idempotent. A client can retry them after a timeout. POST isn't. This Is why idempotency keys exist.
A known path with an unregistered method is 405, not 404. The gap tells you the URL was right.
Routes match in registration order and, across routers, in inclusion order. app.routes prints the table when a thing isn't behaving.
And design the URL space once: plural nouns, shallow nesting, filters in the query string, and no verbs in paths.
Where to go after HTTP Methods and Routing
Headers and cookies come next - the parts of a request that show the exchange rather than the resource - followed by the two body formats a browser form really sends, the status codes that report what happened, the error handling that makes them. The structure that keeps all of it navigable once there's more than one resource.
Questions people ask about HTTP Methods and Routing
Which method should I use to create a resource? POST, typically returning 201 with the new resource. Use PUT when the client chooses the id and replaces the whole thing.
What is the difference between PUT and PATCH? PUT replaces the entire resource, while PATCH changes only the fields supplied in the request.
What does idempotent mean? Repeating the request has the same effect as sending it once. GET, PUT, and DELETE are idempotent; POST is not.
Why do I get a 405 instead of a 404? The path exists but not for that method. A 404 means no route matched the path at all.
Can one function handle several methods? Yes, with app.api_route("/x", methods=["GET", "POST"]). Separate functions per method are usually clearer.
How do I see every registered route? Loop over app.routes and print each route's path and methods, or open /docs.
One more on safety
The safe-method rule deserves restating because it's the only item here whose violation causes real damage rather than inconvenience.
"Safe" doesn't mean "harmless to call once". It means the caller hasn't requested a change. Anything may call it, any number of times, without asking. Link previews in chat uses fetch URLs. Antivirus scanners fetch URLs. Browsers prefetch on hover. Corporate proxies fetch to inspect.
each one of those will eventually hit a destructive GET. The resulting incident is hard to explain because no one clicked anything. Putting the action behind POST or DELETE drops the entire class.
Check yourself
0 of 4
Answer without scrolling back up.
A GET request is prefetched by a browser and your handler deletes something. Whose bug is it?
Browsers prefetch, proxies cache and monitoring replays, all on the assumption that GET changes nothing. A destructive GET will eventually be called by something that was not a person.
What does a 405 tell you that a 404 does not?
405 means the URL is right and the verb is wrong - usually a typo in the decorator or a client using the wrong method. A 404 means nothing matched at all.
Why can a client safely retry a failed PUT but not a failed POST?
A repeated PUT replaces the same resource with the same content. A repeated POST creates a second record, which is why a timed-out POST leaves a client genuinely uncertain.
Where do routes registered on an APIRouter sit in match order?
Inclusion order determines match order, so a catch-all in an early router can shadow a more specific route in a later one.
Cheat sheet
HTTP Methods and Routing
Map GET, POST, PUT and DELETE to handler functions, follow the matching logic, and see what each HTTP method promises to the caller.
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.