Skip to content

How-to: PATCH Partial Update (JSON Merge Patch) ​

FT reference: FT326 (NENE2-FT/patchlog) — JSON Merge Patch (RFC 7396) partial update: null field reset, immutable field rejection, ETag/If-Match, owner-only mutation, 42 tests / 141 assertions PASS.

This guide shows how to implement a PATCH endpoint following JSON Merge Patch semantics: only provided fields are updated, null resets to default, and immutable fields are rejected.

Schema ​

sql
CREATE TABLE documents (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    owner_id   INTEGER NOT NULL,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL DEFAULT '',
    status     TEXT    NOT NULL DEFAULT 'draft',
    version    INTEGER NOT NULL DEFAULT 1,
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

Endpoints ​

MethodPathDescription
POST/documentsCreate (requires X-User-Id)
GET/documentsList
GET/documents/{id}Get with ETag header
PATCH/documents/{id}Partial update (requires X-User-Id)
DELETE/documents/{id}Delete (owner only)

Create ​

php
POST /documents  X-User-Id: 1
{"title": "My Doc", "body": "Content"}
→ 201  {"id": 1, "owner_id": 1, "title": "My Doc", "status": "draft", "version": 1}

// No X-User-Id → 401
// Missing title → 422
// Empty title  → 422
// body is optional → defaults to ""

GET with ETag ​

php
GET /documents/1
→ 200  ETag: "doc-1-1"
{"id": 1, "title": "My Doc", "version": 1, ...}

ETag format: "doc-{id}-{version}".

PATCH — JSON Merge Patch Semantics ​

php
// Update only title — body unchanged
PATCH /documents/1  X-User-Id: 1
{"title": "Updated"}
→ 200  {"title": "Updated", "body": "Content", ...}

// Update body only
PATCH /documents/1  X-User-Id: 1
{"body": "New content"}
→ 200  {"title": "Updated", "body": "New content", ...}

// Empty {} — no-op (valid per RFC 7396 §3)
PATCH /documents/1  X-User-Id: 1
{}
→ 200  (unchanged document)

// null resets field to default
PATCH /documents/1  X-User-Id: 1
{"status": null}
→ 200  {"status": "draft"}   // reset to default

Immutable Fields — Rejected ​

Some fields must never be changed via PATCH:

php
PATCH /documents/1  {"id": 999}         → 422  // immutable
PATCH /documents/1  {"owner_id": 99}    → 422  // immutable
PATCH /documents/1  {"version": 999}    → 422  // immutable
PATCH /documents/1  {"created_at": "…"} → 422  // immutable

Owner-Only Authorization ​

php
// User 2 tries to patch user 1's document → 404 (not 403, to prevent enumeration)
PATCH /documents/1  X-User-Id: 2  {"title": "Stolen"}  → 404

// Owner can always patch their own
PATCH /documents/1  X-User-Id: 1  {"title": "Mine"}    → 200

ETag / If-Match ​

php
// Conditional PATCH — 412 if version changed
PATCH /documents/1  X-User-Id: 1  If-Match: "doc-1-1"
{"title": "Updated"}
→ 200  // if version still 1

PATCH /documents/1  X-User-Id: 1  If-Match: "doc-1-1"
{"title": "Stale"}
→ 412  // if version is now 2

Type Validation ​

php
PATCH /documents/1  {"title": 123}   → 422  // int instead of string
PATCH /documents/1  {"body": [1,2]}  → 422  // array instead of string

What NOT to do ​

Anti-patternRisk
Treat missing field same as nullCaller cannot clear a field; undefined ≠ null in Merge Patch
Allow patching owner_idOwnership transfer via API without authorization flow
Return 403 for cross-owner accessReveals existence of document; return 404 instead
Replace entire document on PATCHOverwrites fields client didn't intend to change
Accept immutable fields silently (no-op)Client believes they changed id; silent failure causes confusion

Released under the MIT License.