Pointer, Path, Patch

Working with JSON

Chapter 8 ยท Addressing and Changing JSON

Four small standards cover most of what you need to point at a part of a JSON document and to change it, and they get confused constantly. JSON Pointer names exactly one location. JSONPath searches for many. JSON Patch is a precise list of edits. JSON Merge Patch is a much simpler "here is what the document should look like now" edit. You have already met the first one without knowing it: the instancePath in a validator's errors (Chapter 6) and the $ref paths are JSON Pointers. This chapter explains each one, applies each with real libraries in Node and Python and with jq (Chapters 4 and 5), measures where the libraries disagree with the standards, and ends with how to choose.

Every example here was run, except where marked
Node 24.18.0 with json-p3 3.0.1, fast-json-patch 3.1.1, jsonpath-plus 11.2.0 and json-merge-patch 1.0.2; Python 3.14.0 with jsonpointer 3.2.1, jsonpatch 1.35, python-jsonpath 2.2.1, jsonpath-ng 1.10.1 and json-merge-patch 0.3.0; jq 1.8.2. All installed in a scratch folder. The RFC statements were checked against the RFC texts on rfc-editor.org. Not run: other libraries, the official JSONPath compliance test suite (I used 21 queries of my own, below), regular-expression behaviour beyond two simple patterns, and the URI-fragment form of a pointer (taken from RFC 6901).

The Four Standards at a Glance

StandardWhat it isPublishedLooks like
JSON Pointer, RFC 6901names one locationApril 2013/store/book/0/title
JSONPath, RFC 9535a query that finds zero or more valuesFebruary 2024$.store.book[?@.price<10].title
JSON Patch, RFC 6902a list of precise edit operationsApril 2013[{"op":"replace","path":"/a","value":2}]
JSON Merge Patch, RFC 7396a partial document that is merged into the originalOctober 2014{"a":2,"b":null}

JSONPath existed for years as a loose convention with many incompatible versions; RFC 9535 (February 2024) finally standardised it, which matters for the library comparison below. Patch media types are application/json-patch+json and application/merge-patch+json, used with the HTTP PATCH method.

JSON Pointer (RFC 6901)

A pointer is a string of reference tokens, each starting with /: /foo/0 means "the member foo, then its element 0". Two characters need escaping inside a token because they have a meaning: ~ is written ~0 and / is written ~1. When decoding, replace ~1 first and then ~0, otherwise ~01 would be misread as /. Three special cases:

  • The empty string "" points to the whole document, but "/" points to the member whose name is the empty string "". They are different.
  • Array indexes have no leading zeros: /foo/0 is valid, /foo/01 is not.
  • The token - means "the element after the last one". It never points at an existing value, so reading it is an error; it is useful only for adding (JSON Patch below).

RFC 6901 (section 5) gives a test document with awkward keys; I checked my copy and the expected values against the RFC text. I wrote it to a file and resolved eighteen pointers (the twelve from the RFC plus six edge cases) with four implementations, one of them a jq function I wrote:

