JSON Toolkit

JSON Patch in Production: Atomicity, Idempotency, and Concurrent Conflicts

Bottom line: JSON Patch is only a change format; atomicity, idempotency and concurrency are still your job. Covers test preconditions, path escaping, rollback and ETag coordination.

Advanced~10 min readPublished 2026-10-31Updated 2026-10-31
Cover for JSON Patch in Production

Bottom line: validate the patch's target paths and permissions first, then add a concurrency assertion with test or If-Match; apply the patch to a copy so a failure leaves nothing half-written.

The real danger of a PATCH request is not writing the wrong field. It is believing the write succeeded while silently overwriting someone else's change. JSON Patch gives you the tools, but the safety design is still yours.

1. A Quick Review of the RFC 6902 Operation Set

JSON Patch describes changes as an array of operations, with the media type application/json-patch+json. Six operations execute in array order:

Operation Effect Key constraint
add Add a member or insert an array element An array index may equal the length; - means the end
remove Remove a member or element The target path must exist
replace Replace an existing value The target path must exist
move Move a node Cannot move a node into its own child path
copy Copy a node The source path must exist
test Assert value equality If it fails, the whole patch does not apply

Many people remember only the first three and forget that test is what turns a blind write into a conditional write. The full operation semantics and array boundaries are covered in JSON Pointer, Patch, and Merge Patch.

2. Using test to Turn a Patch into a Conditional Update

A typical optimistic-locking patch starts not with a write, but with a version assertion:

{"op":"test","path":"/version","value":1}

Put it into a complete patch:

[
  { "op": "test", "path": "/version", "value": 1 },
  { "op": "replace", "path": "/version", "value": 2 },
  { "op": "replace", "path": "/status", "value": "paid" }
]

The logic is: confirm that the document version is currently 1, then change it to 2. If another request has already advanced the version to 2, the first test fails, the whole patch does not apply, and the server returns a conflict. This prevents lost updates without introducing a distributed lock.

A few design points:

  • Put test before every write. If an earlier write has already taken effect, the assertion is meaningless;
  • Assert on a version number or digest generated by the server, not on an ordinary field the client can freely change;
  • Increment the version on every successful write; otherwise two requests using the same old version both pass;
  • The value of test must have exactly the same JSON type as the document value. 1 and "1" are not equal.

3. Path Escaping and Prototype Pollution

JSON Pointer escaping rules are simple: write ~ as ~0 and / as ~1, and replace ~ before /, otherwise you get double escaping.

Key "a/b"    → /a~1b
Key "m~n"    → /m~0n
Key "a~/b"   → /a~0~1b

The risk with external patches is usually not escaping but dangerous keys. In JavaScript, keys such as __proto__, constructor, and prototype can trigger prototype pollution, even through an ordinary add:

[
  { "op": "add", "path": "/__proto__/polluted", "value": true }
]

Use an allowlist rather than a denylist:

  1. Permit patches to modify only business fields, such as /status or /items/0/quantity;
  2. Validate every path and from against a regular expression or path prefix, rejecting unexpected levels other than array indices;
  3. Parse JSON into Object.create(null) or Map containers to reduce the impact of a polluted prototype chain;
  4. Cap the number of operations and the byte size of each value.

4. Atomic Application and Rollback

RFC 6902 requires atomic execution, but atomicity is a semantic requirement, not a default in every library. Libraries such as fast-json-patch mutate the document in place when mutateDocument is true, and a failure may leave partial changes behind.

import { applyPatch, validate } from 'fast-json-patch';

/**
 * Apply a patch immutably and return the original document on failure.
 * @param document The original document, which callers must not mutate
 * @param patch The JSON Patch operation array to apply
 * @returns The result, including a success flag and the new document
 */
function applySafely(document: unknown, patch: unknown[]) {
  const errors = validate(patch, document);

  if (errors) {
    return { ok: false, document };
  }

  try {
    const result = applyPatch(document, patch, false, false);
    return { ok: true, document: result.newDocument };
  } catch {
    return { ok: false, document };
  }
}

Three implementation recommendations:

  • Deep copy or structured-clone first, apply on the copy, and swap the reference only after success;
  • Do not rely on a library's automatic rollback; behavior on exceptions differs across implementations;
  • Validate before executing: validate checks structure only, while semantic conflicts still require test; neither replaces the other.

If a patch spans multiple resources, such as changing an order and its inventory together, JSON Patch atomicity holds only within a single document. Across documents you need a database transaction or compensating actions.

