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.

ASOasis
8 min read
Implementing JSON Patch in REST APIs: A Practical Guide

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: Inserts value at path. For arrays, - appends.
  • remove: Deletes the value at path.
  • replace: Equivalent to remove then add at the same path.
  • move: Moves a value from from to path.
  • copy: Copies a value from from to path.
  • test: Asserts the value at path equals value; if not, the patch fails.

JSON Pointer rules you’ll use in path and from:

  • /a/b/0 navigates object key a, then b, then array index 0.
  • ~1 represents / and ~0 represents ~ 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 ETag and If-Match to avoid lost updates.
  • Responses:
    • 204 No Content when no response body is needed.
    • 200 OK with the updated representation.
    • 409 Conflict if 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-Match with ETag for state-changing PATCH requests.
  • Idempotency: Individual patches may not be idempotent (e.g., add to /-), 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 path and from targets an allowed field.
  • Bounds checks: Validate array indices; reject negative or out-of-range indices.
  • Type checks: replace must respect existing field types if your domain requires it.
  • test as guard: Encourage clients to use test for 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: replace at /items/1 depends on the current array order. Combine with ETag or test to 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 Failed or 428 Precondition Required: Missing/failed If-Match.
  • 415 Unsupported Media Type: Missing application/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 test ops for invariants when useful.
  • PATCH with Content-Type: application/json-patch+json and If-Match.
  • On the server: authenticate, authorize, validate ops, apply in a transaction, schema-validate result, persist, emit new ETag.
  • Return 200 or 204; 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 test on 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 test as 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