Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 01 and +1 name nothing, and - names the place after the last element, which holds no value to read.
  • Inside a token, ~ is written ~0 and / is written ~1, so a member named a/b is /a~1b.
  • Decoding replaces ~1 first and ~0 second. 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:

Interactive · stage by stage Following a JSON Pointer through the order, token by token Step through /items/1/qty, then two pointers that fail at their second token and one whose name holds a slash.
Following a JSON Pointer through the order, token by token, step 8 of 8. Showing The whole document (pointer /attachments/…), Take token 1 (attachments: an object), Take token 2 (invoices/BK-10042.pdf), Take token 3 (pages: a number), The value it names (2). In focus: Take token 2.The whole documentpointer /attachments/…Take token 1attachments: an objectTake token 2invoices/BK-10042.pdfTake token 3pages: a numberThe value it names2

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. A member. The order is an object, so the first token is a member name: items, an array of two order lines.
  3. 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.
  4. A member again. That line is an object, so qty is a member name again.
  5. The value. Three tokens, three steps: /items/1/qty names the number 1, which both programs printed.
  6. 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.
  7. 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.
  8. 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:

Eleven pointers into one order, and the decoding order

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

/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

  1. Question 1 of 2 Which JSON Pointer names the top-level member whose name is m~n/x?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: /m~0n~1x

    Inside a token, ~ is written ~0 and / is written ~1. A program that builds pointers escapes ~ first, then /, which is how pointer_of wrote invoices~1BK-10042.pdf.

  2. Question 2 of 2 The order has two items. Which pointer names one of them?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /items/0

    Array tokens are decimal numbers with no sign and no leading zero, from 0 to the length minus 1. The - token is for add, which can append there.

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:

JSON Patch, the six operations on a copy of the document

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:

A patch that applies, one that fails at its last operation, and four tests

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

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:

Interactive · message by message A patch of three operations whose last one fails Step through the shipping patch. Every operation runs on a copy, so the failure in operation 2 leaves the stored order as it was.
A patch of three operations whose last one fails, step 7 of 7. Messages so far: Client to Order service: PATCH: 3 operations; Stored order to Order service: read the order; Order service to Client: refused: operation 2.Clientsends the patchOrder servicecopy droppedStored orderstatus paid, qty 2PATCH: 3 operationsread the orderrefused: operation 2

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The patch. The client sends three operations: replace /status with shipped, add /tracking, and test that /items/0/qty is 5.
  2. A copy. The service reads the stored order and works on a copy of it, never on the order itself.
  3. Operation 0. Operation 0 replaces the status on the copy: it now says shipped.
  4. Operation 1. Operation 1 adds tracking AWB-7781 to the copy. Two of the three operations have run.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 In the lesson's first patch, the last operation moves /items/2 to /items/0. Why does /items/2 exist, when the order had only two items?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The add before it had appended BK-310, which became element 2

    Operations run in order on the result of the previous one. Reordering a patch can change what its pointers name, or make it fail.

  2. Question 2 of 2 A patch of three operations fails at the third, after the first two succeeded. What does the store hold afterwards?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The document as it was before the patch

    RFC 6902 says a patch with a failed operation is not applied, and RFC 5789 requires HTTP PATCH to be atomic. Working on a copy makes both true.

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:

Two agents, with and without a test of what they saw

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

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:

Interactive · message by message Two agents change the same quantity, each testing what it saw Step through two patches built on the same reading of the order. The test in each one decides which may write.
Two agents change the same quantity, each testing what it saw, step 7 of 7. Messages so far: Order service to Agent A: qty 2; Agent A to Order service: test 2, replace 3; Order service to Agent A: 200 OK; Agent B to Order service: test 2, replace 1; Order service to Agent B: 409: the value is 3; Agent B to Order service: test 3, replace 2.Agent Acustomer added a copyAgent Bcustomer returned oneOrder serviceBK-101: qty 2qty 2test 2, replace 3200 OKtest 2, replace 1409: the value is 3test 3, replace 2

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Both read 2. Both agents open order BK-10042 and see 2 copies of BK-101. Each plans a change from that reading.
  2. A sends a patch. Agent A sends two operations: test that /items/0/qty is 2, then replace it with 3.
  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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 The value at /items/0/qty is the number 2. Which test passes?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: {"op": "test", "path": "/items/0/qty", "value": 2.0}

    test needs the same JSON type and an equal value: numbers by value, strings character for character, arrays in order and objects whatever their member order.

  2. Question 2 of 2 Agent B sends [test /items/0/qty is 2, replace it with 1], but agent A has already changed it to 3. What should the service do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Refuse the whole patch with 409 Conflict and keep 3

    The test made B's change conditional on the value B saw. When it fails, the service refuses, and B reads the order again before it decides.

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:

Interactive · step by step A merge patch, member by member Step through three patches against the stored profile. The left column is the profile, the right one what each patch sends for the same member.
A merge patch, member by member, step 6 of 6. Showing theme: "dark" (now "light"), theme: "light", nickname: "Ash" (deleted), nickname: null, emails: 2 addresses (now 1 address), emails: 1 address, name: "Asha".merged indeletesreplaces wholetheme: "dark"now "light"theme: "light"nickname: "Ash"deletednickname: nullemails: 2 addressesnow 1 addressemails: 1 addressname: "Asha"

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The stored profile. The stored profile has four members: name, nickname, emails with two addresses, and theme, set to dark.
  2. 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.
  3. Every other member. Members the patch does not name are left alone: name, nickname and both emails are as they were.
  4. 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.
  5. 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.
  6. 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:

