Chapter 20 got POST /tasks and GET /tasks working against the in-memory store. Now we finish the remaining CRUD operations for a single task: GET, PUT, and DELETE on /tasks/{id}.
The get handler has answered every GET /tasks/{id} request with a 501 Not Implemented stub since Chapter 19. We will replace it with a real handler, then add update and delete for the same resource. Once these three routes work, the in-memory task API will support every operation a REST client expects on a single task.

Here is what changes in this chapter:
GET /tasks/{id} returns the task with 200 OK, or 404 Not Found if no task has that ID.PUT /tasks/{id} replaces the whole task and returns it with , or if it does not exist.200 OK404DELETE /tasks/{id} removes the task and returns 204 No Content with an empty body, or 404 if it does not exist.{id}, like /tasks/abc, is a 400 Bad Request on all three routes.Sentinel error: a specific, predeclared error value that callers check for by identity rather than by parsing a message string. ErrNotFound is one example. The store returns it whenever an ID doesn't exist, and handlers compare against it using errors.Is. We first saw this pattern in Chapter 12.
204 No Content: the status code for "the request succeeded, and there is deliberately nothing to send back." DELETE uses it because there is no updated resource to return, unlike PUT, which returns 200 OK.
Full replace vs. partial update: PUT takes the request body and replaces the entire task with it. Any field you leave out is gone. A partial update, where you only change the fields you send, is what PATCH is for. We don't implement PATCH in this course, but it is worth knowing it exists for exactly that case.
A missing task and a malformed ID are different failures, and clients need to tell them apart. 404 means "your request was well-formed, but nothing exists at that address." 400 means "I couldn't even understand your request." An ID like abc never reaches the store at all because strconv.Atoi fails before we look anything up.
The store and the handler split this work cleanly. Store.Get, Store.Update, and Store.Delete know nothing about HTTP. They just return ErrNotFound when a map lookup misses. The handler is the only place that knows ErrNotFound means 404. This is the same separation of concerns from Chapter 12. Errors are values the store hands back, and the handler decides what to do with them. If we ever added a CLI or a gRPC server on top of the same store, neither the sentinel error nor the store's logic would need to change. Only the mapping to a status code would change.
200 vs 204 follows a similar logic. GET and PUT both return 200 OK because they hand back a JSON body the caller can use. DELETE returns 204 No Content because after a task is gone, there is nothing left to describe.
internal/task/store.go adds a sentinel error and three new methods. All three are guarded by the same sync.Mutex as Create and List:
go// internal/task/store.govar ErrNotFound = errors.New("task not found")func (s *Store) Get(_ context.Context, id int) (Task, error) {s.mu.Lock()defer s.mu.Unlock()t, ok := s.tasks[id]if !ok {return Task{}, ErrNotFound}return t, nil}func (s *Store) Update(_ context.Context, id int, t Task) (Task, error) {s.mu.Lock()defer s.mu.Unlock()existing, ok := s.tasks[id]if !ok {return Task{}, ErrNotFound}t.ID = existing.IDt.CreatedAt = existing.CreatedAts.tasks[id] = treturn t, nil}func (s *Store) Delete(_ context.Context, id int) error {s.mu.Lock()defer s.mu.Unlock()if _, ok := s.tasks[id]; !ok {return ErrNotFound}delete(s.tasks, id)return nil}
Get is a plain map lookup. The comma-ok form tells us whether the key exists, and we return ErrNotFound if it doesn't. Delete does the same check before calling the builtin delete on the map.
Update requires a closer look. It looks up the existing task first. That lookup is also how we check the task exists before overwriting anything. Then it copies existing.ID and existing.CreatedAt onto the incoming t before storing it. This makes PUT a full replace without accidentally changing a task's ID or creation time. The server always owns those two fields, no matter what the client sends.
store.go now imports errors for the sentinel, alongside the context, sync, and time imports from Chapter 20.
internal/task/handler.go registers the three new routes and adds get, update, and delete:
go// internal/task/handler.gofunc (h *Handler) Routes(mux *http.ServeMux) {mux.HandleFunc("GET /tasks", h.list)mux.HandleFunc("POST /tasks", h.create)mux.HandleFunc("GET /tasks/{id}", h.get)mux.HandleFunc("PUT /tasks/{id}", h.update)mux.HandleFunc("DELETE /tasks/{id}", h.delete)}func (h *Handler) get(w http.ResponseWriter, r *http.Request) {id, err := strconv.Atoi(r.PathValue("id"))if err != nil {http.Error(w, "invalid task id", http.StatusBadRequest)return}t, err := h.store.Get(r.Context(), id)if err != nil {if errors.Is(err, ErrNotFound) {http.Error(w, "task not found", http.StatusNotFound)return}http.Error(w, "failed to get task", http.StatusInternalServerError)return}writeJSON(w, http.StatusOK, t)}func (h *Handler) update(w http.ResponseWriter, r *http.Request) {id, err := strconv.Atoi(r.PathValue("id"))if err != nil {http.Error(w, "invalid task id", http.StatusBadRequest)return}var t Taskif err := json.NewDecoder(r.Body).Decode(&t); err != nil {http.Error(w, "invalid request body", http.StatusBadRequest)return}updated, err := h.store.Update(r.Context(), id, t)if err != nil {if errors.Is(err, ErrNotFound) {http.Error(w, "task not found", http.StatusNotFound)return}http.Error(w, "failed to update task", http.StatusInternalServerError)return}writeJSON(w, http.StatusOK, updated)}func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {id, err := strconv.Atoi(r.PathValue("id"))if err != nil {http.Error(w, "invalid task id", http.StatusBadRequest)return}if err := h.store.Delete(r.Context(), id); err != nil {if errors.Is(err, ErrNotFound) {http.Error(w, "task not found", http.StatusNotFound)return}http.Error(w, "failed to delete task", http.StatusInternalServerError)return}w.WriteHeader(http.StatusNoContent)}
All three handlers start the same way. strconv.Atoi(r.PathValue("id")) turns the {id} wildcard from Chapter 16 into an int, and a conversion failure is an immediate 400. This is the id-parsing step that guards every route. The store never sees a non-numeric ID.
Past that point, each handler calls its store method and checks the returned error with errors.Is(err, ErrNotFound). A match means 404. Anything else falls through to 500 Internal Server Error. This fallback never actually happens with this in-memory store, but it will once a database can fail. This if errors.Is(...) { 404 } else { 500 } shape repeats three times because each handler owns exactly one HTTP decision, not because the logic is complicated.
update also decodes the request body the same way create does, then hands the decoded Task to Store.Update, which does the actual field-preserving work. delete has no body to decode. It just calls Store.Delete and, on success, writes w.WriteHeader(http.StatusNoContent) with nothing after it. There is no writeJSON call and no body. 204 means there is nothing to send.
One small naming note: the handler's delete method doesn't collide with the delete builtin used inside store.go. Methods live in a type's method set, not in package scope. This means h.delete and delete(s.tasks, id) are two entirely different things that happen to share a name.
Boot the server and create a task, just like in Chapter 20:
bashmake run
bashcurl -i -X POST -d '{"title":"Write the chapter","description":"Draft chapter 21"}' localhost:8080/tasks
That gives you a task with id: 1. Now exercise all three new routes against it:
bashcurl -i localhost:8080/tasks/1curl -i localhost:8080/tasks/999curl -i -X PUT -d '{"title":"Ship the chapter","done":true}' localhost:8080/tasks/1curl -i -X DELETE localhost:8080/tasks/1curl -i localhost:8080/tasks/abc

The GET /tasks/1 call finds the task and returns 200 with its full JSON. GET /tasks/999 returns 404 because no task has that ID. The PUT call sends a body with only title and done, leaving out description. The response drops description entirely, because a full replace means whatever you don't send is gone. The id and createdAt fields survive unchanged, exactly as Update promises. DELETE /tasks/1 returns 204 with an empty body, and GET /tasks/abc returns 400 before the store is ever touched.
200 with an empty or zero-valued body. A client checking response.ok would think the request worked. Always check the sentinel error and return 404 explicitly.DELETE. Returning 200 with a body for a delete isn't wrong exactly, but there is nothing meaningful to put in that body. 204 says what actually happened: success, no content.Update reads existing by value, not by reference. s.tasks[id] returns a copy of the Task struct. If you changed existing.Title directly, you would be modifying a local copy that gets thrown away, and nothing in the map would change. The code instead builds the final version on t (the decoded body) and assigns it back to s.tasks[id]. That assignment is the only line that actually updates the store.The in-memory CRUD API is now complete: create, list, get, update, delete. Chapter 22 adds request validation and a consistent JSON error shape, replacing the bare http.Error text we have been using since Chapter 20.