Skip to content
jsonbeautifiers
English

JSONPath, the version that is actually specified

JSONPath was a blog post for seventeen years. RFC 9535 finally says what it means.

Every claim here is either measured or sourced. Where it is neither, it says so.

JSONPath began in 2007 as a blog post by Stefan Goessner, sketching an XPath-like query language for JSON in about two screens of prose and a reference implementation in JavaScript. It was good enough that everybody implemented it, and vague enough that everybody implemented it differently. Does the descendant operator .. consider the root node itself, or only its children? Is [-1] the last element or an error? Can one bracket hold several selectors? What does a filter do when the key it tests does not exist? What does a slice with a step of zero select? Every one of those had at least two answers in the wild, and the original document settled none of them, because parts of it were delegated to whatever eval() happened to be available.

RFC 9535 fixed that in February 2024. It is an IETF Proposed Standard, which is the first maturity level on the standards track: it is a real, stable, normatively worded specification, and it is not an Internet Standard. Treat it the way you would treat any Proposed Standard, as the thing to write new code against while knowing that a lot of deployed code predates it.

The document

Everything below runs against this:

{
  "store": {
    "name": "Corner Books",
    "book": [
      { "category": "reference", "author": "Nigel Rees",
        "title": "Sayings of the Century", "price": 8.95 },
      { "category": "fiction", "author": "Evelyn Waugh",
        "title": "Sword of Honour", "price": 12.99 },
      { "category": "fiction", "author": "Herman Melville",
        "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 },
      { "category": "fiction", "author": "J.R.R. Tolkien",
        "title": "The Lord of the Rings", "isbn": "0-395-19395-8" }
    ]
  }
}

Note the last book: no price. That absence is where most of the interesting behaviour lives.

Segments and selectors

A query is $ followed by a sequence of segments. $ is the root. Each segment applies one or more selectors to every node currently in hand, and produces a new nodelist. The result of a query is always a list of nodes, even when it holds one node or none, which is why a JSONPath library returns an array where a JSON Pointer library returns a value.

$.store.book[0].title        dot notation, shorthand for name selectors
$['store']['book'][0]        bracket notation, identical meaning
$["store"]["book"][0]        double quotes work too

Bracket notation is not optional decoration. $.first-name is not a valid name selector, so a key with a hyphen, a space, a dot or a leading digit has to be written $['first-name']. Bracket form with single quotes is also the shape of a normalised path, the unique identifier RFC 9535 defines for a single node: $['store']['book'][0]['title'].

The selectors:

  • Name: 'title' or "title", selecting an object member. Nothing on an array.
  • Wildcard *: every member value of an object, every element of an array. $.store.book[*] and $.store.book.* are the same query.
  • Index: an integer, zero based. Negative counts from the end, so [-1] is the last element. That is now specified, not a per-library nicety.
  • Slice start:end:step: half open, end exclusive, and a negative step walks backwards. A step of 0 selects nothing rather than raising, which is the one place the RFC deliberately parts company with Python.
  • Filter ?expr: covered below.

A child segment can hold several selectors separated by commas, and they do not have to be the same kind. $.store.book[0, -1] is the first and last book; $.store.book[0, 2:4] mixes an index with a slice. Results come back in selector order, so a union can legitimately return the same node twice.

A descendant segment is written with two dots: $..author, $..['author'], $..*, $..[0]. It visits the input node and every descendant of it, then applies its selectors to each. The old ambiguity is gone: $..store on the document above does match $.store, because the descendant segment starts at the root itself.

Filters, which is where the questions are

Inside a filter, @ is the current node being tested and $ is still the root of the whole document, so a filter can compare a value against something elsewhere in the document.

A bare query used as a filter expression is an existence test: it is true when the query selects at least one node. $.store.book[[email protected]] selects the two books that have an ISBN. Comparison operators are ==, !=, <, <=, >, >=; logical operators are &&, || and prefix !, with parentheses for grouping. Parentheses around the whole expression are allowed but no longer required, so both [?(@.price < 10)] and [[email protected] < 10] are valid and mean the same thing.

Comparison operands are restricted. Each side must be a literal, a singular query (one made only of name and index selectors, so it can select at most one node), or a function call. @.price qualifies. @..price and @.book[*].price do not, and an implementation should reject the query rather than guess.

Now the rule that trips people up. A query that selects nothing yields the special value Nothing, and Nothing is not null, not zero and not false. It compares equal to Nothing and to nothing else, and every ordering comparison involving it is false. Consequences:

$.store.book[[email protected] < 10]     excludes the Tolkien book (no price)
$.store.book[[email protected] >= 10]    also excludes it
$.store.book[[email protected] == null]  also excludes it: Nothing is not null
$.store.book[[email protected]]         selects it, and only it

So absence is tested by negating the existence test, and == null tests for a member that is present and holds null. The mirror image is a genuine surprise: $.store.book[[email protected] == @.discount] selects the Tolkien book, because both sides are Nothing and Nothing equals Nothing.

A comparison between two different types is never an error. Equality across types is simply false, and ordering is defined only between two numbers or two strings, so @.price > "10" is false for every book.

Function extensions

Five are defined, and they are typed, so length(@.book[*]) is a type error rather than a runtime surprise.

Function Takes Gives
length() a value Unicode scalar values in a string, elements in an array, members in an object, otherwise Nothing
count() a nodelist how many nodes it selected
match() a string and a regex true if the regex matches the whole string
search() a string and a regex true if the regex matches anywhere in the string
value() a nodelist the value, if the nodelist holds exactly one node, otherwise Nothing

count() exists because a non-singular query cannot be a comparison operand, so count(@.book[[email protected]]) == 2 is how you say “has exactly two books with an ISBN”. value() solves the same problem from the other direction: $[?value(@..name) == 'Corner Books'] works because value() collapses a multi-node query into a single comparable value, or into Nothing if it matched more or fewer than one node.

The regex dialect is I-Regexp (RFC 9485), a deliberately small subset that maps onto XSD regular expressions. It is not PCRE. Do not expect lookaheads or backreferences, and remember that match(@.category, 'fic') is false for "fiction" while search(@.category, 'fic') is true.

What is not in the language

Three things people still reach for do not exist. Script expressions, the [(...)] form from the original post, are gone, and with them the eval() dependency. There is no parent operator: a query walks down only, so if you need the enclosing object you select the object and filter on the child. And the length pseudo-property is gone, so $.store.book[(@.length-1)] is not a query. Write $.store.book[-1].

Worked examples

Expression Result
$.store.name "Corner Books"
$.store.book[*].author all four authors
$..isbn the two ISBN strings
$.store.book[-1].title "The Lord of the Rings"
$.store.book[1:3].title "Sword of Honour", "Moby Dick"
$.store.book[::2].title "Sayings of the Century", "Moby Dick"
$.store.book[0,-1].title "Sayings of the Century", "The Lord of the Rings"
$.store.book[[email protected] < 10].title "Sayings of the Century", "Moby Dick"
$.store.book[[email protected]].title "The Lord of the Rings"
$.store.book[[email protected] > $.store.book[0].price].title "Sword of Honour", "Moby Dick"
$.store.book[?match(@.category, 'fic.*')].author Waugh, Melville, Tolkien
$.store.book[?search(@.author, 'Mel')].title "Moby Dick"
$.store.book[?length(@.title) > 16].title "Sayings of the Century", "The Lord of the Rings"

Paste the document and any of these into the JSONPath tester to see the nodelist alongside the normalised path of each match, which is the fastest way to check whether your library agrees with the RFC on negative indices and on absent keys.

When to use something else

JSONPath selects nodes. That is the whole job, and three other query tools overlap it:

JSON Pointer (RFC 6901) addresses exactly one node, with no wildcards, no filters and no ambiguity: /store/book/0/title, with ~1 for a literal slash and ~0 for a literal tilde. It is what JSON Schema errors and JSON Patch operations point with. If you know the address, use a Pointer.

jq is a full stream-processing language with its own value model, arithmetic, variables and output formatting. It transforms; JSONPath only selects. If your expression is starting to build new objects, you want jq.

JMESPath sits between the two: a specified query language, older than RFC 9535, with projections and its own function library, and syntax close enough to JSONPath to be confusing and different enough to break. Pick one per codebase.

For the everyday case, cutting a payload down to the fields you care about, the filter tool does it without an expression language at all, and the viewer shows you the shape you are querying. If what you want is to know what changed between two payloads, that is diff, not a query.