5. Idempotency: PATCH Is Not Naturally Idempotent

HTTP semantics define PUT and DELETE as idempotent, but PATCH is not necessarily so. Repeating the same JSON Patch can produce completely different results:

  • add /items/- appends to the end of the array every time, so retries create duplicates;
  • replace /version changes version 1 to 2 and succeeds again on a retry, even though the operation has already been applied;
  • remove /temp fails on the second run because the path no longer exists.

There are three common ways to achieve idempotency:

Approach How it works Best for
Version precondition First operation is test /version, then increment Resources with an explicit version
Idempotency key Client sends Idempotency-Key; server caches the result Payments, orders, and other writes
Target-state patch Use replace instead of add, so the result depends only on the final value Configuration and status fields

The worst pattern is a patch that mixes add and remove while assuming retries are safe. If a patch appends or deletes anything, it must be paired with a version assertion or an idempotency key.

6. Concurrent Conflicts: ETag and If-Match

Besides test inside the patch, HTTP offers a standard mechanism: the server returns an ETag, and the client sends If-Match on the next request.

GET /orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json

[{"op":"replace","path":"/status","value":"paid"}]

If the resource has changed, the server returns 412 Precondition Failed, and the client refetches before retrying. If-Match: * means "execute as long as the resource exists", which is not suitable when you need to prevent overwrites.

The difference between test and If-Match:

  • If-Match is checked at the HTTP layer before body parsing, so it is cheaper;
  • test is checked at the patch semantic layer and can assert any field without depending on server-side ETag support;
  • They can be combined, but do not implement only one of them and claim the endpoint is safe under concurrency.

7. Library Differences and Selection

Implementation Language Atomicity test support Notes
fast-json-patch JavaScript Optional (returns a new document by default) Yes Broad ecosystem; watch mutateDocument
jsonpatch Python Rolls back on exceptions Yes Close to the standard, with clear errors
json-patch Go Operates on a copy Yes Often used alongside JSON Merge Patch
zjsonpatch Java Yes Yes Convenient Jackson integration

Do not select a library solely on "supports all six operations". Confirm three things: whether a failure rolls back, whether test compares strictly, and whether an out-of-range array add raises an error. Loose implementations make the same patch behave differently across environments.

8. A Pre-Launch Safety Checklist

  • [ ] The endpoint declares application/json-patch+json;
  • [ ] Patches are applied to a copy, and a failure never contaminates the original document;
  • [ ] Every path and from in external patches passes an allowlist check;
  • [ ] Dangerous keys such as __proto__, constructor, and prototype are rejected;
  • [ ] Operation count and single-value size are capped;
  • [ ] test or If-Match provides concurrency control;
  • [ ] Patches are written to an audit log with actor, timestamp, and the full operation array;
  • [ ] Transaction boundaries are explicit when a patch spans documents.

A patch is inherently a complete description of "what changed", which makes it a natural audit record. For error codes and idempotency at the API layer, continue with JSON API Design. If a patch comes from merging two branches, you also need conflict semantics, covered in Git Merging for JSON Files.

FAQ

What status code should a failed test operation return?

If the conflict comes from a version assertion, both 409 Conflict and 412 Precondition Failed are defensible. Prefer 412 to stay consistent with If-Match, and include the current version in the response body so the client can retry.

Can a patch containing add and remove be retried safely?

Not directly. add to the end of an array duplicates data, and remove fails on the second run because the path is gone. Add a version precondition or an idempotency key so retries are recognized as the same operation.

Can JSON Patch guarantee cross-document transactions?

No. RFC 6902 atomicity applies to a single JSON document. When orders, inventory, or other resources are involved, use database transactions, Saga compensation, or a message queue for consistency.

Why must a patch never write to __proto__?

Because most JavaScript implementations store the document in ordinary objects, writing __proto__ can change the prototype chain and affect every object afterward. A path allowlist is the most reliable defense; do not rely only on a library's internal filtering.

How do I make a PATCH request idempotent?

There are three common approaches: start the patch with test /version and increment the version on success, have the server cache the result for an Idempotency-Key, or use replace instead of add so the result depends only on the final value. If a patch appends or deletes anything, it must be paired with a version assertion or an idempotency key; it cannot simply be retried.

Should I use both test and If-Match?

You do not have to use both, but implementing only one of them does not make an endpoint safe under concurrency. If-Match is checked at the HTTP layer before body parsing, so it is cheaper; test is checked at the patch semantic layer and can assert any field without depending on server-side ETag support.