Skip to content
gohacks.dev
APIs

Partial Updates in Go APIs With PATCH and Pointers

Implement PATCH in a Go API with merge-patch semantics: tell absent, null and zero apart, apply only the keys sent and reject bad patches before any SQL runs.

12 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

PUT replaces a resource and PATCH changes part of it, and the second is harder to get right than it looks. The question is hiding in the word "part". If the client sends {"name":"Ada L."}, what happens to the bio and the age? If it sends {"bio":null}, does the bio disappear or stay? If it sends {"age":0}, is that a new age or a missing one? A handler that decodes into a struct with ordinary fields cannot answer, and the usual outcome is silent data loss: every field the client left out is overwritten with a zero value.

Tested with Go 1.25.6 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)

This article builds a PATCH handler in Go that answers those questions the same way every time, using the semantics of JSON Merge Patch. It keeps track of which keys were sent, validates each against an allowlist, builds an UPDATE containing only those columns, and rejects a bad request without touching the database. It continues the JSON API cornerstone, which has a simpler PATCH, and goes with the full-replacement approach in CRUD with PUT. The decoding details behind the three states are in telling null, missing and zero apart. Everything ran on Go 1.25.6 with modernc.org/sqlite v1.50.0.

The semantics to promise

JSON Merge Patch, RFC 7396, is the simplest useful definition, and it is what most people mean when they say PATCH on a JSON API. The body is a JSON object that looks like a partial version of the resource, and the rules are:

  • A key that is absent leaves that field as it is.
  • A key with null removes the field, which for a nullable column means setting it to NULL.
  • A key with any other value replaces the field with that value, including 0, false and "".
  • Objects are merged recursively. Arrays are replaced whole.

It is served with Content-Type: application/merge-patch+json, and a server can advertise support with an Accept-Patch header. The alternative is JSON Patch, RFC 6902, a list of operations such as {"op":"replace","path":"/age","value":0}. It is more powerful, handles array edits and supports tests, and it is also more work to implement and to document. For most resources a merge patch is enough, and that is what is built here.

The tricky case is the middle one. To a Go struct with Bio *string, an absent key and an explicit null both leave the pointer nil, so a handler cannot tell "do not touch" from "clear it". There are generic wrapper types that fix this, covered in the null-versus-missing article. The approach here avoids the problem differently: look at the keys directly.

Know which keys were sent

Decoding into map[string]json.RawMessage gives exactly the information needed: one entry per key present in the body, with the raw bytes of its value. null shows up as the four bytes null, a missing key does not appear at all, and 0 is 0. Nothing is lost to zero values because nothing has been converted yet.

profile.goGo
package main

import (
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"sync/atomic"

	_ "modernc.org/sqlite"
)

var dbs atomic.Int64 // each call gets its own in-memory database, so tests cannot see each other

type Profile struct {
	ID   int64   `json:"id"`
	Name string  `json:"name"`
	Bio  *string `json:"bio"`
	Age  *int    `json:"age"`
}

func OpenDB() *sql.DB {
	db, err := sql.Open("sqlite", fmt.Sprintf("file:patch%d?mode=memory&cache=shared", dbs.Add(1)))
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	db.Exec(`CREATE TABLE profiles (id INTEGER PRIMARY KEY, name TEXT NOT NULL, bio TEXT, age INTEGER)`)
	db.Exec(`INSERT INTO profiles (name, bio, age) VALUES ('Ada', 'Mathematician', 36)`)
	return db
}