RFC 7396's own test cases, then three patches to a profile

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

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

  1. Question 1 of 2 What does the merge patch {"nickname": null} do to a profile that has a nickname?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It deletes the nickname member

    RFC 7396 gives null the special meaning of removing a member. That is why the format suits documents that do not use explicit null values.

  2. Question 2 of 2 A profile has "emails": ["a@example.com", "b@example.com"]. A merge patch sends {"emails": ["c@example.com"]}. What is stored?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: "emails": ["c@example.com"]

    Merge Patch merges objects member by member and replaces everything else whole. To change one element, send the whole array, or use JSON Patch.

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:

One change written as JSON Patch and as Merge Patch

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

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:

Interactive · answer and compare Which format should a partial update use? Answer three questions about your updates. Formats that cannot express them are ruled out, with the reason.
Must a client be able to set a member to null?
Do updates change single elements of arrays, such as one order line?
Can two clients change the same document at the same time?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Merge Patch, JSON Patch and a whole new document against what partial updates need
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.

JSON Diff & Compare Compare two JSON documents, export the difference as JSON Patch or Merge Patch, and apply a patch.

Checkpoint

Check: Choosing a patch format

  1. One question Several agents edit the lines of an order at the same time, and sometimes clear the delivery note by setting it to null. Which format should the API take?

    Choose one answer.

    Show the answer: One question

    Answer: JSON Patch, with a test of every value a change depends on

    Each of the three needs, single array elements, a stored null and concurrent writers, rules Merge Patch out on its own, as the Tradeoffs figure shows.

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 in doc. The empty pointer names the whole document. Each token is decoded ~1 to / first, then ~0 to ~. 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, raise ValueError in Python and throw an Error in JavaScript: a pointer that does not start with /, a ~ not followed by 0 or 1, a missing member, a bad or too large index, -, or a token below a string, a number, true, false or null.
  • 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, where null deletes 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.

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.

  1. Question 1 of 13 What does the empty JSON Pointer, "", name?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The whole document

    "" is the whole document, and "/" is the member named "". The program printed the whole order for "".

  2. Question 2 of 13 Which member name does the token ~01 stand for?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: ~1

    RFC 6901 fixes the order, ~1 first and ~0 second, exactly so that an escaped ~ followed by 1 is never read as /.

  3. Question 3 of 13 A program builds a pointer to the member "~draft" inside "attachments". What should it produce?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: /attachments/~0draft

    Building a pointer escapes ~ as ~0 first and / as ~1 second, the reverse of the decoding order.

  4. Question 4 of 13 Which operation appends a new line to the end of the items array?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: {"op": "add", "path": "/items/-", "value": {...}}

    The first patch of the program appended BK-310 with add and /items/-. In an array, add with an index inserts there and moves the later elements along.

  5. Question 5 of 13 What happens to the operation {"op": "move", "from": "/items", "path": "/items/0"}?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: It fails, because the path is inside the value being moved

    The patcher checks whether path starts with every token of from and is longer, and refuses with "a value cannot move into itself".

  6. Question 6 of 13 An order has no tracking member. What does {"op": "replace", "path": "/tracking", "value": "AWB-7781"} do?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: It fails, because replace needs an existing value; add would create it

    add creates or replaces a member, replace only replaces. The shipping patch used add for /tracking for that reason.

  7. Question 7 of 13 The second order line is {"sku": "BK-205", "qty": 1}. Does a test with the value {"qty": 1, "sku": "BK-205"} pass?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Yes, objects are equal when their members are, whatever their order

    Objects are unordered in JSON, so test compares them member by member. Arrays keep their order, which is why the test of the tags in reverse order failed.

  8. Question 8 of 13 Your API already checks If-Match against the order's ETag. Why might a client still put a test into its JSON Patch?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: test checks only the values the change depends on, so an edit elsewhere in the order does not refuse it

    Both prevent lost updates. If-Match works on the version of the whole resource, test on chosen values, so the two can be combined or used apart.

  9. Question 9 of 13 What is the result of the merge patch "bar" applied to {"a": "foo"}?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: "bar"

    The first rule of the algorithm: if the patch is not an object, the result is the patch. All fifteen cases of Appendix A gave the RFC's result in the program.

  10. Question 10 of 13 What is {"a": {"b": "c"}} after the merge patch {"a": {"b": "d", "c": null}}?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: {"a": {"b": "d"}}

    The merge is recursive: a member whose value is an object merges into the target's member of that name. This is case 7 of RFC 7396's Appendix A.

  11. Question 11 of 13 Which media type labels a JSON Merge Patch?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: application/merge-patch+json

    The media type tells a server how to read the body of a PATCH request; a server lists the types it takes in Accept-Patch.

  12. Question 12 of 13 A settings screen changes a user's theme and language, one user at a time, and never stores null. Which format fits best?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Merge Patch

    The Tradeoffs figure points at ease of reading and writing when there are no nulls and no single elements to change, and there Merge Patch wins.

  13. Question 13 of 13 In the diff program, the Merge Patch was 114 bytes and the JSON Patch 198. Why did only the JSON Patch give version 2?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Version 2 sets the note to null, which a merge patch can only express as a deletion

    A shorter patch is not a better one if it cannot say what changed. The JSON Patch replaced /note with null and touched one quantity; the merge patch deleted the note and resent both order lines.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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; ~1 is / and ~0 is ~, 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.
  • test compares 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 null deletes and arrays are replaced whole, so it cannot store null or change one array element.
  • Choose Merge Patch for simple object updates, JSON Patch with test for 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

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.