Quick answer: Test insertion versus replacement, shifted indices after removal, move destinations after source removal, independent copied values, and failures on isolated candidates. JSON Patch executes operations in sequence. An index addresses the array as it exists at that step, so a patch that looks reasonable against the original document can edit the wrong item.
Removing one row can change the meaning of every later indexed edit. The JSON Patch versus Merge Patch guide introduces the formats; this article focuses on five array-update regression groups. Merge Patch replaces arrays as whole values, so it needs a different test strategy.
Run the Python blocks together, in order, with Python 3.12.14, jsonpatch 1.41, and jsonpointer 3.8. These versions were used for the local synthetic fixtures below. No remote endpoint, database transaction, or other implementation was tested. Wikipedia's JSON Patch overview supplies background; the linked specifications and library documentation define the behavior being checked.
1. Does add insert an item where replace would overwrite it?
Test add and replace at the same array index, then assert different expected results. Array add inserts a new item before an existing position; replace changes the value already there. Include append and an out-of-range insertion so a row-editing client cannot accidentally turn an overwrite into insertion or create unsupported gaps.
The JSON Patch operation overview distinguishes these operations. Use a short array with different labels so insertion and replacement cannot produce visually similar results. Repeated labels can hide an incorrect operation even when the array length changes as expected.
import copy
import jsonpatch
def apply(document, operations):
return jsonpatch.apply_patch(document, operations, in_place=False)
def expect_error(error_type, function, *args):
try:
function(*args)
except error_type:
return
raise AssertionError(f"Expected {error_type.__name__}")
original = {"items": ["red", "blue"]}
inserted = apply(original, [{"op": "add", "path": "/items/1", "value": "green"}])
replaced = apply(original, [{"op": "replace", "path": "/items/1", "value": "green"}])
assert inserted == {"items": ["red", "green", "blue"]}
assert replaced == {"items": ["red", "green"]}
assert apply(original, [{"op": "add", "path": "/items/-", "value": "green"}]) == {
"items": ["red", "blue", "green"]
}
expect_error(jsonpatch.JsonPatchConflict, apply, original,
[{"op": "add", "path": "/items/4", "value": "green"}])
assert original == {"items": ["red", "blue"]}
The final assertion checks an additional property of this fixture: the original input remains unchanged because the helper requests a copy. Keep that assertion when adapting the example to a different library. Mutation defaults vary, and a successful result alone does not reveal whether a shared input object changed too.
For an actual row editor, use objects with stable IDs and compare both order and content after patching. Checking only the number of rows would miss replacement of the wrong item. Checking only whether a newly inserted value exists would miss an accidental duplicate or a lost sibling.
Review expected before-and-after payloads in JSON Compare, using sanitized examples. The comparison tool highlights document differences; it does not apply JSON Patch or verify the endpoint's transaction behavior. Keep executable patch assertions beside the client code that generates operations so UI changes cannot quietly alter their meaning.
2. Do later operations use indices after earlier removals?
Test a sequence where removing one array item changes the item at a later numeric index. Operations run against successive document states, not a frozen set of original positions. If the intended edits refer to original items, generate indices accordingly and retain stable item identifiers in your expected-result assertions.
RFC 6902's document structure specifies sequential evaluation. A good regression fixture makes the wrong result valid enough to escape a basic syntax or shape check. The second remove below succeeds, but targets a different original item than an unwary client might expect.
rows = {"items": ["A", "B", "C", "D"]}
shifted = apply(rows, [
{"op": "remove", "path": "/items/1"},
{"op": "remove", "path": "/items/2"},
])
assert shifted == {"items": ["A", "C"]}
original_positions = apply(rows, [
{"op": "remove", "path": "/items/2"},
{"op": "remove", "path": "/items/1"},
])
assert original_positions == {"items": ["A", "D"]}
assert rows == {"items": ["A", "B", "C", "D"]}
Descending index order can be useful when deleting several positions from one unchanged original array. It is not a universal rule for mixed patches. Insertions, moves, parent replacement, and operations on different arrays need a plan based on the actual sequence. Do not sort an arbitrary patch by its path string and assume the result remains equivalent.
Give the generator's tests names that describe the intended item changes, not just the operation count. A patch containing two removes can be perfectly well formed while deleting the wrong records. The acceptance assertion should name the survivors and their order so the test explains the contract.
When an array is a collection of domain records, consider whether index addressing fits the public API. An ID-addressed resource update can avoid some positional assumptions, although that is an API design choice rather than an alternative interpretation of JSON Pointer. The array comparison tests explore the separate question of positional versus identity-based comparison.
Keep empty arrays and removal of the final item in your production corpus too. Those boundaries can reveal a generator that computes the next index from the original length. These small examples establish ordering semantics, not complete coverage of every array shape your application accepts.
3. Is a move destination evaluated after removing the source?
Assert the complete order after moving an item forward and backward within an array. A move removes the source before adding it at the destination, so it is not a swap. Include a move into a descendant as a rejection case, because a value cannot be moved inside one of its own children.
RFC 6902 defines move in terms of removal followed by addition. That sequence matters when source and destination share an array. A drag-and-drop interface may label positions differently from the patch format, so test the UI-to-patch mapping rather than assuming both use identical destination coordinates.
letters = {"items": ["A", "B", "C", "D"]}
forward = apply(letters, [{"op": "move", "from": "/items/0", "path": "/items/2"}])
backward = apply(letters, [{"op": "move", "from": "/items/3", "path": "/items/1"}])
assert forward == {"items": ["B", "C", "A", "D"]}
assert backward == {"items": ["A", "D", "B", "C"]}
assert forward != {"items": ["C", "B", "A", "D"]}
expect_error(jsonpatch.JsonPatchConflict, apply, {"node": {"child": {}}},
[{"op": "move", "from": "/node", "path": "/node/child/moved"}])
The explicit non-swap assertion is a useful negative control. It shows a plausible wrong interpretation rather than merely repeating the desired output. If a drag-and-drop component changes its destination convention, this fixture helps identify the translation error without blaming the patch library.
Use distinct object IDs instead of letters in an integration test, then verify nested values remain attached to the same records. A visually correct row order can still hide a UI bug that moves labels while leaving editable state or metadata behind. The patch result is one layer of the feature, not proof that the rendered list is correct.
Do not use the dash append token as a source location. A source must identify an existing value. Keep source resolution and destination insertion rules separate in your generator, and test unsupported locations using the actual library version you deploy. The JSON Pointer update tests cover escaping and target resolution beyond the array-ordering cases here.
If patches are generated automatically, retain the before and desired-after fixtures as the durable test contract. Several valid operation sequences can produce the same result. Requiring one exact generated sequence can make harmless generator improvements look like failures while missing a changed final document.
4. Does editing a copied object leave its source independent?
Copy a nested object into an array, modify the copied object's child, and assert that the source stays unchanged. JSON describes independent values rather than shared mutable references. A patch implementation must preserve the intended result when a later operation edits the copy, even if the host language can otherwise alias objects.
The python-json-patch tutorial documents applying patches with or without input mutation. This fixture checks both the JSON-level effect of copy followed by editing and the selected helper's treatment of its original input. Those are related but different assertions.
source = {"template": {"settings": {"color": "red"}}, "items": []}
snapshot = copy.deepcopy(source)
result = apply(source, [
{"op": "copy", "from": "/template", "path": "/items/-"},
{"op": "replace", "path": "/items/0/settings/color", "value": "blue"},
])
assert result["template"]["settings"]["color"] == "red"
assert result["items"][0]["settings"]["color"] == "blue"
assert source == snapshot
Use a nested child in this test rather than a scalar. A shallow copy can appear correct when the copied value is a string, then fail when the next operation changes an object shared beneath both locations. The fixture is deliberately shaped to expose that class of mistake.
Check what your surrounding adapters do as well. A library may return an isolated candidate, while a later mapper reuses references from a cache or an application model. Run the complete transformation path with an unchanged-source assertion if other code can observe the original state during patch processing.
Keep resource limits separate from semantic correctness. Copying a large subtree several times can increase memory use substantially even when every resulting value is correct. Set operation-count and payload limits appropriate to the service, and measure representative workloads before accepting patches from untrusted callers.
Finally, enforce authorization on the copy source as well as its destination. A caller permitted to write one public field should not gain access to a protected subtree merely by copying it into that field. These local fixtures demonstrate value behavior; they do not implement an access-control policy or establish that a real endpoint filters sensitive paths correctly.
5. Do guarded updates fail without publishing a partial candidate?
Apply a patch to isolated candidate state, check the expected array item's identity before editing, and publish only a successful complete result. Test a stale guard and a failure after an earlier successful operation. An exception from an in-memory library does not by itself roll back database writes or prevent concurrent writers from racing.
RFC 5789's atomicity requirement applies to the HTTP PATCH resource update. The application must implement that guarantee around its parser, authorization, candidate validation, and persistence. Returning an exception is not enough if earlier operations were already written to durable storage.
stored = {"items": [{"id": "A", "quantity": 1}, {"id": "B", "quantity": 2}]}
before = copy.deepcopy(stored)
guarded = [
{"op": "test", "path": "/items/1/id", "value": "B"},
{"op": "replace", "path": "/items/1/quantity", "value": 3},
]
candidate = apply(stored, guarded)
assert candidate["items"][1]["quantity"] == 3
assert stored == before
reordered = {"items": list(reversed(stored["items"]))}
expect_error(jsonpatch.JsonPatchTestFailed, apply, reordered, guarded)
late_failure = [
{"op": "replace", "path": "/items/0/quantity", "value": 9},
{"op": "remove", "path": "/items/99"},
]
expect_error(jsonpatch.JsonPatchConflict, apply, stored, late_failure)
assert stored == before
print("All five JSON Patch array update test groups passed")
The identity guard checks a positional assumption; it is not a lock. Another writer can still change stored state after a candidate is computed. Use a transaction, version comparison, or another atomic persistence mechanism that checks the same snapshot being committed. The API concurrency tests address those integration concerns.
Validate the complete candidate's business rules before publishing it. A syntactically valid replace can still create a negative quantity, violate a relationship, or edit a field the caller cannot change. Keep patch-shape validation, target authorization, candidate validation, and commit conditions explicit in the endpoint tests.
Record the failing operation index and a safe diagnostic code when useful, without echoing sensitive values. The specification does not make every application return the same HTTP status or error envelope. Choose those responses in the API contract and test them through the real handler.
These five groups are fast local regression checks. Before release, repeat representative success and late-failure cases through your actual endpoint and persistence layer, then assert that rejected updates leave the stored resource unchanged. That evidence closes the gap between a correct patch calculation and a correctly committed resource update.