1. DELETE in CRUD and why people argue about it
DELETE looks simple: “we’re just deleting—what could possibly go wrong?” And then it suddenly turns out that DELETE is exactly where clients most often build automation, retries, and “it fixed itself” flows—and that’s where the nuances start. Some treat DELETE as “delete at any cost,” others as a strict operation: “delete an existing resource, otherwise say it wasn’t found.” Our task is to define a clear contract and implement it with no surprises.
In our training tasks API (tasks), DELETE is the final touch of the CRUD set: create, list, change state, and… remove from the system. The important part is that we do it in a Go style: a short handler, a single error policy, and no status-code “magic.”
DELETE idempotency: the effect matters more than the response
The term “idempotency” sounds like it was invented so programmers could scare each other in interviews. In practice, the idea is simple: if you repeat the exact same request several times, the effect will be the same as from a single request. Not “the response will be byte-for-byte identical,” but the effect on the server.
For DELETE this is especially important, because in the real world a request can reach the server while the response gets lost: the network hiccups, mobile internet goes to make tea, a proxy starts contemplating the meaning of life. The client retries—and that’s normal. If the operation is idempotent, repeating it must not lead to a “double delete” (whatever that would mean) or create new problems.
A small table to make it stick:
| HTTP method | “Safe” | Idempotent |
|---|---|---|
|
yes | yes |
|
no | usually no |
|
no | yes |
|
no | depends |
|
no | yes |
“Safe” means “does not change the server state” (for example, you can repeat GET a hundred times—the server should not “create one more task” because of that). DELETE changes state, so it is not safe, but it is usually idempotent.
2. The contract for DELETE /api/v1/tasks/{id}
So we don’t play “guess the status code based on the server’s mood,” we define a contract. We have a path with {id}, and we want it to behave the same way across all handlers where an identifier appears.
Contract:
- Method and path: DELETE /api/v1/tasks/{id}
- {id} is required and must be a valid positive integer
- Success: 204 No Content (and no body)
- If the task does not exist: 404 Not Found via our single error envelope
- If {id} is bad: 400 Bad Request (this is validation)
- Any unexpected error: 500 Internal Server Error (and the external message is fixed and safe)
In table form:
| Situation | HTTP status | error.code | Body |
|---|---|---|---|
| Task deleted | |
— | absent |
| No such task | |
|
JSON error envelope |
| id is not a number / <= 0 | |
|
JSON error envelope (fields) |
| Internal error | |
|
JSON error envelope |
Why do we do it this way instead of “always 204”? There will be a separate section below, because this is exactly the point where people start arguing as if the fate of civilization depends on it.
4. Implementation: id, domain errors, and the handler
Parsing {id}: why this is 400, not 404
A very common beginner confusion: “if the client sent a weird id, then the task wasn’t found, so 404.” Sounds logical, but these are two different problems.
If the client sent /api/v1/tasks/abc, the server cannot honestly respond “not found,” because it doesn’t even understand what to look for. This is not “the task doesn’t exist,” this is “bad input.” So it is validation → 400.
If the client sent /api/v1/tasks/123, and 123 is a valid number, but there is no such task—that’s an honest not found → 404.
That’s exactly why we introduced a shared parseID earlier: one way to parse an id, the same rules, the same errors. Example (simplified to show the idea; you may already have this helper from previous lectures):
package httpapi
import (
"strconv"
)
func parseID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil || id <= 0 {
return 0, &ValidationError{
Fields: map[string]string{"id": "must be positive integer"},
}
}
return id, nil
}
Notice the “trick”: we return not “just an error,” but specifically a *ValidationError, so that our shared writeError can reliably turn it into 400 validation with fields.
Domain errors: ErrNotFound is normal
At the HTTP layer, we don’t want to know storage details (map, file, database, crystal ball). The HTTP layer should only understand error classes that it can translate into responses. For today, we have three: validation, not_found, internal.
The idea is this: the app layer tries to delete a task. If it wasn’t there—that’s not an “internal error,” it’s a normal domain scenario: “resource not found.” So the app layer returns ErrNotFound (possibly wrapped). This approach is extremely common in Go: an error carries both the class (ErrNotFound) and the context (fmt.Errorf("delete %d: %w", id, ErrNotFound)), and class recognition is done via errors.Is. A similar “map errors to codes (including 404) with a friendly message” idea is often implemented through a centralized handler, so the logic doesn’t get smeared across the codebase.
A mini example of a domain delete (again, in small pieces, the way we like it in training code):
package app
import (
"context"
"fmt"
)
func (a *App) DeleteTask(ctx context.Context, id int) error {
ok := a.store.DeleteByID(id)
if !ok {
return fmt.Errorf("delete task %d: %w", id, ErrNotFound)
}
return nil
}
There are two important points here. First, we don’t return a bool outward: it’s more convenient for the HTTP layer to work with an error, because it already has a shared writeError. Second, we wrap ErrNotFound to preserve context (this helps a lot in logs and diagnostics), but the error class remains recognizable.
HTTP handler: 10 lines of code instead of “just in case”
A good DELETE handler is like good tea: short, clear, and with no surprises floating in it. First we parse the id, then we call the domain operation, then we return 204. Errors go only through the shared writeError.
Let’s start with route registration (Go 1.22+ patterns in ServeMux):
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
// s — our *Server with dependencies (app layer and helpers).
mux.HandleFunc("DELETE /api/v1/tasks/{id}", s.handleDeleteTask)
_ = http.ListenAndServe(":8080", mux)
}
Now the handler itself:
package httpapi
import (
"net/http"
)
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
if err := s.app.DeleteTask(r.Context(), id); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
If you look at these 12 lines and think “is that it?”—yes, that’s it. And that’s a good sign. In Go, handlers are typically linear: error → early return, success → one clear response.
204 No Content: why you must not write a body after it
204 No Content sounds like the server is just too lazy to answer. In fact it’s a strong signal: successful, but there is no body and there must not be one. This is convenient for clients: no need to parse JSON, no need to store “status: deleted,” and the logic is simpler—success is success.
A classic mistake: call WriteHeader(204), and then, out of habit, do json.NewEncoder(w).Encode(...). Sometimes it even “seems to work,” but this is one of those cases where you don’t want to test reality for durability: proxies, clients, and tests can behave differently.
If you want to make the code even more “self-documenting,” you can add a small helper:
package httpapi
import "net/http"
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
And then the handler reads almost like plain text:
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
if err := s.app.DeleteTask(r.Context(), id); err != nil {
writeError(w, err)
return
}
writeNoContent(w)
}
It’s a small thing, but for beginners this kind of “speaking” code is often easier to read.
5. Should you return 204 if the task does not exist
This is where “the most interesting part” begins. Idempotency is about the effect, not the response. Deleting something that is already deleted is still “deleted.” That’s why some APIs do return 204 even if the resource didn’t exist: “after the operation, the resource is gone—what else do you want?”
But for today we explicitly choose a different contract: if the task does not exist, return 404 not_found. Why?
- First, it helps the client distinguish between two scenarios: “we deleted it” vs “there was nothing to delete.” Sometimes this matters for UX. For example, a user deletes a task from a list, but their list was stale: getting a 404 is a useful signal that “it already disappeared.”
- Second, it makes behavior consistent with other {id}-based endpoints. If we have an {id} and the resource does not exist, we always say not_found. That’s easier to explain, easier to test, and easier to maintain.
- Third, it reduces the chance of hiding a bug. Imagine the client is wrong and deletes the wrong id. If the server always responds with 204, the client may not notice for a long time that it’s “shooting blanks.”
At the same time, idempotency does not break, because repeated DELETE calls on a non-existent resource will keep returning the same 404 (the effect is still the same: the resource is absent). And by the way, that’s exactly why it’s important not to produce “random” responses for the same input.
6. Common mistakes
Mistake #1: mixing up “not found” and “bad input.”
When id is not a number or id <= 0, this is not 404 and not “the task doesn’t exist,” it’s an input validation error. If you return 404 for a malformed id, the client will think it “formed the request correctly,” it’s just that the resource isn’t there. But in reality the request was invalid.
Mistake #2: comparing errors with err == ErrNotFound instead of errors.Is.
As soon as you wrap an error via %w, direct comparison breaks. As a result, 404 turns into 500 internal, and the client gets a strange response. Sentinel errors in Go are almost always checked via errors.Is; otherwise, wrapping loses its meaning.
Mistake #3: writing a response body for 204 No Content.
The trickiest part is that sometimes you won’t see the problem immediately: locally everything looks “fine,” and then somewhere in integration a client suddenly stops understanding the response, or tests start failing because of extra bytes. If the status is 204, after WriteHeader it’s better to treat the handler as semantically complete.
Mistake #4: doing “your own” error handling in the DELETE handler instead of using the shared writeError.
It seems like “it’s just one endpoint, we can just write http.Error right here.” But that’s exactly how different error formats spread through a project. Also, http.Error can easily start leaking err.Error() to the outside, and for 500 that’s a bad idea: internal details must stay inside the server. A centralized approach to error mapping and user-friendly messages is standard practice, so you don’t smear logic across the codebase.
GO TO FULL VERSION