1. 404 and 405: they look similar, but mean different things
When you start writing an API, the first errors look the same: “I sent a request and got the wrong response.” It’s very tempting to automatically call all such cases “not found” and slap 404 on anything. But 404 and 405 are different diagnoses, and if you mix up the diagnosis, the client (or you two days later) starts treating strep throat with iodine.
404 Not Found means: “for this path the server doesn’t know what to do” or “a resource with these parameters doesn’t exist.” In other words, “not found” is either about routing or about data.
405 Method Not Allowed means: “I understand the path, but the method is wrong.” That is: “the door exists, but you came with the wrong key.” For example, you sent POST /api/v1/tasks/1, but the server expects only GET on that path.
Let’s compare them in a small table:
| Situation | What it means | Expected status |
|---|---|---|
| The client hit a path that doesn’t exist in the API | “We don’t have such a page/endpoint” | |
| The client hit the correct path, but with a method that is not allowed here | “The path is correct, but the method is not allowed” | |
| The client hit the correct path and method, but requests a non-existent resource (id not found) | “The endpoint exists, but the object doesn’t” | |
Notice the last item: a 404 can happen both “at the routing level” and “at the data level”. And that’s normal. It’s just important to understand who exactly decided to return 404: the router (ServeMux) or your handler.
2. Where 404/405 are generated in net/http
When you write a server in Go, it helps to imagine the request passing through two “checkpoints.”
First, the request lands in ServeMux: it looks at the path (and, if you use method-aware patterns, at the method too) and decides which handler will serve the request. If there’s no matching handler, somewhere around here a 404 appears.
If a handler is found, the request is passed to your function, and from there you decide what to return. Here you can return 200, 400, 404 (if the resource is missing), 500, etc.
For clarity, here’s a mini diagram:
flowchart TD
A[HTTP Request: Method + Path] --> B{ServeMux looks for a handler}
B -->|No match| C[404 Not Found
at the routing level]
B -->|Match found| D[Your handler]
D --> E{Checks inside the handler}
E -->|Resource not found| F[404 Not Found
at the data level]
E -->|Method not supported
if you check manually| G[405 Method Not Allowed]
E -->|All good| H[2xx Success]
A small fragment of a training server
Let’s continue the idea of a training “task tracker” API (tasks). Let’s minimally register a couple of routes:
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks", listTasks)
mux.HandleFunc("GET /api/v1/tasks/{id}", getTask)
_ = http.ListenAndServe(":8080", mux)
}
What matters here is not what listTasks and getTask do, but that we explicitly tell ServeMux which paths and methods exist. Now:
- GET /api/v1/tasks — exists
- POST /api/v1/tasks — does not exist (we haven’t registered it yet)
- GET /api/v1/aliens — does not exist
- GET /api/v1/tasks/123 — exists as a route, but what’s up with the data will be decided by the handler
3. Two kinds of 404: routing and data
Many people воспринимают 404 as “oops, the server broke and couldn’t find anything.” But in reality, 404 is a perfectly correct and useful response. In good APIs, 404 is a way to tell the client: “you’re asking for something we don’t have, and that’s expected.”
First, let’s discuss 404 at the routing level. This is the situation where ServeMux didn’t find a matching handler. For example, you don’t have a route "GET /api/v1/projects", and a request there is simply an unknown entry point. In this case your code never ran, and the 404 was “generated” by the router.
Now 404 at the data level. Suppose the route exists: "GET /api/v1/tasks/{id}" is registered. But id=999 is missing in memory (or in the database). Then the route is found and the handler is invoked, but you decide: “the resource doesn’t exist” → so again 404, just for a different reason.
This classic meaning is clearly seen in a common Go error-handling example: when a record isn’t found in storage, the handler returns 404 “Record not found.”
Mini example: 404 “task not found” inside the handler
Let’s make a primitive in-memory storage (perfect for learning; questionable for production, but that’s not the point here).
package main
var tasks = map[int]string{
1: "buy milk",
2: "learn Go",
}
And a getTask handler that returns 404 if the key doesn’t exist:
package main
import (
"fmt"
"net/http"
"strconv"
)
func getTask(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
title, ok := tasks[id]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
fmt.Fprintln(w, title) // e.g.: buy milk
}
Something important happened here: the route exists, the handler was called, but the 404 appeared because the data was not found.
And that’s a normal scenario: the client might request a task by an id that has already been deleted, or simply make a mistake. This is not an “internal server error,” so it’s not 500.
4. 405 Method Not Allowed: the path is correct, the method is not
405 is the “I understood you, but you can’t do that” kind of response. And it significantly improves the client experience. Because when you respond with 404 for a wrong method, the client thinks: “the path doesn’t exist,” and starts fixing the URL. Even though the URL was correct—the problem was the method.
There are two main ways to get a 405 in a Go server:
- Option one: you use method-aware patterns ("GET /path", "POST /path"), and then ServeMux does part of the work for you: it knows the path exists but the method doesn’t match, and it can return 405.
- Option two: you register the path without a method (or one handler for all methods), and inside the handler you do a switch on r.Method and respond with 405 for unknown methods.
Option A: different handlers for different methods
package main
import "net/http"
func registerTaskRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/tasks", listTasks)
mux.HandleFunc("POST /api/v1/tasks", createTask)
}
Now the path is the same (/api/v1/tasks), but there are two registrations. If the client sends PUT /api/v1/tasks, that’s already “the wrong method.”
Option B: one handler and a manual switch on r.Method
Sometimes people do this for simple cases or if they want to keep the logic in one place:
package main
import (
"fmt"
"net/http"
)
func tasksEndpoint(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
fmt.Fprintln(w, "list tasks")
case http.MethodPost:
w.WriteHeader(http.StatusCreated)
default:
w.Header().Set("Allow", "GET, POST")
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
Pay attention: for a manual 405 you must remember the Allow header. It’s not a “nice-to-have for pedants,” it’s part of what 405 means: “here are the allowed methods.”
The Allow header: how to read it and why it matters
The Allow header is an honest hint to the client: which methods are permitted for this resource. It’s especially useful when you have the same API “point,” but different methods mean different actions. For example, GET /api/v1/tasks means “get a list,” and POST /api/v1/tasks means “create a task.”
If the server responds with 405, good form (and practically the standard expectation) is to include Allow. Then the client can automatically retry the request with the correct method or at least show a clear error to the user/developer.
In minimal form it looks like this:
package main
import "net/http"
func methodNotAllowed(w http.ResponseWriter, allow string) {
w.Header().Set("Allow", allow)
w.WriteHeader(http.StatusMethodNotAllowed)
}
And in the handler:
package main
import "net/http"
func onlyGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, "GET")
return
}
w.WriteHeader(http.StatusOK)
}
How to quickly check Allow using curl
When you debug a 405, it’s useful to look not only at the response body, but also at the headers:
curl -i -X PUT http://localhost:8080/api/v1/tasks
The -i flag shows headers. If you see HTTP/1.1 405 Method Not Allowed and a line like Allow: GET, POST, the server is saying: “I know the path, but PUT is not allowed here.”
5. Why you should distinguish 404 and 405
If we were writing a program “just for ourselves,” we could say: “whatever, an error is an error.” But an API is almost always used by someone else: a frontend, a mobile app, a script, another service. And there the difference becomes very practical.
When a client gets 404, it usually concludes: “the URL is wrong” or “the resource doesn’t exist.” When it gets 405, it concludes: “the method is wrong, I’ll try another.” Those are different behavior branches. If you mix up statuses, you literally force the client to fix the wrong thing.
Another important point is debugging. A 404 from the router and a 404 from the handler look identical by status, but the cause differs. In the first case you forgot to register the route or made a mistake in the pattern. In the second case everything is registered, but the business logic says “there is no such object.”
To make debugging less “magical,” it helps (at least temporarily) to log the method and path in handlers (yes, the good old fmt.Printf sometimes saves you better than philosophical reflections):
package main
import (
"fmt"
"net/http"
)
func debug(w http.ResponseWriter, r *http.Request) {
fmt.Printf("method=%s path=%s\n", r.Method, r.URL.Path) // method=GET path=/api/v1/tasks/1
w.WriteHeader(http.StatusOK)
}
Even a single line like that sometimes instantly reveals the problem: for example, the client calls /api/v1/tasks/1/ (with a trailing slash), while you registered /api/v1/tasks/{id} without the trailing slash. And you look at a 404 and think “why?!,” while the answer is: “because the path string differs.”
6. Common mistakes
Beginners step on 404/405 very consistently, as if these weren’t statuses but a set of heated rakes. The reason is usually that we think “the server should understand what I meant.” But a computer is literal: if you said PUT, it believes you wanted exactly PUT, not “well, actually I wanted to update a task, I just don’t remember how.”
Mistake #1: returning 404 instead of 405 because “it’s simpler.”
It’s simpler exactly until the first client starts “fixing the URL” and wasting time on the wrong diagnosis. If the path exists but the method is forbidden, it’s better to honestly return 405 and (if you generate it manually) include Allow. This makes the API predictable.
Mistake #2: mixing up a 404 “route not found” and a 404 “resource not found.”
Both responses are correct, but you must keep in mind where exactly it was produced. If the 404 came from the router, the problem is in route registration or the path pattern. If the 404 came from the handler, the code ran and decided there’s no data (for example, the id is missing in storage).
Mistake #3: manually checking the method and forgetting Allow.
w.WriteHeader(405) looks “good enough,” but semantically 405 almost always implies: “here are the allowed methods.” If you don’t include Allow, it’s harder for the client to automatically figure out what to do next, and it’s harder for you to debug.
Mistake #4: returning 500 for the wrong method.
A wrong method is a client request error, not “the server broke.” Save 500 for cases where the server truly couldn’t process a valid request (DB error, panic, logic bugs). Using PATCH instead of POST is not a server bug; it’s incorrect API usage.
Mistake #5: registering routes “somewhere else” and starting the server with a different mux.
Sometimes routes are attached to one ServeMux, but ListenAndServe is started with another one (or even with nil, which means DefaultServeMux). The result looks like “constant 404.” The fix is simple: keep one mux := http.NewServeMux() and pass that exact mux to the server, as in the examples.
GO TO FULL VERSION