func Get(db *sql.DB, id int64) (Profile, error) {
	var p Profile
	err := db.QueryRow(`SELECT id, name, bio, age FROM profiles WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.Bio, &p.Age)
	return p, err
}

// setter converts one JSON value into a SQL argument for one column, or explains why it cannot.
type setter func(raw json.RawMessage) (any, error)

func text(max int, nullable bool) setter {
	return func(raw json.RawMessage) (any, error) {
		if string(raw) == "null" {
			if !nullable {
				return nil, errors.New("cannot be null")
			}
			return nil, nil
		}
		var s string
		if err := json.Unmarshal(raw, &s); err != nil {
			return nil, errors.New("must be a string")
		}
		if n := len([]rune(s)); n < 1 || n > max {
			return nil, fmt.Errorf("must be 1 to %d characters", max)
		}
		return s, nil
	}
}

func integer(lo, hi int, nullable bool) setter {
	return func(raw json.RawMessage) (any, error) {
		if string(raw) == "null" {
			if !nullable {
				return nil, errors.New("cannot be null")
			}
			return nil, nil
		}
		var n int
		if err := json.Unmarshal(raw, &n); err != nil {
			return nil, errors.New("must be an integer")
		}
		if n < lo || n > hi {
			return nil, fmt.Errorf("must be between %d and %d", lo, hi)
		}
		return n, nil // zero is a real value and is kept
	}
}

// patchable is the allowlist: only these JSON keys can be changed, each with its own rule and column.
var patchable = map[string]struct {
	column string
	set    setter
}{
	"name": {"name", text(80, false)},
	"bio":  {"bio", text(500, true)},
	"age":  {"age", integer(0, 150, true)},
}

// Patch applies the keys that were sent, and only those. It returns field errors, or nil on success.
func Patch(db *sql.DB, id int64, body map[string]json.RawMessage) map[string]string {
	errs := map[string]string{}
	var sets []string
	var args []any
	for key, raw := range body {
		f, ok := patchable[key]
		if !ok {
			errs[key] = "unknown or read-only field"
			continue
		}
		v, err := f.set(raw)
		if err != nil {
			errs[key] = err.Error()
			continue
		}
		sets = append(sets, f.column+" = ?")
		args = append(args, v)
	}
	if len(errs) > 0 {
		return errs
	}
	if len(sets) > 0 {
		db.Exec(`UPDATE profiles SET `+strings.Join(sets, ", ")+` WHERE id = ?`, append(args, id)...)
	}
	return nil
}

The patchable table is the heart of it. For each public JSON key it names the database column and a setter, a function from the raw value to a SQL argument or an error. name is a non-null string of 1 to 80 characters. bio is a nullable string up to 500. age is a nullable integer from 0 to 150. A setter handles null explicitly, either rejecting it for a required field or returning a nil argument to clear the column, and handles zero by accepting it as a number in range.

Patch loops over the keys that were sent, not over the fields that exist. A key that is not in the table, such as id, gets an error: unknown or read-only field. A key whose value fails its setter gets that setter's message. All errors are collected before anything is written, so a client with three mistakes sees three messages. If there are none, the function builds SET name = ?, age = ? from the keys that were sent and runs one statement. If the body was {} there is nothing to set and no statement runs.

Three properties follow from that structure, and they are the properties a PATCH endpoint should have.

Omitted means untouched. The UPDATE never mentions a column that was not in the body, so it cannot overwrite it.

Column names never come from the client. Only the strings in the table reach the SQL text. The client's keys are used to look things up. This is the same allowlist discipline as in filtering and sorting, and it is also the defence against mass assignment (mass assignment): a client who adds "is_admin": true gets a 422, not a promotion.

Validation precedes the write. If age is out of range, the name in the same request is not applied either. A PATCH is atomic from the client's point of view, which keeps the rule simple to document: either the whole patch applies or none of it does. When several columns are involved, run the single UPDATE inside a transaction with any reads, so the check and the write agree.

What the endpoint returned

The handler wraps Patch: it checks the content type, limits the body to 64 KiB, decodes into the map, and refuses anything that is not a JSON object.

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
)

func Handler(dbPatch func(id int64, body map[string]json.RawMessage) map[string]string, get func(id int64) (Profile, error)) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("PATCH /profiles/{id}", func(w http.ResponseWriter, r *http.Request) {
		if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/merge-patch+json") && !strings.HasPrefix(ct, "application/json") {
			http.Error(w, "send application/merge-patch+json", http.StatusUnsupportedMediaType)
			return
		}
		id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
		var body map[string]json.RawMessage
		if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil || body == nil {
			http.Error(w, "body must be a JSON object", http.StatusBadRequest)
			return
		}
		if errs := dbPatch(id, body); errs != nil {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(422)
			json.NewEncoder(w).Encode(map[string]any{"errors": errs})
			return
		}
		p, err := get(id)
		if err != nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(p)
	})
	return mux
}

func main() {
	db := OpenDB()
	h := Handler(func(id int64, b map[string]json.RawMessage) map[string]string { return Patch(db, id, b) },
		func(id int64) (Profile, error) { return Get(db, id) })

	send := func(label, ct, body string) {
		req := httptest.NewRequest("PATCH", "/profiles/1", strings.NewReader(body))
		req.Header.Set("Content-Type", ct)
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		fmt.Printf("%-34s %d %s", label, rec.Code, rec.Body.String())
		if !strings.HasSuffix(rec.Body.String(), "\n") {
			fmt.Println()
		}
	}
	const mp = "application/merge-patch+json"
	p, _ := Get(db, 1)
	b, _ := json.Marshal(p)
	fmt.Printf("%-34s %s\n", "start", b)
	send(`{"name":"Ada L."}`, mp, `{"name":"Ada L."}`)
	send(`{"age":0}`, mp, `{"age":0}`)
	send(`{} (no changes)`, mp, `{}`)
	send(`{"bio":null}`, mp, `{"bio":null}`)
	send(`{"bio":"Wrote the first program"}`, mp, `{"bio":"Wrote the first program"}`)
	send(`{"name":null}`, mp, `{"name":null}`)
	send(`{"age":"old","id":9}`, mp, `{"age":"old","id":9}`)
	send(`[1,2]`, mp, `[1,2]`)
	send(`wrong content type`, "text/plain", `{}`)

	// The value-field decode that most tutorials show, for comparison.
	var naive struct {
		Name string `json:"name"`
		Bio  string `json:"bio"`
		Age  int    `json:"age"`
	}
	json.Unmarshal([]byte(`{"name":"Ada L."}`), &naive)
	fmt.Printf("\nnaive struct decode of {\"name\":\"Ada L.\"}: %+v  <- bio and age are now zero values to be written\n", naive)
}
Text
start                              {"id":1,"name":"Ada","bio":"Mathematician","age":36}
{"name":"Ada L."}                  200 {"id":1,"name":"Ada L.","bio":"Mathematician","age":36}
{"age":0}                          200 {"id":1,"name":"Ada L.","bio":"Mathematician","age":0}
{} (no changes)                    200 {"id":1,"name":"Ada L.","bio":"Mathematician","age":0}
{"bio":null}                       200 {"id":1,"name":"Ada L.","bio":null,"age":0}
{"bio":"Wrote the first program"}  200 {"id":1,"name":"Ada L.","bio":"Wrote the first program","age":0}
{"name":null}                      422 {"errors":{"name":"cannot be null"}}
{"age":"old","id":9}               422 {"errors":{"age":"must be an integer","id":"unknown or read-only field"}}
[1,2]                              400 body must be a JSON object
wrong content type                 415 send application/merge-patch+json

naive struct decode of {"name":"Ada L."}: {Name:Ada L. Bio: Age:0}  <- bio and age are now zero values to be written

Follow the profile through the requests.

It starts as Ada, a bio of Mathematician and an age of 36. Patching only the name changes the name and nothing else. Patching {"age":0} sets the age to zero, and the bio is still there; zero was treated as a value. An empty object {} returns 200 and the same profile. Patching {"bio":null} clears the bio, which now comes back as null, and a later patch sets a new bio without disturbing the age.

The failures are as informative. {"name":null} is a 422 saying the name cannot be null, because the setter for name refuses it. {"age":"old","id":9} produces two errors in one response, one for the wrong type and one for the attempt to set id. A body that is a JSON array is a 400, and a wrong content type is a 415.

The last line is the comparison that explains the whole design. Decoding {"name":"Ada L."} into a struct with plain Bio string and Age int produces Bio: "" and Age: 0. A handler that then writes the struct would blank the bio and zero the age, silently, for a request that only mentioned the name. This is by far the most common PATCH bug in Go APIs, and nothing flags it, because the compiler and the happy-path test both pass.

Tests

patch_test.goGo
package main

import (
	"encoding/json"
	"testing"
)

func body(s string) map[string]json.RawMessage {
	var m map[string]json.RawMessage
	json.Unmarshal([]byte(s), &m)
	return m
}

func TestOmittedFieldsAreUntouched(t *testing.T) {
	db := OpenDB()
	Patch(db, 1, body(`{"name":"X"}`))
	p, _ := Get(db, 1)
	if p.Name != "X" || p.Bio == nil || *p.Bio != "Mathematician" || p.Age == nil || *p.Age != 36 {
		t.Fatalf("%+v", p)
	}
}

func TestNullClearsAndZeroIsKept(t *testing.T) {
	db := OpenDB()
	Patch(db, 1, body(`{"bio":null,"age":0}`))
	p, _ := Get(db, 1)
	if p.Bio != nil || p.Age == nil || *p.Age != 0 {
		t.Fatalf("bio=%v age=%v", p.Bio, p.Age)
	}
}

func TestOneBadFieldRejectsTheWholePatch(t *testing.T) {
	db := OpenDB()
	errs := Patch(db, 1, body(`{"name":"Changed","age":999}`))
	p, _ := Get(db, 1)
	if len(errs) != 1 || p.Name != "Ada" {
		t.Fatalf("errs=%v name=%q", errs, p.Name)
	}
}

func TestReadOnlyFieldsCannotBeSet(t *testing.T) {
	db := OpenDB()
	if errs := Patch(db, 1, body(`{"id":7}`)); errs["id"] == "" {
		t.Fatal("id was accepted")
	}
}
Text
=== RUN   TestOmittedFieldsAreUntouched
--- PASS: TestOmittedFieldsAreUntouched (0.01s)
=== RUN   TestNullClearsAndZeroIsKept
--- PASS: TestNullClearsAndZeroIsKept (0.00s)
=== RUN   TestOneBadFieldRejectsTheWholePatch
--- PASS: TestOneBadFieldRejectsTheWholePatch (0.00s)
=== RUN   TestReadOnlyFieldsCannotBeSet
--- PASS: TestReadOnlyFieldsCannotBeSet (0.00s)
PASS
ok  	example.test/lab	1.372s

The four tests state the contract. Omitted fields are untouched. Null clears and zero is kept. One bad field rejects the whole patch and the stored name remains Ada. Read-only fields cannot be set. Each test uses its own in-memory database: I learned that the hard way when a shared one let an earlier test's write leak into a later test and fail the third, which is a good reminder that state shared between tests is a bug that looks like a PATCH problem.

Details worth deciding

Return the updated resource. The handler responds with the stored profile, so the client sees any server-side normalisation. A 204 with no body is also legitimate. Choose one and use it for every PATCH.

Use conditional requests for concurrent editors. Two people patching different fields of the same resource do not conflict under merge-patch semantics, which is a pleasant property, but two people patching the same field overwrite each other. If that matters, require If-Match with the resource's ETag or version, as shown in CRUD with optimistic locking, and answer 412 or 428.

Idempotency. A merge patch that sets fields to fixed values is idempotent in effect, since applying it twice gives the same state. It stops being so if the patch is relative, which merge patch cannot express, or if the server stamps updated_at. PATCH is not required to be idempotent by HTTP; when retries matter, use an idempotency key.

Nested objects and arrays. Merge patch merges nested objects recursively and replaces arrays whole. If a resource has a list of tags and a client wants to add one, they must send the entire list. That surprises people and is an honest reason to choose JSON Patch for resources with large arrays. For nested objects, apply the same allowlist idea recursively, or flatten the nested object into separate top-level fields.

Validation errors. Return them as a problem document with a pointer per field (problem details). The errors map in this example is the shortened form.

Do not use PATCH to hide actions. A change such as "cancel this order" is better as its own endpoint (POST /orders/{id}/cancel) than as PATCH {"status":"cancelled"}, because the action has rules and side effects. Allow PATCH on fields that are plain data.

Document the semantics. State in the reference that absent keys are untouched and null clears, and give an example of each. This is the thing clients get wrong when they guess.

The HTTP definition of the method is in RFC 5789, and the decoding types used here are documented in the encoding/json package.

Also inDatabases
Taggedjson
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.