Implementing JSON Patch in REST APIs: A Practical Guide
A practical, production-ready guide to implementing JSON Patch (RFC 6902) in REST APIs with HTTP semantics, validation, and code examples.
Image used for representation purposes only.
Overview
JSON Patch (RFC 6902) is a standardized, expressive format for partial updates of JSON documents over HTTP. Instead of sending an entire resource with PUT, clients submit a precise list of operations—add, remove, replace, move, copy, and test—targeted at specific locations. Combined with HTTP PATCH, ETags, and robust validation, JSON Patch enables efficient, safe, and auditable updates at scale.
This guide explains how JSON Patch works, how to design a solid REST interface around it, common pitfalls (especially with arrays), and production-ready implementation patterns with example snippets in Node.js, .NET, Python, and Java.
JSON Patch in 60 seconds
- Specification: RFC 6902 (JSON Patch) builds on RFC 6901 (JSON Pointer paths).
- Media type:
application/json-patch+json. - Document structure: an ordered array of operations applied atomically (all-or-nothing) to a single JSON document.
Example patch that changes a title, adds a tag, and removes a deprecated field:
[
{ "op": "replace", "path": "/title", "value": "New title" },
{ "op": "add", "path": "/tags/-", "value": "release-1.2" },
{ "op": "remove", "path": "/legacyField" }
]
Operations and JSON Pointer basics
Each operation has an op and a path. Some also require from or value:
add: Insertsvalueatpath. For arrays,-appends.remove: Deletes the value atpath.replace: Equivalent toremovethenaddat the same path.move: Moves a value fromfromtopath.copy: Copies a value fromfromtopath.test: Asserts the value atpathequalsvalue; if not, the patch fails.
JSON Pointer rules you’ll use in path and from:
/a/b/0navigates object keya, thenb, then array index 0.~1represents/and~0represents~inside keys (escape sequences).
PATCH semantics and headers
- Method:
PATCH /resources/{id} - Content-Type:
application/json-patch+json - Conditional updates: Strongly recommend optimistic concurrency with
ETagandIf-Matchto avoid lost updates. - Responses:
204 No Contentwhen no response body is needed.200 OKwith the updated representation.409 Conflictif a precondition or domain rule fails.
Example conditional flow:
GET /articles/123 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
ETag: "W/\"v7\""
Content-Type: application/json
{ "id": 123, "title": "Old", "tags": ["news"] }
PATCH /articles/123 HTTP/1.1
Content-Type: application/json-patch+json
If-Match: "W/\"v7\""
[
{ "op": "replace", "path": "/title", "value": "New" },
{ "op": "add", "path": "/tags/-", "value": "featured" }
]
HTTP/1.1 200 OK
ETag: "W/\"v8\""
Content-Type: application/json
{ "id": 123, "title": "New", "tags": ["news", "featured"] }
JSON Patch vs. JSON Merge Patch
- JSON Patch (RFC 6902): A sequence of precise operations; supports arrays reliably; supports test/move/copy.
- JSON Merge Patch (RFC 7396): A partial document that is merged with target; simpler but ambiguous for arrays.
Rule of thumb:
- Use JSON Patch when you need exact control, array edits, preconditions (
test), or auditable changes. - Use Merge Patch for simple, object-only partial updates where arrays are replaced entirely.
Designing your API for patching
- Resource boundaries: Apply a patch to a single resource/document. Avoid spanning multiple root resources in one request.
- Atomicity: Apply all operations within a transaction. If any op fails, fail the entire patch.
- Field policy: Whitelist patchable paths; some fields (e.g.,
id, server-generated timestamps) should be immutable. - Concurrency: Require
If-MatchwithETagfor state-changing PATCH requests. - Idempotency: Individual patches may not be idempotent (e.g.,
addto/-), but conditional requests keep correctness. - Caching: PATCH is not cacheable by default; rely on GET with ETags for efficiency.
Validation and security
Harden your implementation:
- Schema validation: Validate the document against JSON Schema after applying the patch; reject on violations.
- Path validation: Verify that every
pathandfromtargets an allowed field. - Bounds checks: Validate array indices; reject negative or out-of-range indices.
- Type checks:
replacemust respect existing field types if your domain requires it. testas guard: Encourage clients to usetestfor invariants (e.g., ensure a status is still “draft”).- Resource size limits: Enforce request body size and op count limits to prevent DoS.
- Redaction: Disallow patching into sensitive paths (password hashes, secrets) entirely.
Arrays: the sharp edges
- Appending: Use
"/array/-"to append. - Index sensitivity:
replaceat/items/1depends on the current array order. Combine with ETag ortestto ensure intent. - Object identity: Prefer array elements with stable IDs and expose them as sub-resources, or patch by searching with server logic.
- Move/copy: When moving across arrays, revalidate both source and destination bounds.
Example of guarded array update with test:
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/items/1/name", "value": "Pro" }
]
Error handling and problem details
Adopt precise errors with Problem Details (application/problem+json):
400 Bad Request: Malformed JSON Patch document or invalid operation shape.409 Conflict: Business rule violations (e.g., cannot move item from an empty list).412 Precondition Failedor428 Precondition Required: Missing/failedIf-Match.415 Unsupported Media Type: Missingapplication/json-patch+json.422 Unprocessable Entity: Schema validation failed after applying the patch.
Problem example:
{
"type": "https://api.example.com/errors/invalid-path",
"title": "Invalid path",
"status": 400,
"detail": "Path '/owner/ssn' is not patchable.",
"instance": "/articles/123"
}
Implementation examples
Below are minimal server-side sketches. Always add authentication, authorization, logging, and transactional safety in production.
Node.js (Express)
import express from 'express';
import * as jsonpatch from 'fast-json-patch'; // a.k.a. 'jsonpatch'
const app = express();
app.use(express.json({ type: 'application/json-patch+json' }));
app.patch('/articles/:id', async (req, res) => {
const etag = req.headers['if-match'];
if (!etag) return res.status(428).end();
const doc = await loadArticle(req.params.id); // returns { data, etag }
if (doc.etag !== etag) return res.status(412).end();
// Validate ops against a whitelist
for (const op of req.body) {
if (!op.path.startsWith('/title') && !op.path.startsWith('/tags'))
return res.status(400).json({ title: 'Invalid path' });
}
const draft = JSON.parse(JSON.stringify(doc.data));
try {
jsonpatch.applyPatch(draft, req.body, /*validate*/ true);
} catch (e) {
return res.status(400).json({ title: 'Patch failed', detail: e.message });
}
// Validate with JSON Schema here...
const saved = await saveArticle(req.params.id, draft); // returns new etag
res.set('ETag', saved.etag).status(200).json(draft);
});
.NET (ASP.NET Core)
[HttpPatch("/articles/{id}")]
public async Task<IActionResult> Patch(int id, [FromBody] JsonPatchDocument<Article> patchDoc)
{
if (patchDoc == null) return BadRequest();
var (article, etag) = await repo.GetAsync(id);
if (!Request.Headers.TryGetValue("If-Match", out var match) || match != etag)
return StatusCode(StatusCodes.Status412PreconditionFailed);
patchDoc.ApplyTo(article, ModelState); // validates paths
if (!TryValidateModel(article))
return UnprocessableEntity(ModelState);
var newEtag = await repo.SaveAsync(article);
Response.Headers.ETag = newEtag;
return Ok(article);
}
Python (FastAPI)
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
import jsonpatch
app = FastAPI()
@app.patch('/articles/{id}', openapi_extra={'requestBody': {'content': {'application/json-patch+json': {}}}})
async def patch_article(id: int, patch_ops: list, if_match: str = Header(None)):
if not if_match:
raise HTTPException(status_code=428)
doc, etag = await load_article(id)
if etag != if_match:
raise HTTPException(status_code=412)
try:
patched = jsonpatch.apply_patch(doc, patch_ops, in_place=False)
except jsonpatch.JsonPatchException as e:
return JSONResponse(status_code=400, content={"title": "Patch failed", "detail": str(e)})
# run JSON Schema validation here
new_etag = await save_article(id, patched)
return JSONResponse(status_code=200, content=patched, headers={"ETag": new_etag})
Java (JAX-RS + a JSON Patch lib)
@PATCH
@Path("/articles/{id}")
@Consumes("application/json-patch+json")
public Response patch(@PathParam("id") String id, JsonArray patch, @HeaderParam("If-Match") String ifMatch) {
if (ifMatch == null) return Response.status(428).build();
var doc = repo.get(id); // contains entity and etag
if (!doc.getEtag().equals(ifMatch)) return Response.status(412).build();
try {
var patched = JsonPatch.fromJson(patch).apply(doc.getEntity());
// validate patched entity
var newEtag = repo.save(id, patched);
return Response.ok(patched).header("ETag", newEtag).build();
} catch (Exception e) {
return Response.status(400).entity(Map.of("title", "Patch failed", "detail", e.getMessage())).build();
}
}
Note: Choose a well-maintained JSON Patch library; ensure it honors RFC 6902 and validates operations.
OpenAPI documentation
Document PATCH endpoints clearly to drive client generation and testing.
paths:
/articles/{id}:
patch:
summary: Partially update an article using JSON Patch
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: If-Match
in: header
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json-patch+json:
schema:
type: array
items:
$ref: '#/components/schemas/JsonPatchOperation'
responses:
'200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Article' } } } }
'204': { description: Updated with no content }
'400': { description: Invalid patch document }
'412': { description: Precondition failed }
'422': { description: Validation failed }
components:
schemas:
JsonPatchOperation:
type: object
required: [op, path]
properties:
op: { type: string, enum: [add, remove, replace, move, copy, test] }
path: { type: string }
from: { type: string }
value: { }
End-to-end workflow checklist
- GET the resource and capture
ETag. - Build a minimal patch reflecting intended changes; include
testops for invariants when useful. - PATCH with
Content-Type: application/json-patch+jsonandIf-Match. - On the server: authenticate, authorize, validate ops, apply in a transaction, schema-validate result, persist, emit new
ETag. - Return
200or204; clients can re-GET if they need a fresh representation.
Observability and auditability
- Log each op with user identity, timestamp, request ID, and resulting version.
- Expose correlation IDs for cross-service tracing.
- Consider a change-history subresource or event stream for compliance-heavy domains.
Common pitfalls and how to avoid them
- Missing concurrency control: Require
If-Match. - Over-permissive paths: Implement a whitelist and field-level authorization.
- Array index drift: Use stable IDs or pair with
teston adjacent context. - Silent type changes: Validate domain invariants post-patch.
- Large patches: Limit size/op count; consider batching via domain-specific commands instead.
When not to use JSON Patch
- Bulk cross-resource edits that must succeed or fail together—prefer a domain-specific batch command endpoint.
- Streaming/real-time partial updates—consider WebSockets or event-driven approaches.
- Non-JSON payloads—JSON Patch is for JSON documents only.
Final tips
- Be explicit: Enforce content types, preconditions, and validation.
- Be safe: Treat
testas a first-class feature for guard rails. - Be clear: Document patchable fields and provide examples in your API docs.
- Be observable: Audit every applied patch with who, what, and why.
With these patterns, you can implement JSON Patch that is efficient, correct, secure, and pleasant for clients to use.
Related Posts
A Practical Guide to Integrating OpenTelemetry with REST APIs
Instrument REST APIs with OpenTelemetry: traces, metrics, logs, context propagation, code examples (Node.js, Python, Java, Go), Collector, and best practices.
Designing a Robust REST API Response Envelope: Patterns, Pitfalls, and Practical Examples
A practical guide to the REST API response envelope pattern: design, errors, pagination, performance tips, and when to use or skip it.
AI Object Detection API on Mobile: A Practical, End-to-End Tutorial
Build an Android and iOS app that streams camera frames to an AI object detection API, draws real-time boxes, and ships with production-ready patterns.