To patch means to apply a set of modification operations to a JSON document, producing a new value with the original left unchanged.
The entry point to this bulk-editing API is JsonValue.patch.
Unlike RFC 6902, a patch here is not a sequence of mutations. It is a set of operations, all of which reference the target before any change is applied.
By design this results in:
- Order of operations does not matter. Any permutation of the same operations produces the same result.
- Clashes are detected up front. Two operations that would compete for the same path are rejected before any edit is attempted.
- Single-pass application. The result can and is produced by copying slices of the source text with no intermediate JSON tree ever materialized.
If two operations write to the same path — or to a path and its ancestor — the patch is rejected. There is no "last write wins", no silent override, no guessing what the client meant.
JsonMixed target = JsonMixed.of("""
{"name": "Alice", "tags": ["a", "b"]}""");
JsonPatch patch = JsonMixed.of("""
[
{"op": "replace", "path": ["name"], "value": "Bob"},
{"op": "insert", "path": ["tags", -0], "value": "c"}
]""").as(JsonPatch.class);
JsonMixed result = target.patch(patch);
// = {"name": "Bob", "tags": ["a", "b", "c"]}-0 means "append to array". Negative indexes count from the end of the original array, not the current one.
| op | aliases | purpose | notes |
|---|---|---|---|
insert |
add, + |
Insert a value | On existing object member: replaces value. On array index: inserts before index |
remove |
delete, - |
Delete a value | No-op if the target doesn't exist |
replace |
Replace a value | Missing object key → adds member; missing array slot → error | |
spread |
~ |
Insert multiple elements | Array: splice elements at index. Object: merge members |
move |
> |
Move a value | Source removed, target inserted |
copy |
Copy a value | Source unchanged | |
swap |
Exchange two values | Both must exist | |
test |
? |
Assert a precondition | Test path(s) for being defined or equal to a value, details below |
RFC 6902 aliases are accepted: add → insert, delete → remove.
Every operation carries a path (plus from / to where relevant).
Two equivalent forms:
[...]: array of path segments; no escaping needed, advanced selectors"...": a JSON Pointer string (RFC 6901) — for RFC 6902 compatibility
{"op": "remove", "path": ["users", 0, "roles", 2]} {"op": "remove", "path": "/users/0/roles/2"}Inside a path array, a segment encodes targeting intent by its JSON shape:
| type | shape | targets | meaning |
|---|---|---|---|
| key | "foo" |
object member | object member (literal key) |
| index | 5 |
array index | array index |
| index from end | -1 |
array index | index from end (-0 = append) |
| index range | [[0, 2]] |
array index range | inclusive range |
| index enumeration | [0, 2, 4] |
array indexes | scattered indexes |
| index condition | ["=", 3] |
array index | first element equal to value |
| index condition shorthand | [true], ["a"] |
array index | shorthand for ["=", true], ["=", "a"] |
| index object condition | [{"id": "x"}] |
array of objects index | first object element with matching members |
Note:
- Ranges and enumerations are only permitted at the end of the path (as they may result in multiple targets).
- Conditional index selection always resolves to a single index and may appear anywhere.
- Index numbers in any shape can be negative to resolve from the end (
-0is the equivalent to JSON Pointer's append marker-) - Indexes, ranges, and conditions all resolve against the original target — a condition doesn't see values inserted by another operation in the same patch.
- Object conditions can have multiple key:value pairs (all must match)
- Shorthand condition cannot be used to match numbers as
[42]is an index enumeration of just 42 (=>["=" 42])
Any op that writes a single value accepts an optional test field:
{"op": "replace", "path": ["balance"], "value": 100, "test": 50}The write happens only if /balance currently equals 50. Otherwise, the entire patch throws.
This makes the operation a compare-and-swap — a natural guard against concurrent modification and a standard idempotency primitive.
The equivalent standalone test operation is:
{"op": "test", "path": ["balance"], "value": 50}Using a standalone test operation has 2 benefits that are not accessible via the test field:
- it may target multiple values
- it can omit the
valueto test if the target is defined (present and not JSONnull)
All tests are evaluated before any mutation is performed.
When replace targets a range or enumeration, the value's shape determines how it's applied:
shape of value |
effect |
|---|---|
| scalar | broadcast — every target gets this value |
| array, same length | aligned position-by-position |
| array, shorter | aligned; excess targets removed |
| array, longer | ranges only — excess values spread past the end |
| array, longer | enumerations — error (no coherent "past the end") |
To collapse several targets into one, use an array-of-one: Replace([1:3], [99]) shrinks elements 1..3 to a single 99.
Replace - duplicate keys. When replacing an object member that is a duplicate key the operation throws unless the key is the first member in which case it removes the first occurence of the key.
Insert - no array parent auto-created. insert into a missing object parent creates the parent. insert into a missing array parent throws — the array must exist first.
Single-target ops reject multi-target paths. insert, spread, move, copy, swap and the CAS test field reject ranges and enumerations.
| aspect | RFC 6902 | This library |
|---|---|---|
| Execution | sequential | order-independent set |
| Path syntax | JSON Pointer only | JSON Pointer or array of segments |
| Index semantics | current state | original state |
| Clash detection | implicit (failure at apply) | explicit (validated up front) |
remove on missing |
error | no-op |
replace on missing object key |
error | adds member |
add on existing object key |
replaces | replaces (same) |
Array add past end |
error | error (except size = append) |
move rightward in array |
sequential (post-removal index) | original (pre-removal index) |
test numeric equality |
numeric | structural (1 ≈ 1.0) |
| Auto-create object parents | no | yes |
What works identically: any patch using only JSON Pointer paths where no path is a prefix of another. That's the overwhelming majority of real-world RFC 6902 patches — the differences only surface when array mutation ordering matters, or when a patch deliberately relies on sequential behavior.
How application differs: clash detection up front, order-independence, single-pass application with predictable memory. There is no way to write a silently-wrong patch — either it applies correctly or it throws.
This section documents the semantics of path expressions and operations in detail. It assumes you have read the Quick Reference and are looking for the precise behavior of a particular path form or operation.
A path is either an array of segments or a JSON Pointer string. The array form is the primary form; the string form exists for RFC 6902 compatibility. Both forms can be used freely within one patch, but each individual path is one or the other.
Each segment in the array form is interpreted by its JSON shape. There are seven distinct segment types.
["users", "name"]Addresses an object member by its literal key. No escaping is needed — keys can
contain ., [, ", and so on without any encoding. This is one
advantage of the array form over JSON Pointer strings.
["items", 2]Addresses an array element positionally, 0-based. A non-negative index refers to an existing element position in the original array.
["items", -1]Counts from the end of the array. -1 is the last element, -2 the
second-to-last, and so on. -0 is the position one past the last element —
the append slot.
The distinction between 0 and -0 matters:
["items", 0] // first element position
["items", -0] // append slot, after the last elementBoth resolve against the original array's length. In a patch that both removes
and inserts, -0 always refers to the original array's tail. Underflow (a
negative index that resolves below 0) is an error.
Shape: an array of 2 integers within an array:
["items", [[1, 3]]]A contiguous span of array elements, both bounds inclusive. Resolves to multiple paths, one per element in the range. Both bounds may be negative.
A range that resolves to nothing — because both bounds are past the end, or because the parent array is empty — produces no targets.
Shape: an array of integers of any length
["items", [0, 2, 4]]A set of specific array indexes. Resolves to multiple paths, one per listed
index. Elements may be negative. An empty array [] is a valid enumeration
with no entries.
Enumerations are the way to address a scattered selection of elements — the conceptual equivalent of a range but without the contiguous constraint.
Shape: an array of one object
["users", [{"id": "alice", "status": "active"}]]Selects the first element of an array of objects whose members structurally match all of the given key-value pairs. Additional members on the target element are ignored. Multiple key-value pairs narrow the match — all must be satisfied.
If no element matches, the condition resolves to no path. For remove this
is a no-op; for replace it's an error, since the client's assumption about
the target is stale.
Object conditions are the primary tool for set-semantic manipulation of arrays — addressing elements by their logical identity rather than by position.
Shape: an array with first element "=" and any second value to compare with
Shorthand shape: just an array of the value to compare with (for non-numeric primitives)
["items", ["=", 42]]
["items", ["=", "hello"]]Selects the first element of an array that is structurally equal to the given value. Works on arrays of any element type — scalars, objects, arrays.
For non-numeric primitives values a shorthand is available:
["items", [true]] // same as ["=", true]
["items", ["hello"]] // same as ["=", "hello"]
["items", [null]] // same as ["=", null]Numbers do not have a shorthand — [42] is an enumeration of the single index
42, not a value condition for 42. Use ["=", 42] explicitly.
Ranges and enumerations potentially resolve to multiple paths. To keep targets linear ranges and enumerations must be the last segment of their path.
["items", [[0, 2]], "name"] // error
["items", [0, 2], "name"] // errorConditions resolve to a single index and may appear anywhere:
["users", [{"id": "alice"}], "email"] // ok
["users", [{"id": "alice"}], "roles", 0] // okEvery path, index, range, and condition resolves against the target before any edit is applied. A condition does not see values inserted by another operation in the same patch, and an index does not shift because an earlier operation removed an element.
[
{"op": "remove", "path": ["items", 0]},
{"op": "replace", "path": ["items", 1], "value": "x"}
]On an items array of ["a", "b", "c"], this removes "a" and replaces "b" with "x",
giving ["x", "c"]. The replace at index 1 refers to the original "b", not the
post-removal "c". This is what makes the patch order-independent.
The string form follows RFC 6901:
/separates segments~1escapes/,~0escapes~-on an array parent means "append position" (equivalent to-0)""(empty string) means the root
{"op": "remove", "path": "/items/2"}
{"op": "insert", "path": "/items/-", "value": "new"}Only single-target addressing is possible in this form — no ranges, enumerations, or conditions. It is the standard form for interoperability with RFC 6902.
Conditions let a patch express "do X to this element" rather than "do X to element at index N" — the correct primitive when the array's shape isn't known in advance:
{"op": "replace", "path": ["users", [{"id": "alice"}], "role"], "value": "admin"}This targets the role member of whichever user has id equal to "alice",
regardless of their position. If no such user exists, the patch fails — the
client's assumption about the target was wrong.
Combine a condition with the test field to make the whole operation
conditional:
{"op": "replace",
"path": ["users", [{"id": "alice"}], "role"],
"test": "guest",
"value": "admin"}Now the promotion to admin only happens if alice is currently a guest.
This is a compare-and-swap on the role member.
Every operation is one of eight types. This section describes each operation's semantics precisely.
Adds a value.
- Object parent, existing member: the member's value is replaced.
- Object parent, missing member: the member is appended.
- Array parent: the value is inserted before the addressed index,
shifting later elements right.
-0appends at the end. - Missing object parents are auto-created.
["a", "b", "c"]on{}produces{"a": {"b": {"c": value}}}. - Missing array parents are not auto-created. The array must exist.
Insertion-before-index is what distinguishes insert from replace on
arrays:
{"op": "insert", "path": ["items", 1], "value": "x"}
// "items" array ["a", "b", "c"] → ["a", "x", "b", "c"]
{"op": "replace", "path": ["items", 1], "value": "x"}
// "items" array ["a", "b", "c"] → ["a", "x", "c"]Deletes the value at the path.
- No-op if the path doesn't exist.
- Multiple targets from a range or enumeration remove each resolved target.
The no-op behavior is deliberate: remove expresses "ensure this is gone,"
and absence already satisfies that. Combined with conditions, this lets a
patch safely attempt cleanup without requiring the caller to check existence
first. A test can be used to explicitly require existence or value equality.
Replaces the value at the path.
- Existing target: value at path is replaced.
- Missing object key: the member is added (same as
insert). - Missing array position: error.
- Multiple targets from a range or enumeration: see Multi-target Replace below.
The dual behavior on objects is a deliberate convergence with insert: a
client that knows "this key should have this value" doesn't need to branch on
whether the key currently exists. It also makes replace composable with
remove — the combination remove + insert on the same path is precisely
what replace represents. Client use it to express their intent of
replacing a target (as remove + insert on the same target is an error
as is unclear whether the same target is deliberate or accidental).
Bulk-inserts from a structured value.
- Array parent, array value: the
value's elements are spliced in before the addressed index. - Object parent, object value: the
value's members are merged in — existing keys are replaced, new keys are appended.
{"op": "spread", "path": ["items", 1], "value": [10, 20, 30]}
// "items" array ["a", "b", "c"] → ["a", 10, 20, 30, "b", "c"]The value must be an array or an object. Scalar values are rejected.
spread is the way to insert multiple elements at a single position.
Multiple insert operations at the same index would clash — they claim the
same target path. spread claims one path and inserts many elements at it.
This is again to be explicit about intent as it is unclear if
multiple inserts on the same target is deliberate or accidental.
Missing object parents are auto-created, same as insert.
Removes the value at from and inserts it at to (or path; see the RFC
6902 fallback below).
- No-op if
fromdoesn't exist. - No-op if
fromequalsto. - Both paths resolve against the original target.
For arrays, move rightward within the same array is subtle:
{"op": "move", "from": ["items", 0], "to": ["items", 2]}
// "items" array ["a", "b", "c"] → ["b", "a", "c"]The to index refers to the original array's slot 2 (before "c"), not the
shortened post-removal array's slot 2. To move an element past others, address
the position one past the target, or use -0 to append. See the
"Compared to RFC 6902" section for how this differs from sequential semantics.
Inserts a copy of the value at from at to (or path; see the RFC
6902 fallback below).
- No-op if
fromdoesn't exist. - Source is unchanged.
- Value is read from the original target — the snapshot is taken before any edit is applied.
Because the read is against the original, copy composes predictably with
other operations that modify the source path:
[
{"op": "copy", "from": ["a"], "to": ["b"]},
{"op": "replace", "path": ["a"], "value": 99}
]produces {"a": 99, "b": originalA} — b gets the pre-patch value of a.
Exchanges the values at from and to (or path; see the RFC
6902 fallback below).
- No-op if either side is missing.
- No-op if
fromequalsto. - Values may be of any shape and at any depth.
{"op": "swap", "from": ["a"], "to": ["b"]}
// {"a": 1, "b": 2} → {"a": 2, "b": 1}Asserts a precondition without modifying anything.
A standalone test operation passes under one of three modes:
valueomitted: the target path is defined — present and not JSONnull.valueisnull: the target path is exactly JSONnull.valueis defined: the target is structurally equal to the value.
The three modes let a client distinguish "is there anything here" from "is there specifically a null or specific value here":
{"op": "test", "path": ["a"]} // defined (not null, not missing)
{"op": "test", "path": ["a"], "value": null} // exactly null
{"op": "test", "path": ["a"], "value": 42} // exactly 42Multiple targets (from a range or enumeration) each must pass.
Any operation that writes a single value accepts an optional test field:
{"op": "replace", "path": ["balance"], "test": 50, "value": 100}This is the same check as a standalone test at the same path with the
same value. The write only proceeds if the test passes; otherwise the whole
patch throws.
The test field is allowed on insert, remove, replace, move, and
copy. It is rejected on test (redundant), spread (no single target), and
swap (two targets, ambiguous).
The idiomatic use is a compare-and-swap style change:
{"op": "replace", "path": ["status"], "test": "draft", "value": "published"}Applying this patch twice: the first application transitions the state, the
second fails the test and produces no change. Combined with handling
JsonPatchTestException, this gives idempotency without any external
coordination.
When replace targets a range or enumeration, the resolved paths form a set
of values to update. The shape of value determines how they're matched.
Scalar value — broadcast to every target:
{"op": "replace", "path": ["flags", [[0, 3]]], "value": false}Sets every flag in the range to false.
Array value, same length — aligned position-by-position:
{"op": "replace", "path": ["items", [[1, 3]]], "value": [10, 20, 30]}
// replaces elements 1, 2, 3 with 10, 20, 30 respectivelyArray value, shorter than targets — aligned, with excess targets removed:
{"op": "replace", "path": ["items", [[1, 3]]], "value": [10]}
// replaces element 1 with 10, removes elements 2 and 3This is the compact form for shrinking a contiguous span into a shorter sequence, and the reason scalar broadcasts rather than collapses — collapsing many targets into one is expressed by array-of-one, not by scalar.
Array value, longer than targets — this depends on the target type:
- For a range, the excess values are inserted after the last target, extending the span:
{"op": "replace", "path": ["items", [[1, 2]]], "value": [10, 20, 30, 40]}
// replaces 1 and 2 with 10 and 20, then inserts 30 and 40 after them- For an enumeration, this is rejected. A scattered selection has no coherent "past the end" position, so the operation would be ambiguous.
Collapsing multiple targets into one uses array-of-one:
{"op": "replace", "path": ["items", [[1, 3]]], "value": [99]}
// three original elements become one `99`A patch runs in two phases:
-
All tests run first. Every standalone
testand everytestfield is evaluated against the original target. Any failure aborts the entire patch before any edit is performed. -
All mutations are applied. Each mutation is translated to text edits against the original; The output is composed though edits in a single pass.
The consequence: regardless of where the test appears in the operation list
a patch with a failing test is a read-only operation where no work has been
performed to write the patched result.
The combination of conditions and the operations above gives a set-based view of arrays: elements can be addressed by their identity, not their position.
Remove an element by identity:
{"op": "remove", "path": ["users", [{"id": "alice"}]]}No-op if alice isn't present.
Replace a member of an element by identity:
{"op": "replace", "path": ["users", [{"id": "alice"}], "role"], "value": "admin"}Error if alice isn't present — the client's assumption about the array's contents is stale.
Conditionally promote only if the current state matches:
{"op": "replace",
"path": ["users", [{"id": "alice"}], "role"],
"test": "tester",
"value": "admin"}Fails the whole patch if alice's current role isn't "tester".
Remove multiple specific elements in one patch:
[
{"op": "remove", "path": ["users", [{"id": "alice"}]]},
{"op": "remove", "path": ["users", [{"id": "bob"}]]}
]Both operations resolve independently against the original array, so neither shifts the other. Each is a no-op if its target isn't present. The claim checker verifies that the two resolved indexes don't overlap, otherwise the patch would be rejected.
This pattern is the reason patches are order-independent: the client describes what they want changed in terms of the data's identity, and the implementation works out where those changes land.