Data Formats (JSON, YAML, TOML, XML, CSV) Module 2 – JSON in depth
JSON Pointer, JSON Patch and Merge Patch
Name any value with an RFC 6901 pointer, apply RFC 6902 patches that test before they write, and predict what RFC 7396 merge patches do with nulls and arrays.
What you will learn
- Write an RFC 6901 JSON Pointer to any value, including the ~0 and ~1 escapes
- Apply RFC 6902 JSON Patch operations in order, and predict which patches fail as a whole
- Use the test operation to refuse a change built on a stale reading
- Predict RFC 7396 Merge Patch results for nulls, arrays and non-object patches
- Choose between JSON Patch, Merge Patch and a whole document for a partial update
Before you start
What you’ll learn · Before you start
What you will learn
- Write an RFC 6901 JSON Pointer to any value, including the ~0 and ~1 escapes
- Apply RFC 6902 JSON Patch operations in order, and predict which patches fail as a whole
- Use the test operation to refuse a change built on a stale reading
- Predict RFC 7396 Merge Patch results for nulls, arrays and non-object patches
- Choose between JSON Patch, Merge Patch and a whole document for a partial update
Before you start
On this page
A support agent has to change one number in an order: 2 copies of BK-101 become 3. Sending the whole order back would also undo anything a colleague changed a moment earlier, so the change should travel on its own, and that needs a way to name one value inside a document and a format for the change.
You will address any value with an RFC 6901 JSON Pointer, apply RFC 6902 JSON Patch operations that succeed or fail as
a whole, use test to refuse a change built on a stale reading, predict what RFC 7396 Merge Patch does with nulls and
arrays, and choose between the two for an update of your own.
A JSON Pointer names one value by its path, with ~0 and ~1 as escapes
A JSON Pointer: A string that names one value inside a JSON document, such as /items/0/qty, defined by RFC 6901. Inside a name, ~ is written ~0 and / is written ~1. More in “JSON interop: duplicates, key order, I-JSON” is a string such as /items/1/qty. Each part after a slash is a
reference token: One step of a JSON Pointer, the text between two slashes: a member name in an object or a decimal index in an array. Inside a token, ~0 stands for ~ and ~1 for /., and evaluation takes one step per token, starting from the whole
document: in an object the token is a member name, in an array a decimal index. RFC 6901 adds four rules that first
attempts get wrong:
- The empty pointer,
"", names the whole document, and/names the member whose name is the empty string. - An index has no leading zeros and no sign, so
01and+1name nothing, and-names the place after the last element, which holds no value to read. - Inside a token,
~is written~0and/is written~1, so a member nameda/bis/a~1b. - Decoding replaces
~1first and~0second. The other order turns~01, the escaped name~1, into/.
Follow four pointers through the order, the last with a slash inside a member name:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Split the pointer. The pointer /items/1/qty is split at each slash into three tokens: items, 1 and qty. Evaluation starts at the whole document, which the empty pointer would name on its own.
- A member. The order is an object, so the first token is a member name: items, an array of two order lines.
- An index. The value is now an array, so the token must be a decimal index with no leading zero, below the length of 2. Token 1 is the second line, the one for BK-205.
- A member again. That line is an object, so qty is a member name again.
- The value. Three tokens, three steps: /items/1/qty names the number 1, which both programs printed.
- A leading zero. Now /items/01. The second token meets the same array, but 01 is not an index: RFC 6901 allows no leading zeros, so evaluation stops with an error instead of guessing that 01 means 1.
- The place past the end. /items/- fails at the same token. In an array, - names the place after the last element, where JSON Patch add can append; there is nothing there to read.
- A slash inside a name. Last, /attachments/invoices~1BK-10042.pdf/pages. The second token is decoded before it is used: ~1 becomes /, so it names the member invoices/BK-10042.pdf, and pages is 2.
The program evaluates those pointers and seven more on the same order, both readers giving the same answers:
Python · pointer/pointer.py
# JSON Pointer (RFC 6901), evaluated as the RFC describes: split at "/", decode ~1 then ~0 in each token, and take one
# step per token, a member name in an object or a decimal index in an array.
import json
import re
order = {
"id": "BK-10042",
"status": "paid",
"items": [{"sku": "BK-101", "qty": 2}, {"sku": "BK-205", "qty": 1}],
"attachments": {"invoices/BK-10042.pdf": {"pages": 2}, "~draft": {"pages": 1}},
}
INDEX = re.compile(r"0|[1-9][0-9]*") # no leading zeros, no sign
q = json.dumps # quote a name the way JSON writes it
def kind_of(value):
return "boolean" if isinstance(value, bool) else "null" if value is None else "string" if isinstance(value, str) else "number"
def tokens_of(pointer):
if pointer == "":
return [] # the empty pointer names the whole document
if not pointer.startswith("/"):
raise ValueError(f"{q(pointer)} does not start with /")
if re.search(r"~(?![01])", pointer):
raise ValueError("a ~ must be followed by 0 or 1")
return [t.replace("~1", "/").replace("~0", "~") for t in pointer[1:].split("/")]
def pointer_get(doc, pointer):
value = doc
for token in tokens_of(pointer):
if isinstance(value, dict):
if token not in value:
raise ValueError(f"no member {q(token)}")
value = value[token]
elif isinstance(value, list):
if token == "-":
raise ValueError('"-" is the place after the last element, which holds no value')
if not INDEX.fullmatch(token):
raise ValueError(f"{q(token)} is not an array index")
if int(token) >= len(value):
raise ValueError(f"index {token} is past the end of {len(value)} elements")
value = value[int(token)]
else:
raise ValueError(f"{q(token)} goes below a {kind_of(value)}, which has no members")
return value
def pointer_of(path):
"""Build a pointer from member names and indexes: ~ becomes ~0 first, then / becomes ~1."""
return "".join("/" + str(p).replace("~", "~0").replace("/", "~1") for p in path)
for p in ["", "/id", "/items/1/qty", "/attachments/invoices~1BK-10042.pdf/pages", "/attachments/~0draft/pages",
"/items/01", "/items/2", "/items/-", "/qty", "/id/0", "/items/~2"]:
try:
shown = json.dumps(pointer_get(order, p), separators=(",", ":"))
if p == "":
shown = "the whole document, " + shown[:24] + "..."
print(f"{q(p):46} -> {shown}")
except ValueError as e:
print(f"{q(p):46} -> error: {e}")
print("built:", pointer_of(["attachments", "invoices/BK-10042.pdf", "pages"]))
# The order of decoding matters: the token "~01" means the name "~1".
print('"~01", ~1 decoded first:', q("~01".replace("~1", "/").replace("~0", "~")))
print('"~01", ~0 decoded first:', q("~01".replace("~0", "~").replace("~1", "/"))) Output
"" -> the whole document, {"id":"BK-10042","status...
"/id" -> "BK-10042"
"/items/1/qty" -> 1
"/attachments/invoices~1BK-10042.pdf/pages" -> 2
"/attachments/~0draft/pages" -> 1
"/items/01" -> error: "01" is not an array index
"/items/2" -> error: index 2 is past the end of 2 elements
"/items/-" -> error: "-" is the place after the last element, which holds no value
"/qty" -> error: no member "qty"
"/id/0" -> error: "0" goes below a string, which has no members
"/items/~2" -> error: a ~ must be followed by 0 or 1
built: /attachments/invoices~1BK-10042.pdf/pages
"~01", ~1 decoded first: "~1"
"~01", ~0 decoded first: "/"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pointer.py
JavaScript · pointer/pointer.mjs
// JSON Pointer (RFC 6901), evaluated as the RFC describes: split at "/", decode ~1 then ~0 in each token, and take one
// step per token, a member name in an object or a decimal index in an array.
const order = {
id: 'BK-10042',
status: 'paid',
items: [{ sku: 'BK-101', qty: 2 }, { sku: 'BK-205', qty: 1 }],
attachments: { 'invoices/BK-10042.pdf': { pages: 2 }, '~draft': { pages: 1 } },
};
const INDEX = /^(?:0|[1-9][0-9]*)$/; // no leading zeros, no sign
const q = JSON.stringify; // quote a name the way JSON writes it
const kindOf = (value) => (value === null ? 'null' : typeof value);
function tokensOf(pointer) {
if (pointer === '') return []; // the empty pointer names the whole document
if (!pointer.startsWith('/')) throw new Error(`${q(pointer)} does not start with /`);
if (/~(?![01])/.test(pointer)) throw new Error('a ~ must be followed by 0 or 1');
return pointer.slice(1).split('/').map((t) => t.replaceAll('~1', '/').replaceAll('~0', '~'));
}
function pointerGet(doc, pointer) {
let value = doc;
for (const token of tokensOf(pointer)) {
if (Array.isArray(value)) {
if (token === '-') throw new Error('"-" is the place after the last element, which holds no value');
if (!INDEX.test(token)) throw new Error(`${q(token)} is not an array index`);
if (Number(token) >= value.length) throw new Error(`index ${token} is past the end of ${value.length} elements`);
value = value[Number(token)];
} else if (value !== null && typeof value === 'object') {
// Object.hasOwn, never `in`: an inherited name such as "constructor" is not a member of the document.
if (!Object.hasOwn(value, token)) throw new Error(`no member ${q(token)}`);
value = value[token];
} else {
throw new Error(`${q(token)} goes below a ${kindOf(value)}, which has no members`);
}
}
return value;
}
/** Build a pointer from member names and indexes: ~ becomes ~0 first, then / becomes ~1. */
const pointerOf = (path) => path.map((p) => `/${String(p).replaceAll('~', '~0').replaceAll('/', '~1')}`).join('');
for (const p of ['', '/id', '/items/1/qty', '/attachments/invoices~1BK-10042.pdf/pages', '/attachments/~0draft/pages',
'/items/01', '/items/2', '/items/-', '/qty', '/id/0', '/items/~2']) {
try {
let shown = JSON.stringify(pointerGet(order, p));
if (p === '') shown = `the whole document, ${shown.slice(0, 24)}...`;
console.log(`${q(p).padEnd(46)} -> ${shown}`);
} catch (e) {
console.log(`${q(p).padEnd(46)} -> error: ${e.message}`);
}
}
console.log('built:', pointerOf(['attachments', 'invoices/BK-10042.pdf', 'pages']));
// The order of decoding matters: the token "~01" means the name "~1".
console.log('"~01", ~1 decoded first:', q('~01'.replaceAll('~1', '/').replaceAll('~0', '~')));
console.log('"~01", ~0 decoded first:', q('~01'.replaceAll('~0', '~').replaceAll('~1', '/'))); Output
"" -> the whole document, {"id":"BK-10042","status...
"/id" -> "BK-10042"
"/items/1/qty" -> 1
"/attachments/invoices~1BK-10042.pdf/pages" -> 2
"/attachments/~0draft/pages" -> 1
"/items/01" -> error: "01" is not an array index
"/items/2" -> error: index 2 is past the end of 2 elements
"/items/-" -> error: "-" is the place after the last element, which holds no value
"/qty" -> error: no member "qty"
"/id/0" -> error: "0" goes below a string, which has no members
"/items/~2" -> error: a ~ must be followed by 0 or 1
built: /attachments/invoices~1BK-10042.pdf/pages
"~01", ~1 decoded first: "~1"
"~01", ~0 decoded first: "/"
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pointer.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
/items/1/qty gave 1, and the two attachment pointers reached members whose names hold / and ~. Every failure
names its reason: 01 is not an index, 2 is past the end of two elements, - holds no value, qty is not a member
of the order, /id/0 tries to step below a string, and ~2 is no escape at all. Building a pointer escapes in the
opposite order, ~ before /, which is how pointer_of, pointerOf in JavaScript, produced
invoices~1BK-10042.pdf. The last two lines show why the decoding order is fixed: ~01 must become the name ~1,
never /.
In a URI, a pointer goes after # and characters that a fragment may not hold are percent-encoded, so the RFC’s member
c%d is #/c%25d. The JavaScript reader checks members with Object.hasOwn, because "constructor" in order is true
for every object, although the order has no member of that name.
Checkpoint
Check: JSON Pointer and its escapes
JSON Patch applies its operations in order, and one failure cancels all
A JSON Patch: The format of RFC 6902, media type application/json-patch+json: a JSON array of operations (add, remove, replace, move, copy and test) at JSON Pointers, applied in order and as a whole or not at all. document, media type application/json-patch+json, is a JSON array of
operations. Each names its op and a path, a JSON Pointer, and RFC 6902 defines six:
| Operation, with its members | What it does | Fails when |
|---|---|---|
add: path, value |
inserts into an array, or sets a member, creating or replacing it; - appends |
the parent of path does not exist, or an index is above the array’s length |
remove: path |
removes the value; later array elements move up | there is no value at path |
replace: path, value |
replaces the value | there is no value at path |
move: from, path |
removes the value at from and adds it at path |
from is missing, or path is inside from |
copy: from, path |
adds a copy of the value at from at path |
from is missing |
test: path, value |
changes nothing | the value at path does not equal value |
Operations run one at a time, each on the result of the one before. If any of them fails, the RFC says the patch as a
whole is not applied, and RFC 5789, which defines HTTP PATCH, requires a server to apply the whole set of changes
atomically. The lesson’s patcher gets that by working on a copy and returning it only when every operation succeeded:
Python · patch/jsonpatch.py
# JSON Patch (RFC 6902): the six operations, applied in order to a copy of the document. If any operation fails, the
# copy is thrown away and the caller's document is never touched, so a patch applies whole or not at all.
import copy
import json
import re
NEEDS = {"add": ("path", "value"), "remove": ("path",), "replace": ("path", "value"),
"move": ("from", "path"), "copy": ("from", "path"), "test": ("path", "value")}
def q(value):
"""A name or a value as compact JSON text, for messages."""
return json.dumps(value, separators=(",", ":"))
class PatchError(Exception):
"""A patch that could not be applied; the document is unchanged."""
def tokens_of(pointer):
"""The tokens of a JSON Pointer (RFC 6901), ~1 decoded before ~0."""
if pointer == "":
return []
if not pointer.startswith("/") or re.search(r"~(?![01])", pointer):
raise PatchError(f"{q(pointer)} is not a JSON Pointer")
return [t.replace("~1", "/").replace("~0", "~") for t in pointer[1:].split("/")]
def index_of(token, size):
"""An array index from 0 to size - 1 (add passes size + 1, so that it may name the end)."""
if not re.fullmatch(r"0|[1-9][0-9]*", token) or int(token) >= size:
raise PatchError(f"no array position {q(token)}")
return int(token)
def resolve(doc, tokens):
for token in tokens:
if isinstance(doc, list):
doc = doc[index_of(token, len(doc))]
elif isinstance(doc, dict) and token in doc:
doc = doc[token]
else:
raise PatchError(f"no member {q(token)}")
return doc
def json_equal(a, b):
"""test's equality: the same JSON type, numbers by value, arrays in order, objects whatever their member order."""
if isinstance(a, bool) or isinstance(b, bool):
return type(a) is type(b) and a == b
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return a == b
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(json_equal(x, y) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(json_equal(a[k], b[k]) for k in a)
return type(a) is type(b) and a == b
def add(doc, tokens, value):
if not tokens:
return value # the whole document is replaced
parent, last = resolve(doc, tokens[:-1]), tokens[-1]
if isinstance(parent, list):
parent.insert(len(parent) if last == "-" else index_of(last, len(parent) + 1), value)
elif isinstance(parent, dict):
parent[last] = value # creates the member, or replaces it
else:
raise PatchError(f"{q(last)} is below a value that has no members")
return doc
def remove(doc, tokens):
"""(doc, the value removed); the value must exist."""
if not tokens:
raise PatchError("the whole document cannot be removed")
parent, last = resolve(doc, tokens[:-1]), tokens[-1]
if isinstance(parent, list):
return doc, parent.pop(index_of(last, len(parent)))
if isinstance(parent, dict) and last in parent:
return doc, parent.pop(last)
raise PatchError(f"no member {q(last)}")
def apply_op(doc, op):
kind = op.get("op")
if kind not in NEEDS:
raise PatchError(f"unknown op {q(kind)}")
if any(member not in op for member in NEEDS[kind]):
raise PatchError(f"{kind} needs {' and '.join(NEEDS[kind])}")
path = tokens_of(op["path"])
if kind == "add":
return add(doc, path, copy.deepcopy(op["value"]))
if kind == "remove":
return remove(doc, path)[0]
if kind == "replace":
if not path:
return copy.deepcopy(op["value"])
parent, last = resolve(doc, path[:-1]), path[-1]
if isinstance(parent, list):
parent[index_of(last, len(parent))] = copy.deepcopy(op["value"])
elif isinstance(parent, dict) and last in parent:
parent[last] = copy.deepcopy(op["value"])
else:
raise PatchError(f"no member {q(last)}")
return doc
if kind == "test":
actual = resolve(doc, path)
if not json_equal(actual, op["value"]):
raise PatchError(f"the value is {q(actual)}, not {q(op['value'])}")
return doc
source = tokens_of(op["from"])
if kind == "move":
if path[: len(source)] == source and len(path) > len(source):
raise PatchError("a value cannot move into itself")
doc, value = remove(doc, source)
return add(doc, path, value)
return add(doc, path, copy.deepcopy(resolve(doc, source))) # copy
def apply_patch(doc, patch):
"""A new document with every operation applied, or PatchError; doc itself is never changed."""
result = copy.deepcopy(doc)
for number, op in enumerate(patch):
try:
result = apply_op(result, op)
except PatchError as e:
raise PatchError(f"operation {number}, {op.get('op')} {op.get('path')}: {e}") from None
return result JavaScript · patch/jsonpatch.mjs
// JSON Patch (RFC 6902): the six operations, applied in order to a copy of the document. If any operation fails, the
// copy is thrown away and the caller's document is never touched, so a patch applies whole or not at all.
const NEEDS = {
add: ['path', 'value'], remove: ['path'], replace: ['path', 'value'],
move: ['from', 'path'], copy: ['from', 'path'], test: ['path', 'value'],
};
const q = JSON.stringify; // names and values as compact JSON text, for messages
const clone = (value) => JSON.parse(JSON.stringify(value)); // a JSON document copies exactly this way
export class PatchError extends Error {}
/** The tokens of a JSON Pointer (RFC 6901), ~1 decoded before ~0. */
function tokensOf(pointer) {
if (pointer === '') return [];
if (!pointer.startsWith('/') || /~(?![01])/.test(pointer)) throw new PatchError(`${q(pointer)} is not a JSON Pointer`);
return pointer.slice(1).split('/').map((t) => t.replaceAll('~1', '/').replaceAll('~0', '~'));
}
/** An array index from 0 to size - 1 (add passes size + 1, so that it may name the end). */
function indexOf(token, size) {
if (!/^(?:0|[1-9][0-9]*)$/.test(token) || Number(token) >= size) throw new PatchError(`no array position ${q(token)}`);
return Number(token);
}
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
// A member is set as an own data property, so a name such as "__proto__" stays data and never reaches a prototype.
const setMember = (obj, name, value) => Object.defineProperty(obj, name, { value, writable: true, enumerable: true, configurable: true });
function resolve(doc, tokens) {
for (const token of tokens) {
if (Array.isArray(doc)) doc = doc[indexOf(token, doc.length)];
else if (isObject(doc) && Object.hasOwn(doc, token)) doc = doc[token];
else throw new PatchError(`no member ${q(token)}`);
}
return doc;
}
/** test's equality: the same JSON type, numbers by value, arrays in order, objects whatever their member order. */
export function jsonEqual(a, b) {
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => jsonEqual(x, b[i]));
if (isObject(a) && isObject(b)) {
const names = Object.keys(a);
return names.length === Object.keys(b).length && names.every((k) => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]));
}
return a === b;
}
function add(doc, tokens, value) {
if (!tokens.length) return value; // the whole document is replaced
const parent = resolve(doc, tokens.slice(0, -1));
const last = tokens.at(-1);
if (Array.isArray(parent)) parent.splice(last === '-' ? parent.length : indexOf(last, parent.length + 1), 0, value);
else if (isObject(parent)) setMember(parent, last, value); // creates the member, or replaces it
else throw new PatchError(`${q(last)} is below a value that has no members`);
return doc;
}
/** [doc, the value removed]; the value must exist. */
function remove(doc, tokens) {
if (!tokens.length) throw new PatchError('the whole document cannot be removed');
const parent = resolve(doc, tokens.slice(0, -1));
const last = tokens.at(-1);
if (Array.isArray(parent)) return [doc, parent.splice(indexOf(last, parent.length), 1)[0]];
if (isObject(parent) && Object.hasOwn(parent, last)) {
const value = parent[last];
delete parent[last];
return [doc, value];
}
throw new PatchError(`no member ${q(last)}`);
}
function applyOp(doc, op) {
const kind = op.op;
if (!Object.hasOwn(NEEDS, kind)) throw new PatchError(`unknown op ${q(kind)}`);
if (NEEDS[kind].some((member) => !Object.hasOwn(op, member))) throw new PatchError(`${kind} needs ${NEEDS[kind].join(' and ')}`);
const path = tokensOf(op.path);
if (kind === 'add') return add(doc, path, clone(op.value));
if (kind === 'remove') return remove(doc, path)[0];
if (kind === 'replace') {
if (!path.length) return clone(op.value);
const parent = resolve(doc, path.slice(0, -1));
const last = path.at(-1);
if (Array.isArray(parent)) parent[indexOf(last, parent.length)] = clone(op.value);
else if (isObject(parent) && Object.hasOwn(parent, last)) setMember(parent, last, clone(op.value));
else throw new PatchError(`no member ${q(last)}`);
return doc;
}
if (kind === 'test') {
const actual = resolve(doc, path);
if (!jsonEqual(actual, op.value)) throw new PatchError(`the value is ${q(actual)}, not ${q(op.value)}`);
return doc;
}
const source = tokensOf(op.from);
if (kind === 'move') {
if (path.length > source.length && source.every((t, i) => path[i] === t)) throw new PatchError('a value cannot move into itself');
const [rest, value] = remove(doc, source);
return add(rest, path, value);
}
return add(doc, path, clone(resolve(doc, source))); // copy
}
/** A new document with every operation applied, or PatchError; doc itself is never changed. */
export function applyPatch(doc, patch) {
let result = clone(doc);
patch.forEach((op, number) => {
try {
result = applyOp(result, op);
} catch (e) {
if (!(e instanceof PatchError)) throw e;
throw new PatchError(`operation ${number}, ${op.op} ${op.path}: ${e.message}`);
}
});
return result;
} Here it applies three patches to the stored order:
Python · patch/patch_demo.py
# Three JSON Patch documents against one stored order: one that applies, one whose last operation fails, and tests
# that show which values count as equal.
import json
from jsonpatch import PatchError, apply_patch
order = {
"id": "BK-10042",
"status": "paid",
"branch": "Pune",
"items": [{"sku": "BK-101", "qty": 2}, {"sku": "BK-205", "qty": 1}],
"tags": ["gift", "fragile"],
}
def show(doc):
return json.dumps(doc, separators=(",", ":"))
# 1. Every operation, applied in order to a copy of the order.
edit = [
{"op": "test", "path": "/status", "value": "paid"},
{"op": "replace", "path": "/items/0/qty", "value": 3},
{"op": "add", "path": "/items/-", "value": {"sku": "BK-310", "qty": 1}},
{"op": "remove", "path": "/tags/0"},
{"op": "copy", "from": "/branch", "path": "/pickup"},
{"op": "move", "from": "/items/2", "path": "/items/0"},
]
print("edited:", show(apply_patch(order, edit)))
# 2. The last operation fails, so the first two are not applied either.
ship = [
{"op": "replace", "path": "/status", "value": "shipped"},
{"op": "add", "path": "/tracking", "value": "AWB-7781"},
{"op": "test", "path": "/items/0/qty", "value": 5},
]
try:
apply_patch(order, ship)
except PatchError as e:
print("refused:", e)
print("stored:", show(order))
# 3. Which values a test counts as equal.
for text in [
'{"op": "test", "path": "/items/0/qty", "value": 2.0}',
'{"op": "test", "path": "/items/0/qty", "value": "2"}',
'{"op": "test", "path": "/items/1", "value": {"qty": 1, "sku": "BK-205"}}',
'{"op": "test", "path": "/tags", "value": ["fragile", "gift"]}',
]:
try:
apply_patch(order, [json.loads(text)])
print(f"{text} -> passes")
except PatchError as e:
print(f"{text} -> {str(e).split(': ', 1)[1]}") Output
edited: {"id":"BK-10042","status":"paid","branch":"Pune","items":[{"sku":"BK-310","qty":1},{"sku":"BK-101","qty":3},{"sku":"BK-205","qty":1}],"tags":["fragile"],"pickup":"Pune"}
refused: operation 2, test /items/0/qty: the value is 2, not 5
stored: {"id":"BK-10042","status":"paid","branch":"Pune","items":[{"sku":"BK-101","qty":2},{"sku":"BK-205","qty":1}],"tags":["gift","fragile"]}
{"op": "test", "path": "/items/0/qty", "value": 2.0} -> passes
{"op": "test", "path": "/items/0/qty", "value": "2"} -> the value is 2, not "2"
{"op": "test", "path": "/items/1", "value": {"qty": 1, "sku": "BK-205"}} -> passes
{"op": "test", "path": "/tags", "value": ["fragile", "gift"]} -> the value is ["gift","fragile"], not ["fragile","gift"]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 patch_demo.py
JavaScript · patch/patch_demo.mjs
// Three JSON Patch documents against one stored order: one that applies, one whose last operation fails, and tests
// that show which values count as equal.
import { PatchError, applyPatch } from './jsonpatch.mjs';
const order = {
id: 'BK-10042',
status: 'paid',
branch: 'Pune',
items: [{ sku: 'BK-101', qty: 2 }, { sku: 'BK-205', qty: 1 }],
tags: ['gift', 'fragile'],
};
const show = (doc) => JSON.stringify(doc);
// 1. Every operation, applied in order to a copy of the order.
const edit = [
{ op: 'test', path: '/status', value: 'paid' },
{ op: 'replace', path: '/items/0/qty', value: 3 },
{ op: 'add', path: '/items/-', value: { sku: 'BK-310', qty: 1 } },
{ op: 'remove', path: '/tags/0' },
{ op: 'copy', from: '/branch', path: '/pickup' },
{ op: 'move', from: '/items/2', path: '/items/0' },
];
console.log('edited:', show(applyPatch(order, edit)));
// 2. The last operation fails, so the first two are not applied either.
const ship = [
{ op: 'replace', path: '/status', value: 'shipped' },
{ op: 'add', path: '/tracking', value: 'AWB-7781' },
{ op: 'test', path: '/items/0/qty', value: 5 },
];
try {
applyPatch(order, ship);
} catch (e) {
if (!(e instanceof PatchError)) throw e;
console.log('refused:', e.message);
}
console.log('stored:', show(order));
// 3. Which values a test counts as equal.
for (const text of [
'{"op": "test", "path": "/items/0/qty", "value": 2.0}',
'{"op": "test", "path": "/items/0/qty", "value": "2"}',
'{"op": "test", "path": "/items/1", "value": {"qty": 1, "sku": "BK-205"}}',
'{"op": "test", "path": "/tags", "value": ["fragile", "gift"]}',
]) {
try {
applyPatch(order, [JSON.parse(text)]);
console.log(`${text} -> passes`);
} catch (e) {
if (!(e instanceof PatchError)) throw e;
console.log(`${text} -> ${e.message.split(': ').slice(1).join(': ')}`);
}
} Output
edited: {"id":"BK-10042","status":"paid","branch":"Pune","items":[{"sku":"BK-310","qty":1},{"sku":"BK-101","qty":3},{"sku":"BK-205","qty":1}],"tags":["fragile"],"pickup":"Pune"}
refused: operation 2, test /items/0/qty: the value is 2, not 5
stored: {"id":"BK-10042","status":"paid","branch":"Pune","items":[{"sku":"BK-101","qty":2},{"sku":"BK-205","qty":1}],"tags":["gift","fragile"]}
{"op": "test", "path": "/items/0/qty", "value": 2.0} -> passes
{"op": "test", "path": "/items/0/qty", "value": "2"} -> the value is 2, not "2"
{"op": "test", "path": "/items/1", "value": {"qty": 1, "sku": "BK-205"}} -> passes
{"op": "test", "path": "/tags", "value": ["fragile", "gift"]} -> the value is ["gift","fragile"], not ["fragile","gift"]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node patch_demo.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The first patch ran all six kinds of operation in order: test found the status paid, the quantity of BK-101 became
3, BK-310 was appended with -, the first tag was removed, the branch was copied to pickup, and the new line moved to
the front. Order matters, because each operation sees the result of the last: /items/2 named BK-310 only after the
add had put it there.
The second patch replaced the status and added tracking before its test failed, and the order printed after it is
the stored one, status paid and no tracking. The message names operation 2, counting from 0 as a pointer into the
patch, /2, would. Step through what the service did:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The patch. The client sends three operations: replace /status with shipped, add /tracking, and test that /items/0/qty is 5.
- A copy. The service reads the stored order and works on a copy of it, never on the order itself.
- Operation 0. Operation 0 replaces the status on the copy: it now says shipped.
- Operation 1. Operation 1 adds tracking AWB-7781 to the copy. Two of the three operations have run.
- Operation 2 fails. Operation 2 tests /items/0/qty for 5, but the value is 2. RFC 6902 says the whole patch then fails, so the service stops here.
- The copy is dropped. The service throws the copy away and answers with an error that names operation 2. Nothing was written, so no reader ever sees a half-shipped order.
- The order is unchanged. The stored order still says paid, with no tracking, exactly as the program printed it after the refusal. The HTTP PATCH method asks for this: the server applies all the changes or none.
Applying operations straight to the stored document
Wrong: running each operation on the document in the store, which leaves shipped and the tracking number behind when
operation 2 fails. Right: apply the whole patch to a copy, and write the copy back only when every operation has
succeeded.
Checkpoint
Check: JSON Patch, in order and all or nothing
test turns a patch into a conditional update
The last four lines of the program show what test compares. Values must have the same JSON type: 2.0 equals 2,
because numbers compare by value, but the string "2" does not. Objects are equal when they have the same members,
whatever their order, while arrays must hold the same elements in the same order, which is why ["fragile", "gift"]
failed against ["gift", "fragile"].
That makes test a guard against the lost update: The silent loss of one writer's change when another writer, who read the same earlier version, saves a change on top of it. A test in a JSON Patch, or If-Match in HTTP, refuses the second write instead.. Two agents read the same order and see
2 copies of BK-101; one customer adds a copy, another returns one. If each agent sends only a replace, the second
write silently undoes the first. If each patch first tests the value its agent saw, the second patch fails, and the
service can say so:
Python · patch/conditional.py
# Two support agents read the same order, both see 2 copies of BK-101, and both send a change. Without a test the
# second change silently undoes the first; with a test of the value each agent saw, the second patch is refused.
import json
from jsonpatch import PatchError, apply_patch
stored = {"id": "BK-10042", "items": [{"sku": "BK-101", "qty": 2}, {"sku": "BK-205", "qty": 1}]}
def server(doc, patch):
"""What a PATCH endpoint would answer: the new document and 200, or the old one and 409 Conflict."""
try:
return apply_patch(doc, patch), "200 OK"
except PatchError as e:
return doc, f"409 Conflict ({e})"
a_wants = {"op": "replace", "path": "/items/0/qty", "value": 3} # the customer added a copy
b_wants = {"op": "replace", "path": "/items/0/qty", "value": 1} # the customer returned a copy
print("without test:")
doc, status = server(stored, [a_wants])
print(" agent A:", status, "qty", doc["items"][0]["qty"])
doc, status = server(doc, [b_wants])
print(" agent B:", status, "qty", doc["items"][0]["qty"], "<- A's change is gone, and nobody was told")
print("with a test of the value each agent saw:")
saw = {"op": "test", "path": "/items/0/qty", "value": 2}
doc, status = server(stored, [saw, a_wants])
print(" agent A:", status, "qty", doc["items"][0]["qty"])
doc, status = server(doc, [saw, b_wants])
print(" agent B:", status, "qty", doc["items"][0]["qty"])
# B reads the order again, sees 3, and sends a patch built on what is really there.
doc, status = server(doc, [{"op": "test", "path": "/items/0/qty", "value": 3}, {**b_wants, "value": 2}])
print(" agent B, after reading again:", status, "qty", doc["items"][0]["qty"])
print("final:", json.dumps(doc, separators=(",", ":"))) Output
without test:
agent A: 200 OK qty 3
agent B: 200 OK qty 1 <- A's change is gone, and nobody was told
with a test of the value each agent saw:
agent A: 200 OK qty 3
agent B: 409 Conflict (operation 0, test /items/0/qty: the value is 3, not 2) qty 3
agent B, after reading again: 200 OK qty 2
final: {"id":"BK-10042","items":[{"sku":"BK-101","qty":2},{"sku":"BK-205","qty":1}]}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 conditional.py
JavaScript · patch/conditional.mjs
// Two support agents read the same order, both see 2 copies of BK-101, and both send a change. Without a test the
// second change silently undoes the first; with a test of the value each agent saw, the second patch is refused.
import { PatchError, applyPatch } from './jsonpatch.mjs';
const stored = { id: 'BK-10042', items: [{ sku: 'BK-101', qty: 2 }, { sku: 'BK-205', qty: 1 }] };
/** What a PATCH endpoint would answer: the new document and 200, or the old one and 409 Conflict. */
function server(doc, patch) {
try {
return [applyPatch(doc, patch), '200 OK'];
} catch (e) {
if (!(e instanceof PatchError)) throw e;
return [doc, `409 Conflict (${e.message})`];
}
}
const aWants = { op: 'replace', path: '/items/0/qty', value: 3 }; // the customer added a copy
const bWants = { op: 'replace', path: '/items/0/qty', value: 1 }; // the customer returned a copy
console.log('without test:');
let [doc, status] = server(stored, [aWants]);
console.log(' agent A:', status, 'qty', doc.items[0].qty);
[doc, status] = server(doc, [bWants]);
console.log(' agent B:', status, 'qty', doc.items[0].qty, "<- A's change is gone, and nobody was told");
console.log('with a test of the value each agent saw:');
const saw = { op: 'test', path: '/items/0/qty', value: 2 };
[doc, status] = server(stored, [saw, aWants]);
console.log(' agent A:', status, 'qty', doc.items[0].qty);
[doc, status] = server(doc, [saw, bWants]);
console.log(' agent B:', status, 'qty', doc.items[0].qty);
// B reads the order again, sees 3, and sends a patch built on what is really there.
[doc, status] = server(doc, [{ op: 'test', path: '/items/0/qty', value: 3 }, { ...bWants, value: 2 }]);
console.log(' agent B, after reading again:', status, 'qty', doc.items[0].qty);
console.log('final:', JSON.stringify(doc)); Output
without test:
agent A: 200 OK qty 3
agent B: 200 OK qty 1 <- A's change is gone, and nobody was told
with a test of the value each agent saw:
agent A: 200 OK qty 3
agent B: 409 Conflict (operation 0, test /items/0/qty: the value is 3, not 2) qty 3
agent B, after reading again: 200 OK qty 2
final: {"id":"BK-10042","items":[{"sku":"BK-101","qty":2},{"sku":"BK-205","qty":1}]}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node conditional.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Without the test, both agents got 200 OK and the order ended at 1, as if the added copy had never existed. With it,
agent B’s patch was refused with 409 Conflict, because the value was 3, not 2. Agent B read the order again and sent a
patch built on what was really there, so the order ended at 2, with both changes counted. Step through the second run:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Both read 2. Both agents open order BK-10042 and see 2 copies of BK-101. Each plans a change from that reading.
- A sends a patch. Agent A sends two operations: test that /items/0/qty is 2, then replace it with 3.
- A's test passes. The value is 2, so the test passes and the replace runs. The service answers 200, and the order now holds 3 copies.
- B sends a patch. Agent B sends a patch built on the same old reading: test that the value is 2, then replace it with 1.
- B's test fails. The value is 3 now, so the test fails and the replace never runs. The service answers 409 Conflict with the reason, instead of silently undoing A's change.
- B reads again. Agent B reads the order again, sees 3, and applies the customer's return to what is really there: one copy fewer is 2.
- Both changes kept. The new test passes, and the order ends with 2 copies: the copy A added and the copy B removed are both counted. Without the tests, B's patch would have set 1 and lost A's change.
Key idea
Put a test of every value a change depends on in front of the change: the patch then applies only to the document
its sender saw, and fails as a whole on any other.
HTTP has a guard of its own for the same problem: RFC 9110’s If-Match makes a request conditional on the version of
the whole resource, named by its ETag, which works for any format of change. A test is finer: it checks only the
values the change depends on, so an edit elsewhere in the order does not refuse it.
Checkpoint
Check: test and conditional updates
Merge Patch sends the changed members, and null deletes
A Merge Patch: The format of RFC 7396, media type application/merge-patch+json: an object of changed members merged into a document, where null deletes a member and arrays are replaced whole., RFC 7396, media type application/merge-patch+json, looks like the
document itself: {"theme": "light"} changes one member and leaves the rest. Its algorithm fits in a paragraph. A
patch that is not an object replaces the target whole. An object patch is merged member by member: a member set to
null is deleted, any other value is merged in the same way, and a member the patch does not name is left alone.
Two things follow that surprise people. A merge patch cannot set a member to null, because null means delete. And
it cannot change one element of an array, because an array is not an object, so it is replaced whole. Step through three
patches against a stored profile:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The stored profile. The stored profile has four members: name, nickname, emails with two addresses, and theme, set to dark.
- A new value. The first patch is {"theme": "light"}. A member whose value is not null and not an object replaces the stored value, so theme becomes light.
- Every other member. Members the patch does not name are left alone: name, nickname and both emails are as they were.
- A null. The second patch is {"nickname": null}. In a merge patch null means "delete", so the member disappears; a merge patch has no way to store null itself.
- An array. The third patch sends emails with one new address. An array is not an object, so it is not merged: it replaces the whole list, and the first address is gone.
- What it cannot say. So a merge patch can set, replace and delete members of objects, but it cannot set a member to null or change one element of an array. JSON Patch can do both.
The program runs the fifteen test cases of RFC 7396’s Appendix A and then the three patches:
Python · merge/merge.py
# JSON Merge Patch (RFC 7396): the patch is an object of changes. A member set to null is deleted, any other value is
# merged in, and anything that is not an object, an array included, replaces what was there whole.
import json
def merge_patch(target, patch):
if not isinstance(patch, dict):
return patch # a non-object patch replaces the target whole
if not isinstance(target, dict):
target = {} # an object patch starts from an empty object
result = dict(target)
for name, value in patch.items():
if value is None:
result.pop(name, None) # null deletes the member
else:
result[name] = merge_patch(result.get(name), value)
return result
# The test cases of RFC 7396, Appendix A: original, patch and the result the RFC gives.
CASES = [
('{"a":"b"}', '{"a":"c"}', '{"a":"c"}'),
('{"a":"b"}', '{"b":"c"}', '{"a":"b","b":"c"}'),
('{"a":"b"}', '{"a":null}', "{}"),
('{"a":"b","b":"c"}', '{"a":null}', '{"b":"c"}'),
('{"a":["b"]}', '{"a":"c"}', '{"a":"c"}'),
('{"a":"c"}', '{"a":["b"]}', '{"a":["b"]}'),
('{"a":{"b":"c"}}', '{"a":{"b":"d","c":null}}', '{"a":{"b":"d"}}'),
('{"a":[{"b":"c"}]}', '{"a":[1]}', '{"a":[1]}'),
('["a","b"]', '["c","d"]', '["c","d"]'),
('{"a":"b"}', '["c"]', '["c"]'),
('{"a":"foo"}', "null", "null"),
('{"a":"foo"}', '"bar"', '"bar"'),
('{"e":null}', '{"a":1}', '{"e":null,"a":1}'),
("[1,2]", '{"a":"b","c":null}', '{"a":"b"}'),
("{}", '{"a":{"bb":{"ccc":null}}}', '{"a":{"bb":{}}}'),
]
right = sum(merge_patch(json.loads(o), json.loads(p)) == json.loads(r) for o, p, r in CASES)
print(f"RFC 7396, Appendix A: {right} of {len(CASES)} cases give the RFC's result")
profile = {"name": "Asha", "nickname": "Ash", "emails": ["asha@example.com", "asha.work@example.com"], "theme": "dark"}
show = lambda doc: json.dumps(doc, separators=(",", ":"))
print("change one member: ", show(merge_patch(profile, {"theme": "light"})))
print("try to set null: ", show(merge_patch(profile, {"nickname": None})))
print("change one email: ", show(merge_patch(profile, {"emails": ["asha.home@example.com"]}))) Output
RFC 7396, Appendix A: 15 of 15 cases give the RFC's result
change one member: {"name":"Asha","nickname":"Ash","emails":["asha@example.com","asha.work@example.com"],"theme":"light"}
try to set null: {"name":"Asha","emails":["asha@example.com","asha.work@example.com"],"theme":"dark"}
change one email: {"name":"Asha","nickname":"Ash","emails":["asha.home@example.com"],"theme":"dark"}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 merge.py
JavaScript · merge/merge.mjs
// JSON Merge Patch (RFC 7396): the patch is an object of changes. A member set to null is deleted, any other value is
// merged in, and anything that is not an object, an array included, replaces what was there whole.
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
// A member is set as an own data property, so a name such as "__proto__" stays data and never becomes a prototype.
const setMember = (obj, name, value) => Object.defineProperty(obj, name, { value, writable: true, enumerable: true, configurable: true });
function mergePatch(target, patch) {
if (!isObject(patch)) return patch; // a non-object patch replaces the target whole
const result = isObject(target) ? { ...target } : {}; // an object patch starts from an empty object
for (const [name, value] of Object.entries(patch)) {
if (value === null) delete result[name]; // null deletes the member
else setMember(result, name, mergePatch(Object.hasOwn(result, name) ? result[name] : undefined, value));
}
return result;
}
/** Whether two JSON values are equal, whatever the order of object members. */
function same(a, b) {
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => same(x, b[i]));
if (isObject(a) && isObject(b)) return Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.hasOwn(b, k) && same(a[k], b[k]));
return a === b;
}
// The test cases of RFC 7396, Appendix A: original, patch and the result the RFC gives.
const CASES = [
['{"a":"b"}', '{"a":"c"}', '{"a":"c"}'],
['{"a":"b"}', '{"b":"c"}', '{"a":"b","b":"c"}'],
['{"a":"b"}', '{"a":null}', '{}'],
['{"a":"b","b":"c"}', '{"a":null}', '{"b":"c"}'],
['{"a":["b"]}', '{"a":"c"}', '{"a":"c"}'],
['{"a":"c"}', '{"a":["b"]}', '{"a":["b"]}'],
['{"a":{"b":"c"}}', '{"a":{"b":"d","c":null}}', '{"a":{"b":"d"}}'],
['{"a":[{"b":"c"}]}', '{"a":[1]}', '{"a":[1]}'],
['["a","b"]', '["c","d"]', '["c","d"]'],
['{"a":"b"}', '["c"]', '["c"]'],
['{"a":"foo"}', 'null', 'null'],
['{"a":"foo"}', '"bar"', '"bar"'],
['{"e":null}', '{"a":1}', '{"e":null,"a":1}'],
['[1,2]', '{"a":"b","c":null}', '{"a":"b"}'],
['{}', '{"a":{"bb":{"ccc":null}}}', '{"a":{"bb":{}}}'],
];
const right = CASES.filter(([o, p, r]) => same(mergePatch(JSON.parse(o), JSON.parse(p)), JSON.parse(r))).length;
console.log(`RFC 7396, Appendix A: ${right} of ${CASES.length} cases give the RFC's result`);
const profile = { name: 'Asha', nickname: 'Ash', emails: ['asha@example.com', 'asha.work@example.com'], theme: 'dark' };
const show = (doc) => JSON.stringify(doc);
console.log('change one member: ', show(mergePatch(profile, { theme: 'light' })));
console.log('try to set null: ', show(mergePatch(profile, { nickname: null })));
console.log('change one email: ', show(mergePatch(profile, { emails: ['asha.home@example.com'] }))); Output
RFC 7396, Appendix A: 15 of 15 cases give the RFC's result
change one member: {"name":"Asha","nickname":"Ash","emails":["asha@example.com","asha.work@example.com"],"theme":"light"}
try to set null: {"name":"Asha","emails":["asha@example.com","asha.work@example.com"],"theme":"dark"}
change one email: {"name":"Asha","nickname":"Ash","emails":["asha.home@example.com"],"theme":"dark"}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node merge.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
All fifteen cases gave the RFC’s result. The profile patches show the limits in data: the nickname disappeared instead
of becoming null, and sending one new email replaced the list, so asha@example.com was lost. The RFC itself says
the format suits documents that do not use explicit null values.
Send the whole list
Press Edit, change the email patch to {"emails": ["asha@example.com", "asha.home@example.com"]}, and run it again.
The first address now survives, because a merge patch can change an array only by sending all of it.
Checkpoint
Check: Merge Patch, nulls and arrays
Choose Merge Patch for simple objects, JSON Patch for arrays, nulls and conditions
The two formats can describe the same change at very different costs. This program compares two versions of an order, writes the change both ways, and applies each to the first version:
Python · patch/diff.py
# From two versions of an order, make a JSON Patch and a Merge Patch, apply each to version 1, and check which one
# gives back version 2. Arrays are compared by position, the simplest diff that is always correct.
import json
from jsonpatch import apply_patch, json_equal
v1 = {"id": "BK-10042", "status": "paid", "note": "call before delivery",
"items": [{"sku": "BK-101", "qty": 2}, {"sku": "BK-205", "qty": 1}]}
v2 = {"id": "BK-10042", "status": "shipped", "note": None, "tracking": "AWB-7781",
"items": [{"sku": "BK-101", "qty": 3}, {"sku": "BK-205", "qty": 1}]}
def step(path, name):
return f"{path}/{str(name).replace('~', '~0').replace('/', '~1')}"
def json_patch(a, b, path=""):
if isinstance(a, dict) and isinstance(b, dict):
ops = [{"op": "remove", "path": step(path, k)} for k in a if k not in b]
for k in b:
ops += json_patch(a[k], b[k], step(path, k)) if k in a else [{"op": "add", "path": step(path, k), "value": b[k]}]
return ops
if isinstance(a, list) and isinstance(b, list):
common = min(len(a), len(b))
ops = [op for i in range(common) for op in json_patch(a[i], b[i], step(path, i))]
ops += [{"op": "remove", "path": step(path, i)} for i in range(len(a) - 1, common - 1, -1)] # from the end
return ops + [{"op": "add", "path": step(path, "-"), "value": v} for v in b[common:]]
return [] if json_equal(a, b) else [{"op": "replace", "path": path, "value": b}]
def merge_patch_of(a, b):
if not (isinstance(a, dict) and isinstance(b, dict)):
return b
patch = {k: None for k in a if k not in b}
for k in b:
if k not in a:
patch[k] = b[k]
elif not json_equal(a[k], b[k]):
patch[k] = merge_patch_of(a[k], b[k])
return patch
def merge_patch(target, patch): # RFC 7396, as in the merge example
if not isinstance(patch, dict):
return patch
result = dict(target) if isinstance(target, dict) else {}
for name, value in patch.items():
if value is None:
result.pop(name, None)
else:
result[name] = merge_patch(result.get(name), value)
return result
def text(doc):
return json.dumps(doc, separators=(",", ":"))
ops = json_patch(v1, v2)
mp = merge_patch_of(v1, v2)
print(f"JSON Patch, {len(ops)} operations, {len(text(ops))} bytes:")
for op in ops:
print(" " + text(op))
print(f"Merge Patch, {len(text(mp))} bytes:")
print(" " + text(mp))
print("JSON Patch gives version 2:", json_equal(apply_patch(v1, ops), v2))
merged = merge_patch(v1, mp)
print("Merge Patch gives version 2:", json_equal(merged, v2), "| note in the result:", "note" in merged) Output
JSON Patch, 4 operations, 198 bytes:
{"op":"replace","path":"/status","value":"shipped"}
{"op":"replace","path":"/note","value":null}
{"op":"add","path":"/tracking","value":"AWB-7781"}
{"op":"replace","path":"/items/0/qty","value":3}
Merge Patch, 114 bytes:
{"status":"shipped","note":null,"tracking":"AWB-7781","items":[{"sku":"BK-101","qty":3},{"sku":"BK-205","qty":1}]}
JSON Patch gives version 2: True
Merge Patch gives version 2: False | note in the result: False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 diff.py
JavaScript · patch/diff.mjs
// From two versions of an order, make a JSON Patch and a Merge Patch, apply each to version 1, and check which one
// gives back version 2. Arrays are compared by position, the simplest diff that is always correct.
import { applyPatch, jsonEqual } from './jsonpatch.mjs';
const v1 = { id: 'BK-10042', status: 'paid', note: 'call before delivery',
items: [{ sku: 'BK-101', qty: 2 }, { sku: 'BK-205', qty: 1 }] };
const v2 = { id: 'BK-10042', status: 'shipped', note: null, tracking: 'AWB-7781',
items: [{ sku: 'BK-101', qty: 3 }, { sku: 'BK-205', qty: 1 }] };
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
// A member is set as an own data property, so a name such as "__proto__" stays data and never becomes a prototype.
const setMember = (obj, name, value) => Object.defineProperty(obj, name, { value, writable: true, enumerable: true, configurable: true });
const step = (path, name) => `${path}/${String(name).replaceAll('~', '~0').replaceAll('/', '~1')}`;
function jsonPatch(a, b, path = '') {
if (isObject(a) && isObject(b)) {
const ops = Object.keys(a).filter((k) => !Object.hasOwn(b, k)).map((k) => ({ op: 'remove', path: step(path, k) }));
for (const k of Object.keys(b)) {
ops.push(...(Object.hasOwn(a, k) ? jsonPatch(a[k], b[k], step(path, k)) : [{ op: 'add', path: step(path, k), value: b[k] }]));
}
return ops;
}
if (Array.isArray(a) && Array.isArray(b)) {
const common = Math.min(a.length, b.length);
const ops = [];
for (let i = 0; i < common; i++) ops.push(...jsonPatch(a[i], b[i], step(path, i)));
for (let i = a.length - 1; i >= common; i--) ops.push({ op: 'remove', path: step(path, i) }); // from the end
for (const v of b.slice(common)) ops.push({ op: 'add', path: step(path, '-'), value: v });
return ops;
}
return jsonEqual(a, b) ? [] : [{ op: 'replace', path, value: b }];
}
function mergePatchOf(a, b) {
if (!(isObject(a) && isObject(b))) return b;
const patch = {};
for (const k of Object.keys(a)) if (!Object.hasOwn(b, k)) setMember(patch, k, null);
for (const k of Object.keys(b)) {
if (!Object.hasOwn(a, k)) setMember(patch, k, b[k]);
else if (!jsonEqual(a[k], b[k])) setMember(patch, k, mergePatchOf(a[k], b[k]));
}
return patch;
}
function mergePatch(target, patch) { // RFC 7396, as in the merge example
if (!isObject(patch)) return patch;
const result = isObject(target) ? { ...target } : {};
for (const [name, value] of Object.entries(patch)) {
if (value === null) delete result[name];
else setMember(result, name, mergePatch(Object.hasOwn(result, name) ? result[name] : undefined, value));
}
return result;
}
const text = (doc) => JSON.stringify(doc);
const ops = jsonPatch(v1, v2);
const mp = mergePatchOf(v1, v2);
console.log(`JSON Patch, ${ops.length} operations, ${text(ops).length} bytes:`);
for (const op of ops) console.log(` ${text(op)}`);
console.log(`Merge Patch, ${text(mp).length} bytes:`);
console.log(` ${text(mp)}`);
console.log('JSON Patch gives version 2:', jsonEqual(applyPatch(v1, ops), v2));
const merged = mergePatch(v1, mp);
console.log('Merge Patch gives version 2:', jsonEqual(merged, v2), '| note in the result:', Object.hasOwn(merged, 'note')); Output
JSON Patch, 4 operations, 198 bytes:
{"op":"replace","path":"/status","value":"shipped"}
{"op":"replace","path":"/note","value":null}
{"op":"add","path":"/tracking","value":"AWB-7781"}
{"op":"replace","path":"/items/0/qty","value":3}
Merge Patch, 114 bytes:
{"status":"shipped","note":null,"tracking":"AWB-7781","items":[{"sku":"BK-101","qty":3},{"sku":"BK-205","qty":1}]}
JSON Patch gives version 2: true
Merge Patch gives version 2: false | note in the result: false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node diff.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The JSON Patch has four operations in 198 bytes and gives version 2 exactly. The Merge Patch is shorter, 114 bytes,
yet it resent both order lines to change one quantity, and it could not say “the note is now null”: applying it
deleted the note, so the result is not version 2. A merge patch is the simpler format, and that is its whole case;
whenever nulls, single array elements or a condition matter, JSON Patch says what Merge Patch cannot. Answer the three
questions for an update of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Format | Easy to read and write | Can store null | Changes one array element | Can refuse a stale change |
|---|---|---|---|---|
| Merge Patch (RFC 7396) What it sends: an object of changed members; null deletes | Good looks like the document: {"theme": "light"} | Poor null means delete: nickname vanished instead of becoming null | Poor arrays go whole: one new email replaced both addresses | Mixed no test of its own; needs If-Match with an ETag |
| JSON Patch (RFC 6902) What it sends: a list of operations at JSON Pointers | Mixed operations and pointers, four of them for the shipping change | Good replace /note with null kept the member, set to null | Good replace /items/0/qty changed one element and nothing else | Good test refused agent B's stale change with 409 |
| The whole document (PUT) What it sends: the new version, which replaces the old one | Good send the document as it should now be | Good null is just a value in the document | Mixed possible, but the whole array is resent, and so is everything else | Mixed only with If-Match and an ETag |
How the answers decide
- “Must a client be able to set a member to null?” Yes, null is a stored value: rules out Merge Patch (RFC 7396). In a merge patch null deletes the member, so no merge patch can store a null, as the nickname showed.
- “Do updates change single elements of arrays, such as one order line?” Yes: rules out Merge Patch (RFC 7396) and adds a caveat to The whole document (PUT). A merge patch replaces an array whole, and PUT resends it whole, so two edits of one list clash; JSON Patch names the element.
- “Can two clients change the same document at the same time?” Yes, several people or services edit it: adds a caveat to Merge Patch (RFC 7396) and The whole document (PUT). Only JSON Patch can test a value inside the change itself; the others need If-Match with an ETag to refuse a stale write.
- “Must a client be able to set a member to null?” No, absent and null mean the same and “Do updates change single elements of arrays, such as one order line?” No, arrays are short and change whole: With no nulls to store and no single elements to change, Merge Patch is the simplest fit: it reads like the document.
In short: Merge Patch for small objects without nulls, edited by one writer at a time; JSON Patch, with a test of the
values the change depends on, for arrays, nulls and concurrent edits; and the whole document only when it is small and
changes as a whole. An HTTP server says which formats it takes in Accept-Patch, and answers 415 for a patch format it
does not support.
Checkpoint
Check: Choosing a patch format
Practise: a pointer reader and a merge patch, from the RFCs
The exercise asks for the two functions the lesson’s programs are built on, written from the RFCs: a JSON Pointer reader that refuses every pointer that names nothing, and a merge patch that changes neither of its arguments. The tests are the RFCs’ own: the example document of RFC 6901, section 5, and the fifteen cases of RFC 7396:
Exercise · Medium · Python, JavaScript
A JSON Pointer reader and a merge patch, from the RFCs
Write two functions straight from their RFCs, in rfc.py (Python) and rfc.mjs (JavaScript):
pointer_get(doc, pointer)/pointerGet(doc, pointer)returns the value that an RFC 6901 JSON Pointer names indoc. The empty pointer names the whole document. Each token is decoded~1to/first, then~0to~. In an object a token is a member name; in an array it is a decimal index without leading zeros, below the length. When the pointer names no value, raiseValueErrorin Python and throw anErrorin JavaScript: a pointer that does not start with/, a~not followed by0or1, a missing member, a bad or too large index,-, or a token below a string, a number,true,falseornull.merge_patch(target, patch)/mergePatch(target, patch)returns the result of the RFC 7396 merge patch: a patch that is not an object replaces the target; an object patch merges member by member into the target, or into an empty object when the target is not one, wherenulldeletes a member. Neither argument may change.
The tests use the example document of RFC 6901, section 5, and the fifteen cases of RFC 7396, Appendix A.
Python · Starter code · rfc.py
def pointer_get(doc, pointer):
"""The value that the RFC 6901 JSON Pointer names in doc; ValueError when it names none."""
return doc
def merge_patch(target, patch):
"""The RFC 7396 merge of patch into target; neither argument changes."""
return target The sample tests · test_rfc.py
import json
from rfc import merge_patch, pointer_get
# RFC 6901, section 5.
DOC = {"foo": ["bar", "baz"], "": 0, "a/b": 1, "c%d": 2, "e^f": 3, "g|h": 4, "i\\j": 5, 'k"l': 6, " ": 7, "m~n": 8}
def refused(pointer):
try:
pointer_get(DOC, pointer)
except ValueError:
return True
return False
def test_rfc_6901_section_5():
"""gives the value of every pointer of the RFC's example"""
assert pointer_get(DOC, "") == DOC
assert pointer_get(DOC, "/foo") == ["bar", "baz"]
assert pointer_get(DOC, "/foo/0") == "bar"
expected = {"/": 0, "/a~1b": 1, "/c%d": 2, "/e^f": 3, "/g|h": 4, "/i\\j": 5, '/k"l': 6, "/ ": 7, "/m~0n": 8}
for pointer, value in expected.items():
assert pointer_get(DOC, pointer) == value, pointer
def test_pointers_that_name_nothing():
"""raises ValueError for pointers that name no value"""
for pointer in ["foo", "/foo/2", "/foo/01", "/foo/-", "/foo/x", "/bar", "/foo/0/x", "/~2", "/m~n"]:
assert refused(pointer), pointer
def test_decoding_order():
"""decodes ~1 before ~0, so ~01 is the name ~1"""
assert pointer_get({"~1": "tilde-one", "/": "slash"}, "/~01") == "tilde-one"
CASES = [
('{"a":"b"}', '{"a":"c"}', '{"a":"c"}'),
('{"a":"b"}', '{"b":"c"}', '{"a":"b","b":"c"}'),
('{"a":"b"}', '{"a":null}', "{}"),
('{"a":"b","b":"c"}', '{"a":null}', '{"b":"c"}'),
('{"a":["b"]}', '{"a":"c"}', '{"a":"c"}'),
('{"a":"c"}', '{"a":["b"]}', '{"a":["b"]}'),
('{"a":{"b":"c"}}', '{"a":{"b":"d","c":null}}', '{"a":{"b":"d"}}'),
('{"a":[{"b":"c"}]}', '{"a":[1]}', '{"a":[1]}'),
('["a","b"]', '["c","d"]', '["c","d"]'),
('{"a":"b"}', '["c"]', '["c"]'),
('{"a":"foo"}', "null", "null"),
('{"a":"foo"}', '"bar"', '"bar"'),
('{"e":null}', '{"a":1}', '{"e":null,"a":1}'),
("[1,2]", '{"a":"b","c":null}', '{"a":"b"}'),
("{}", '{"a":{"bb":{"ccc":null}}}', '{"a":{"bb":{}}}'),
]
def test_rfc_7396_appendix_a():
"""gives the RFC's result for all fifteen cases"""
for original, patch, result in CASES:
assert merge_patch(json.loads(original), json.loads(patch)) == json.loads(result), (original, patch)
def test_arguments_unchanged():
"""changes neither the target nor the patch"""
target = {"a": {"b": "c", "d": "e"}, "list": [1]}
patch = {"a": {"b": None}, "list": [2]}
assert merge_patch(target, patch) == {"a": {"d": "e"}, "list": [2]}
assert target == {"a": {"b": "c", "d": "e"}, "list": [1]}
assert patch == {"a": {"b": None}, "list": [2]} JavaScript · Starter code · rfc.mjs
/** The value that the RFC 6901 JSON Pointer names in doc; throws an Error when it names none. */
export function pointerGet(doc, pointer) {
return doc;
}
/** The RFC 7396 merge of patch into target; neither argument changes. */
export function mergePatch(target, patch) {
return target;
} The sample tests · rfc.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { mergePatch, pointerGet } from './rfc.mjs';
// RFC 6901, section 5.
const DOC = { foo: ['bar', 'baz'], '': 0, 'a/b': 1, 'c%d': 2, 'e^f': 3, 'g|h': 4, 'i\\j': 5, 'k"l': 6, ' ': 7, 'm~n': 8 };
const refused = (pointer) => {
try {
pointerGet(DOC, pointer);
} catch {
return true;
}
return false;
};
test('gives the value of every pointer of the RFC\'s example', () => {
assert.equal(pointerGet(DOC, ''), DOC);
assert.deepEqual(pointerGet(DOC, '/foo'), ['bar', 'baz']);
assert.equal(pointerGet(DOC, '/foo/0'), 'bar');
const expected = { '/': 0, '/a~1b': 1, '/c%d': 2, '/e^f': 3, '/g|h': 4, '/i\\j': 5, '/k"l': 6, '/ ': 7, '/m~0n': 8 };
for (const [pointer, value] of Object.entries(expected)) assert.equal(pointerGet(DOC, pointer), value, pointer);
});
test('throws for pointers that name no value', () => {
for (const pointer of ['foo', '/foo/2', '/foo/01', '/foo/-', '/foo/x', '/bar', '/foo/0/x', '/~2', '/m~n', '/constructor']) {
assert.ok(refused(pointer), pointer);
}
});
test('decodes ~1 before ~0, so ~01 is the name ~1', () => {
assert.equal(pointerGet({ '~1': 'tilde-one', '/': 'slash' }, '/~01'), 'tilde-one');
});
const CASES = [
['{"a":"b"}', '{"a":"c"}', '{"a":"c"}'],
['{"a":"b"}', '{"b":"c"}', '{"a":"b","b":"c"}'],
['{"a":"b"}', '{"a":null}', '{}'],
['{"a":"b","b":"c"}', '{"a":null}', '{"b":"c"}'],
['{"a":["b"]}', '{"a":"c"}', '{"a":"c"}'],
['{"a":"c"}', '{"a":["b"]}', '{"a":["b"]}'],
['{"a":{"b":"c"}}', '{"a":{"b":"d","c":null}}', '{"a":{"b":"d"}}'],
['{"a":[{"b":"c"}]}', '{"a":[1]}', '{"a":[1]}'],
['["a","b"]', '["c","d"]', '["c","d"]'],
['{"a":"b"}', '["c"]', '["c"]'],
['{"a":"foo"}', 'null', 'null'],
['{"a":"foo"}', '"bar"', '"bar"'],
['{"e":null}', '{"a":1}', '{"e":null,"a":1}'],
['[1,2]', '{"a":"b","c":null}', '{"a":"b"}'],
['{}', '{"a":{"bb":{"ccc":null}}}', '{"a":{"bb":{}}}'],
];
test('gives the RFC\'s result for all fifteen cases', () => {
for (const [original, patch, result] of CASES) {
assert.deepEqual(mergePatch(JSON.parse(original), JSON.parse(patch)), JSON.parse(result), `${original} + ${patch}`);
}
});
test('changes neither the target nor the patch', () => {
const target = { a: { b: 'c', d: 'e' }, list: [1] };
const patch = { a: { b: null }, list: [2] };
assert.deepEqual(mergePatch(target, patch), { a: { d: 'e' }, list: [2] });
assert.deepEqual(target, { a: { b: 'c', d: 'e' }, list: [1] });
assert.deepEqual(patch, { a: { b: null }, list: [2] });
}); A hint
For the pointer, split pointer[1:] at /, decode each token with two replace calls in the right order, and walk the document one token at a time, testing whether the value is an object or an array at each step (in Python, test isinstance(v, dict) and isinstance(v, list)). For the merge patch, copy the target before changing it, and call the function again for each member whose value is not null.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on pointers, patches, tests and merges. The self-check asks first about whatever you have missed most, and a wrong pick tells you exactly what it overlooked:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what is the JSON Pointer to the member “invoices/BK-10042.pdf” inside “attachments”, and why?
It is /attachments/invoices~1BK-10042.pdf. A JSON Pointer is a sequence of reference tokens, each written after a
/, so a / inside a member name would be read as the start of a new token; RFC 6901 therefore writes it as ~1, and
a ~ in a name as ~0. A reader decodes ~1 before ~0, so ~01 stays the name ~1. A program that builds pointers
escapes in the other order, ~ first and then /, and inside a URI fragment the pointer is also percent-encoded.
Two harder questions follow, about a mobile app whose profile updates lose data and about two agents whose edits overwrite each other. Decide what you would check first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A mobile app updates user profiles with JSON Merge Patch. Editing one saved address deletes the user's other addresses, and the API cannot record "no alternate phone" as null, which a downstream export needs. Explain both bugs and fix them without breaking app versions already installed.
-
Mid to senior
Two support agents change the quantities in the same order at the same time, and one agent's change disappears without an error. How would you use JSON Patch to stop this, what should the API answer the second agent, and how would you test it?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does a JSON Pointer write a / or a ~ that is part of a member name?
A / as ~1 and a ~ as ~0. Readers decode ~1 before ~0, so ~01 means the name ~1. The empty pointer names the whole document.
-
What is the pointer to the member "invoices/BK-10042.pdf" inside "attachments"?
/attachments/invoices~1BK-10042.pdf: the slash in the name becomes ~1 so it is not read as a new step.
-
What are the six JSON Patch operations?
add, remove, replace, move, copy and test. They run in order, each on the result of the one before.
-
What happens when one operation of a JSON Patch fails?
The whole patch fails. Apply it to a copy, and keep the copy only when every operation has succeeded.
-
How does a test operation prevent a lost update?
Put a test for each value the change depends on in front of the change. If another writer has changed one of them, the test fails and the patch is refused as a whole.
-
What does null mean in a JSON Merge Patch?
Delete the member. So a merge patch can never set a value to null, and an array in it replaces the whole old array.
-
Merge Patch, JSON Patch or a whole document: which for a partial update?
Merge Patch for simple object changes; JSON Patch with test for arrays, nulls and edits that may collide; a whole document only when it is small and changes as a whole.
Key takeaways
- A JSON Pointer takes one step per reference token, a member name in an object or an index without leading zeros in an
array;
~1is/and~0is~, decoded in that order, and the empty pointer names the whole document. - A JSON Patch is a list of six kinds of operation applied in order, each on the result of the one before, and if one fails the whole patch fails, so apply it to a copy and keep the copy only when every operation succeeded.
testcompares by JSON type and value, objects in any member order and arrays in order, and putting it before a change refuses a patch built on a stale reading instead of losing another writer’s update.- A Merge Patch is an object of changed members in which
nulldeletes and arrays are replaced whole, so it cannot storenullor change one array element. - Choose Merge Patch for simple object updates, JSON Patch with
testfor arrays, nulls and concurrent edits, and a whole document only when it is small and changes as a whole.
Terms in this lesson
- JSON Patch
- The format of RFC 6902, media type application/json-patch+json: a JSON array of operations (add, remove, replace, move, copy and test) at JSON Pointers, applied in order and as a whole or not at all.
- lost update
- The silent loss of one writer's change when another writer, who read the same earlier version, saves a change on top of it. A test in a JSON Patch, or If-Match in HTTP, refuses the second write instead.
- Merge Patch
- The format of RFC 7396, media type application/merge-patch+json: an object of changed members merged into a document, where null deletes a member and arrays are replaced whole.
- reference token
- One step of a JSON Pointer, the text between two slashes: a member name in an object or a decimal index in an array. Inside a token, ~0 stands for ~ and ~1 for /.
References
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress