PATCH — Partial Updates
Chapter 3's updateTask handles PUT /tasks/{id}: the client sends a
complete task representation, and the handler replaces the stored value
outright. That's fine when the client always has the full picture, but it's
wasteful — and dangerous — when it doesn't. A mobile client that only wants
to flip done to true shouldn't have to first GET the task, copy every
other field back verbatim, and hope nothing changed underneath it between
the read and the write. PATCH exists for exactly this case: send only the
fields you want to change, and leave everything else alone.
The problem PUT can't express
The difficulty isn't semantic — "only touch what's provided" is easy to
say. It's structural. PUT's handler decodes into a Task-shaped struct
with plain string/bool/int fields, and Go's JSON decoder leaves any
field whose key is missing from the body at its zero value. That's exactly
the ambiguity PATCH can't tolerate: {"done":false} and {} (or
{"title":"same title"}) would decode to an identical struct, Done: false, Title: "". There's no way to look at that struct afterward and
tell "the client explicitly set done to false" apart from "the client
never mentioned done at all." A merge that can't make that distinction
either always applies the zero value (clobbering fields the client never
touched) or never applies it (making it impossible to explicitly set
something back to false or empty).
Pointers make "absent" a value
The JSON in Depth chapter covered the fix: give the decode target pointer
fields instead of value fields. encoding/json only allocates and sets a
pointer field when its key is present in the body; an omitted key leaves the
pointer nil. That turns "was this key present?" into a check you can
actually write:
var in struct {
Title *string `json:"title"`
Done *bool `json:"done"`
Priority *int `json:"priority"`
}
Now {"done":false} produces Done: &false — a non-nil pointer to the zero
value — while {} leaves Done: nil. Those are distinguishable, which is
the entire point: nil means "the client didn't mention this field, don't
touch it," and non-nil means "the client set this, apply it, even if the
value is the type's zero value."
Load, merge, save
With that struct decoded, patchTask follows a three-step shape: load the
current task, apply only the non-nil fields on top of it, save the merged
result.
- Load —
store.Get(r.PathValue("id")). If the task doesn't exist,404immediately; there's nothing to merge into. - Merge — for each pointer field that isn't
nil, overwrite the corresponding field on the loadedTask. A field leftnilmeans the loaded value passes through untouched. - Save — hand the fully merged
Task's fields tostore.Update, which still takes wholestring/bool/intvalues, not pointers.
That last point is worth noticing: Update's signature doesn't change.
The store doesn't need to know anything about partial updates — it always
writes a complete Task. The merging — deciding what "complete" means for
this particular request — is entirely the handler's job, done once, in
memory, before Update is ever called. Keeping that logic out of the store
is what let Chapter 3's PUT handler and this PATCH handler share the
same Update method without either one compromising the other's contract.
What's next
That completes the API surface this Part set out to build: list, get,
create, update, delete, and now partial update, all wired through a single
server. One honest gap to notice: we call validate only on create — a
real service would run the same checks on PUT and PATCH too (the values a
client sends there deserve exactly as much suspicion), it's just left out
here to avoid repeating the pattern you already met. Part 3 moves up a layer
— middleware, auth, and timeouts — the concerns that wrap around every
handler rather than living inside any one of them.
Practice
1. PATCH — Partial Updates
IntermediatePUTreplaces a whole task;PATCHchanges only the fields the client sends. The trick is telling "field omitted" apart from "field set to its zero value" — a*string/*bool/*intthat staysnilwhen the key is absent (the pointer-optionality pattern from the JSON in Depth chapter).Implement
(s *server) patchTask(w, r):- Load the current task (
store.Get);404if it doesn't exist. - Decode the body into a struct whose fields are pointers
(
Title *string,Done *bool,Priority *int). - For each field that isn't
nil, overwrite that field on the current task. - Save with
store.Updateand respond200with the merged task.
So
{"done":true}flipsdoneand leaves title and priority exactly as they were.package main import ( "encoding/json" "net/http" "strconv" "sync" ) type Task struct { ID string `json:"id"` Title string `json:"title"` Done bool `json:"done"` Priority int `json:"priority"` } // Store is the persistence seam the HTTP layer depends on. *TaskStore // satisfies it; a database-backed store could too, without touching handlers. type Store interface { Create(title string, priority int) Task Get(id string) (Task, bool) List() []Task Update(id, title string, done bool, priority int) (Task, bool) Delete(id string) bool } type TaskStore struct { mu sync.Mutex items map[string]Task order []string nextID int } func NewTaskStore() *TaskStore { return &TaskStore{items: make(map[string]Task), nextID: 1} } func (s *TaskStore) Create(title string, priority int) Task { s.mu.Lock() defer s.mu.Unlock() id := strconv.Itoa(s.nextID) s.nextID++ t := Task{ID: id, Title: title, Done: false, Priority: priority} s.items[id] = t s.order = append(s.order, id) return t } func (s *TaskStore) Get(id string) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.items[id] return t, ok } func (s *TaskStore) List() []Task { s.mu.Lock() defer s.mu.Unlock() out := make([]Task, 0, len(s.order)) for _, id := range s.order { out = append(out, s.items[id]) } return out } func (s *TaskStore) Update(id, title string, done bool, priority int) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.items[id]; !ok { return Task{}, false } t := Task{ID: id, Title: title, Done: done, Priority: priority} s.items[id] = t return t, true } func (s *TaskStore) Delete(id string) bool { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.items[id]; !ok { return false } delete(s.items, id) for i, oid := range s.order { if oid == id { s.order = append(s.order[:i], s.order[i+1:]...) break } } return true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return false } return true } type server struct { store Store } func (s *server) patchTask(w http.ResponseWriter, r *http.Request) { // TODO: load the current task with s.store.Get(r.PathValue("id")), // writing a 404 if it doesn't exist. Decode the body into a struct of // pointer fields (*string/*bool/*int) and overwrite only the ones that // aren't nil on the current task. Save with s.store.Update and respond // 200 with the result. } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Load the current task (