{ "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 }
Pointer (as a JSON string)Result
""the whole document
"/foo", "/foo/0", "/foo/1"["bar","baz"], "bar", "baz"
"/"0 (the empty-string key)
"/a~1b"1 (the key a/b)
"/m~0n"8 (the key m~n)
"/c%d", "/e^f", "/g|h", "/ "2, 3, 4, 7 (these characters need no escaping in a pointer)
"/i\\j", "/k\"l"5, 6 (backslash and quote need escaping only in the JSON string, not in the pointer)
"/foo/-", "/foo/2", "/nope", "/foo/bar"no value
"/foo/01"no value (leading zero)

json-p3, Python's jsonpointer and my jq function matched the RFC on all 18 pointers. fast-json-patch matched on 17 and returned "baz" for /foo/01, accepting a leading zero that the RFC forbids. In the other "no value" cases the libraries differ in how they say so: json-p3 throws a JSONPointer...Error, fast-json-patch returns undefined, Python's jsonpointer raises JsonPointerException (and for /foo/- returns a special EndOfList marker object, not an error). Check for "no value" the way your library reports it.

In jq a pointer is a path, so getpath does the work once the string is converted. My function splits on /, unescapes ~1 then ~0, and treats a digit-only token as an index only when the value there is an array (so the key "0" in an object still works): "/foo/0" gave "bar", and on {"0":"zero-key","list":["a","b"]} the pointers /0 and /list/0 gave "zero-key" and "a". jq itself has no pointer syntax; the reverse conversion (paths | map(tostring) | join("/")) is not safe if keys contain / or ~.

Escaping is the usual source of bugs: /a/b means two levels; /a~1b means the single key a/b. A pointer can also be written in a URI fragment (#/c%25d for the pointer /c%d, percent-encoded as RFC 6901 says; I did not run that form).

JSONPath (RFC 9535)

A JSONPath query starts at $ (the root) and chains segments. The result is a nodelist: zero or more values, each with a known location (RFC 9535 calls it a Normalized Path, written like $['store']['book'][0]). An empty result is valid, not an error.

SyntaxMeaning
$.name, $['name']child member (dot or bracket form)
$.*, $[*]every child
$..name, $..*descendant segment: at any depth
[3], [-1], [0,1]array index (negative counts from the end), or several
[1:4:2], [::-1]slice start:end:step, end excluded; a negative step reverses
[?@.price < 10]filter: @ is the current item; the parentheses of older syntax (?(...)) are optional
length(), count(), match(), search(), value()the five standard function extensions (match must match the whole string, search any part)

I ran 21 queries on the bookstore document that RFC 9535 and older JSONPath articles use (four books and a bicycle; store.json), with the expected answer for each written by hand beforehand, in four libraries:

LibraryQueries with the expected answerWhere it differed
json-p3 3.0.1 (Node, an RFC 9535 implementation)21 of 21-
python-jsonpath 2.2.1 (Python, an RFC 9535 implementation)21 of 21-; and it gave identical answers to json-p3 on every query
jsonpath-plus 11.2.0 (Node, long-established)13 of 21filters without parentheses (?@.isbn, ?@.price<10) returned nothing; && filters, length, match and search returned nothing; the negative step [::-1] returned nothing; comparing two missing values returned nothing
jsonpath-ng 1.10.1, ext parser (Python, older)15 of 21$..* gave 23 values instead of 27; &&, the functions and the missing-value comparison were parse errors

The older libraries are not "wrong" in their own terms: they predate the RFC and each has its own dialect. But jsonpath-plus returning an empty result for a filter it does not understand, with no error, is the dangerous kind of difference: the query looks like it ran and found nothing. The same query text can therefore return different data in different libraries. Pick an RFC 9535 implementation when queries cross a language boundary or are stored, and keep a few known-answer queries as a test. Two details of the RFC the compliant libraries implemented: a comparison of two queries that both select nothing is true ($.store.book[?@.nope == $.store.book[0].nope].price returned all four prices), and JSON null is an ordinary value, not "missing". match() and search() take I-Regexp patterns (RFC 9485), a deliberately small regular-expression dialect; I tested only "J.*" and "Moby".

jq has no JSONPath, but it can do the same work with filters. These jq programs gave the same answers as their JSONPath twins on store.json:

JSONPathjq
$..author[.. | .author? // empty]
$.store.book[?@.price<10].title[.store.book[] | select(.price < 10) | .title]
$..book[?@.isbn][.. | objects | select(has("isbn")) | .title] (titles)
$.store.book[1:4:2].title[.store.book | to_entries[] | select(.key % 2 == 1) | .value.title] (jq slices have no step)
$.store.book[?length(@.title) > 15].title[.store.book[] | select(.title | length > 15) | .title]
the location of a match (a Normalized Path)[path(.. | select(. == "Moby Dick"))] gave [["store","book",2,"title"]]

JSON Patch (RFC 6902)

A patch is an array of operations applied in order. Each has an op and a path (a JSON Pointer):

opNeedsWhat it does
addpath, valueadds an object member, replaces it if it exists, or inserts into an array at an index (/- appends); the parent must already exist
removepathdeletes; the target must exist
replacepath, valuelike remove-then-add; the target must exist
movefrom, pathremove from one place, add at another; cannot move a value into its own child
copyfrom, pathadd a copy of an existing value
testpath, valuefails the whole patch unless the value at path equals value (numbers compare by value; objects ignore member order)

RFC 6902 says a patch is applied as a whole: if any operation fails, the patch as a whole is not successful. I took the examples from the RFC's appendix, A.1 to A.16 except A.13 (a duplicate-op document that an ordinary parser cannot even represent), which I checked against the RFC text (add, remove, replace, move, test, the ~ escape, appending an array as a single value, unrecognised members ignored, and the error cases), added nine of my own (copy, add past the end of an array, remove or replace a missing member, add over an existing member, move into its own child, test 1 against 1.0, an unknown op, replacing the whole document with an empty path) and ran all 24 in three libraries:

LibraryCases matching the RFC
jsonpatch 1.35 (Python)24 of 24
json-p3 3.0.1 (Node)24 of 24
fast-json-patch 3.1.1 with operation validation turned on (applyPatch(doc, patch, true))24 of 24
fast-json-patch 3.1.1 with its default applyPatch(doc, patch)21 of 24: it silently succeeded on "add past the end of an array" (["a"] became ["a","x"]), "remove a missing member" and "replace a missing member" (it created the member)

The three libraries agreed with each other on every case when configured correctly. The lesson for fast-json-patch: pass true as the third argument (validate operations), or a patch that should have been rejected will go through.

Atomicity is your job. I applied a three-operation patch whose third operation fails (remove /nope) to {"a":1}:

Library and modeOriginal document afterwards
fast-json-patch, default (mutates the document){"a":99,"b":2} — the first two operations had already been applied
fast-json-patch with mutateDocument=false (applyPatch(doc, patch, true, false)){"a":1} (untouched)
json-p3{"a":99,"b":2} (partly applied)
jsonpatch (Python), default in_place=False{"a": 1} (untouched)
jsonpatch with in_place=True{"a": 99, "b": 2} (partly applied)

So a patch that fails halfway can leave a half-edited document unless you apply it to a copy and keep the copy only on success. The test operation is also how you do optimistic concurrency ("apply this only if /version is still 7"); in the HTTP example below a leading test on /name let the rest of the patch through.

Patches can also be generated by comparing two documents. Python's jsonpatch.make_patch turned {"a":1,"b":[1,2,3],"c":{"d":4}} and {"a":2,"b":[1,3],"c":{"d":4,"e":5}} into [{"op":"replace","path":"/a","value":2}, {"op":"remove","path":"/b/1"}, {"op":"add","path":"/c/e","value":5}], and applying that patch to the first document reproduced the second. jq has no built-in JSON Patch; you express edits directly (.a = 2 | del(.b[1]) | .c.e = 5), and Chapter 5's setpath and delpaths are the primitives.

JSON Merge Patch (RFC 7396)

A merge patch is just a JSON document shaped like the part of the target you want to change. The rules are short: if the patch is not an object, it replaces the target entirely; if it is an object, each member is merged in; a member whose value is null deletes that member; an array in the patch replaces the whole array. I ran the RFC's Appendix A table of 15 examples (checked against the RFC text) plus three of my own (18 cases) in the Node library, the Python library and a jq function I wrote from the RFC's pseudo-code: all three gave the RFC's result on all 18. Some of them:

TargetPatchResult
{"a":"b","c":{"d":"e","f":"g"}}{"a":"z","c":{"f":null}}{"a":"z","c":{"d":"e"}}
{"a":"b"}{"a":null}{}
{"a":["b"]}{"a":"c"}{"a":"c"}
{"tags":["x","y"]}{"tags":["x","y","z"]}{"tags":["x","y","z"]} (the whole array was replaced)
{"a":"b"}["c"]["c"] (not an object: replaces the target)
{"e":null}{"a":1}{"e":null,"a":1} (an existing null is left alone)
{}{"a":{"bb":{"ccc":null}}}{"a":{"bb":{}}}

Merge patch is pleasant for "update these fields", but it has built-in limits: you cannot set a value to null (that deletes it), you cannot change one element of an array (you must send the whole array), and a patch generated from two documents loses a null: generate({a:1}, {a:1, f:null}) produced {"f":null}, and applying it to {"a":1} gave {"a":1}, not the intended {"a":1,"f":null}. Do not use merge patch where null is meaningful data. Both libraries also changed their input object in place unless I copied it first (apply({a:1}, {a:2}) left the target as {"a":2}).

jq's * operator is not merge patch. I ran four expressions: {"a":"b"} * {"a":null} gave {"a":null} (RFC 7396 would delete a), the nested example gave {"a":"z","c":{"d":"e","f":null}} (the RFC's answer deletes f), {"a":["b"]} * {"a":"c"} gave {"a":"c"} (the same), and + merged only the top level ({"a":{"x":1}} + {"a":{"y":2}} gave {"a":{"y":2}}). So * is a recursive merge that keeps nulls, close to merge patch but not equal. If you need real merge-patch semantics in jq, write the pseudo-code as a function (mine passed all 18 cases).

Patching Over HTTP

The two patch formats are carried by the HTTP PATCH method, with the media type telling the server which one you sent. I wrote a small Node server (made for this chapter, so the status codes are my choices, not a standard) that accepts both and applies the patch to a copy, and called it with curl:

curl -s -X PATCH -H 'Content-Type: application/merge-patch+json' -d '{"email":null,"address":{"zip":"N2"},"tags":["a","b","c"]}' http://127.0.0.1:8766 curl -s -X PATCH -H 'Content-Type: application/json-patch+json' -d '[{"op":"test","path":"/name","value":"Ada"},{"op":"replace","path":"/address/city","value":"Leeds"},{"op":"add","path":"/tags/-","value":"d"}]' http://127.0.0.1:8766 curl -s -X PATCH -H 'Content-Type: application/json-patch+json' -d '[{"op":"replace","path":"/address/city","value":"Hull"},{"op":"remove","path":"/nope"}]' http://127.0.0.1:8766
start: {"name":"Ada","email":"ada@example.com","tags":["a","b"],"address":{"city":"London","zip":"N1"}} merge patch -> 200 {"name":"Ada","tags":["a","b","c"],"address":{"city":"London","zip":"N2"}} (email deleted, zip changed, city kept, array replaced) json patch -> 200 {"name":"Ada","tags":["a","b","c","d"],"address":{"city":"Leeds","zip":"N2"}} (test passed, city replaced, "d" appended) failing patch-> 409 {"error":"patch could not be applied","detail":"OPERATION_PATH_UNRESOLVABLE"} state after -> {"name":"Ada","tags":["a","b","c","d"],"address":{"city":"Leeds","zip":"N2"}} (city is still Leeds: the first operation was not kept) JSON Patch body that is an object, not an array -> 422 Content-Type: application/json -> 415 with the list of accepted types merge patch body that is not JSON -> 400

The failing patch left the stored document unchanged because the server applied it to a copy and replaced the stored document only on success: exactly the atomicity from the previous section. Note that the merge patch changed one field and removed another in one small request, while the JSON Patch needed an operation per edit but could append to the array and could check a precondition first.

Choosing

You want to...Use
name one exact location (in an error, a $ref, a patch)JSON Pointer
search inside a document in your own code or in a stored/shared query, and the query may be written by someone elseJSONPath, with an RFC 9535 library; test it on known answers
explore a file or an API response from the terminal, or reshape it, or script a pipelinejq (Chapters 4 and 5): far more powerful than JSONPath, but a program, not a standard query syntax
send a precise, ordered, checkable list of edits (append to an array, edit one element, precondition with test, move or copy)JSON Patch
send "set these fields, delete those" for plain object data with no meaningful null and no partial array editsJSON Merge Patch
say what shape is allowedJSON Schema (Chapters 6 and 7), not any of these
Mistakes while preparing this chapter
1. My first pointer test gave a TypeError for every pointer in json-p3. I had called the constructor with a string, but it expects already-split tokens; the function that takes a pointer string is resolve(pointer, document). How to avoid it: when a library throws on every input, read its API and print one working call before looping.

2. My first JSONPath table printed undefined and {} for json-p3. Its result is a list of nodes (.value, .location), not a list of values. How to avoid it: look at the shape of one result before comparing many.

3. My jq pointer function treated "/" as the whole document. In jq, "" | split("/") is an empty list, so the one-empty-token case vanished; the RFC says "/" is the member named "". The 18-pointer table caught it. How to avoid it: test a hand-written parser on the RFC's own awkward examples, not just on typical input.

4. I assumed Python's jsonpointer would raise for /foo/-. It returns an EndOfList marker, and my json.dumps of the result is what crashed. How to avoid it: print the type of a result, not only its text.

5. My first fast-json-patch run looked perfect (24 of 24). I had called its validate function first, which hid that the default applyPatch accepts three invalid patches. Running the default on its own showed it. How to avoid it: test a library the way a typical caller uses it, as well as the way its documentation recommends.

Hands-On Exercises

Exercise 1

Write the RFC 6901 test document to a file (build the backslash and quote keys without typing escapes by hand). Resolve eighteen pointers (the twelve from the RFC and six edge cases: /foo/-, /foo/01, /foo/2, /nope, /foo/bar and one more of your choice) with at least three real libraries and a jq function you write yourself. Put the RFC's expected result next to each, print which library differs where, and make sure your jq function treats "" and "/" differently.

๐Ÿ“„ View solution
Exercise 2

Using the bookstore document, write 21 JSONPath queries (child, descendant, wildcard, index and negative index, union, slice with step and negative step, filters with and without parentheses, &&, length(), match(), search() and a comparison of two missing values), with the expected answer for each written by hand first. Run them in at least two RFC 9535 libraries and two older ones, score each library, list every query where a library differs, and check that the two RFC libraries agree with each other. Then write jq equivalents for six of them and check their output.

๐Ÿ“„ View solution
Exercise 3

Run the RFC 6902 appendix examples plus extra cases of your own (24 in all) through fast-json-patch (default and with validation), json-p3 and Python's jsonpatch, and report which cases each gets wrong. Show what a failing three-operation patch leaves behind in each library's default mode. Run the RFC 7396 examples (18 cases) in a Node library, a Python library and a jq function you write, and show that a generated merge patch loses a null. Finally write a small HTTP server that accepts both patch media types, applies them to a copy, and call it with curl for a success, a failing patch, a wrong body shape, a wrong media type and bad JSON.

๐Ÿ“„ View solution

Chapter 8 Quick Reference

  • Pointer (RFC 6901): /a/0/b; escape ~ as ~0 and / as ~1 (decode ~1 first); "" is the whole document, "/" is the key ""; no leading zeros; - is "after the last" and never reads a value. fast-json-patch accepted /foo/01
  • JSONPath (RFC 9535, Feb 2024): $, .name, *, .., [3], [-1], [a,b], [start:end:step], [?@.x < 10], functions length count match search value; results are a nodelist, empty is valid; json-p3 and python-jsonpath scored 21/21, jsonpath-plus 13/21, jsonpath-ng 15/21
  • jq has no JSONPath: use select, .., paths, path(...); no slice step
  • Patch (RFC 6902): array of add remove replace move copy test; remove/replace need an existing target; add over an existing member replaces it; /- appends; test gives preconditions; media type application/json-patch+json
  • Libraries: jsonpatch, json-p3 and validated fast-json-patch matched the RFC on 24/24; fast-json-patch's default applyPatch accepted 3 invalid patches (pass true); apply patches to a copy: fast-json-patch (default), json-p3 and jsonpatch with in_place=True left a half-applied document after a failure
  • Merge patch (RFC 7396): object patch merges, null deletes, arrays replace whole, non-object patch replaces the target; cannot set null, cannot edit one array element; application/merge-patch+json; Node, Python and jq function all matched 18/18; jq's * keeps nulls and is not merge patch
  • Choose: Pointer for one place; an RFC 9535 JSONPath library for portable search; jq for terminal work; Patch for exact, ordered, checkable edits; Merge Patch for simple field updates without meaningful null; Schema for shape
Coming next
Chapter 9 is JSON Lines and large files: the problem with a 2 GB array, JSON Lines and NDJSON (one value per line) with jq -c, -s and --stream, streaming parsers in Node and Python, measured memory and time on generated data, gzip, binary formats such as MessagePack and CBOR, and pagination as the API-side answer. Start with Working with JSON 9.