JSONPath is a query language that selects values from a JSON document, the way XPath selects nodes from XML. Stefan Gössner described it in a 2007 article, and for seventeen years every library implemented its own reading of that article. In February 2024 the IETF published RFC 9535, a Standards Track specification of the syntax and the results.

Most libraries in use today were written before the RFC, so the same query can return different results, an empty list, or an error depending on the library. This guide covers the RFC 9535 syntax with results computed by the ZeroTool JSONPath Tester, which implements the RFC, and then runs sixteen queries through five implementations so you can see exactly where they diverge.

The syntax on one document

All examples use this document:

{
  "store": {
    "name": "Tech Books",
    "inventory": [
      { "title": "Refactoring", "price": 29.99, "inStock": true, "ebook": true },
      { "title": "Clean Code", "price": 34.99, "inStock": false },
      { "title": "Domain-Driven Design", "price": 39.99, "inStock": true, "ebook": false }
    ]
  }
}

A query starts at the root $ and applies segments from left to right. Each segment holds one or more selectors. The result is a nodelist: zero or more values, in document order for arrays. An empty nodelist is a normal result, not an error (RFC 9535 §2.1).

QuerySelector (RFC 9535 section)Result
$.store.nameName, shorthand (§2.3.1, §2.5.1)["Tech Books"]
$['store']['name']Name, bracket form["Tech Books"]
$.store.inventory[*].titleWildcard (§2.3.2)["Refactoring","Clean Code","Domain-Driven Design"]
$.store.inventory[0].titleIndex (§2.3.3)["Refactoring"]
$.store.inventory[-1].titleNegative index counts from the end["Domain-Driven Design"]
$.store.inventory[5].titleIndex past the end selects nothing[]
$.store.inventory[0:2].titleSlice start:end:step, end excluded (§2.3.4)["Refactoring","Clean Code"]
$.store.inventory[::-1].titleNegative step walks backwards["Domain-Driven Design","Clean Code","Refactoring"]
$.store.inventory[0,2].titleSeveral selectors in one segment (§2.5.1)["Refactoring","Domain-Driven Design"]
$..priceDescendant segment (§2.5.2)[29.99,34.99,39.99]
$.store.inventory[?@.price < 35].titleFilter (§2.3.5)["Refactoring","Clean Code"]

Bracket notation is required when a member name is not a valid shorthand name, for example $['first name'] or $['2026']. Strings can use single or double quotes.

Filters, existence tests and functions

A filter [?expr] keeps the array elements (or object member values) for which the expression is true. Inside it, @ is the current element and $ is the whole document. Parentheses around the whole expression, as in Gössner’s [?(@.price < 35)], are allowed because the RFC grammar accepts a parenthesized logical expression, but they are not needed.

QueryWhat it testsResult
$.store.inventory[?@.price < 35].titleComparison: ==, !=, <, <=, >, >=["Refactoring","Clean Code"]
$.store.inventory[?@.inStock == true && @.price > 30].titleLogical AND; also || and !["Domain-Driven Design"]
$.store.inventory[?@.ebook].titleExistence: the member is present, even if false["Refactoring","Domain-Driven Design"]
$.store.inventory[?!@.ebook].titleAbsence["Clean Code"]
$.store.inventory[?@.ebook == true].titleValue test["Refactoring"]
$.store.inventory[?length(@.title) > 10].titlelength(): characters of a string, items of an array or members of an object["Refactoring","Domain-Driven Design"]
$.store.inventory[?match(@.title, 'C.*')].titlematch(): the whole string matches["Clean Code"]
$.store.inventory[?search(@.title, 'Design')].titlesearch(): a substring matches["Domain-Driven Design"]
$[?count(@.inventory[*]) == 3].namecount(): number of nodes in a nodelist; here the filter runs over the root object’s member values["Tech Books"]

Three rules from the RFC explain most surprises:

  • Existence is not truthiness. [?@.ebook] selects the third book, whose ebook is false, because the member exists. Write [?@.ebook == true] to test the value.
  • Comparisons never fail with an error. Ordering a number against a string is false, and @.missing == 'x' is false because the left side is an empty nodelist (§2.3.5.2.2). [?@.price < 'x'] selects nothing rather than reporting a type error.
  • Regular expressions are I-Regexp. match() and search() take RFC 9485 I-Regexp patterns, a deliberately small subset meant to behave the same in every regex engine. There is no =~ operator and no /…/i flags. These five functions are the only ones the RFC defines (§2.4); anything else is a library extension.

The RFC also defines how to name a result’s location. A normalized path uses bracket notation only: the third title is $['store']['inventory'][2]['title'] (§2.7). Libraries that return paths along with values should use this form if they follow the RFC.

Five implementations, sixteen queries

We ran the queries below on 2026-10-02 against the document above with the ZeroTool tester (RFC 9535), jsonpath-plus 11.1.1 (JSONPath({ path, json, wrap: true })), jsonpath 1.3.0 (jp.query), Python jsonpath-ng 1.8.0 (jsonpath_ng.ext.parse, the parser that supports filters) and Java Jayway JsonPath 3.0.0 (JsonPath.parse(json).read(path) with the default configuration). “error” means the library threw; [] means it returned nothing without an error.

QueryZeroTool (RFC 9535)jsonpath-plusjsonpathjsonpath-ngJayway
$.store.name["Tech Books"]["Tech Books"]["Tech Books"]["Tech Books"]"Tech Books"
$.store.inventory[*].title3 titles3 titles3 titles3 titles3 titles
$.store.inventory[-1].title["Domain-Driven Design"][][]["Domain-Driven Design"]"Domain-Driven Design"
$.store.inventory[0:2].title["Refactoring","Clean Code"]["Refactoring","Clean Code"]["Refactoring","Clean Code"]["Refactoring","Clean Code"]["Refactoring","Clean Code"]
$..price[29.99,34.99,39.99][29.99,34.99,39.99][29.99,34.99,39.99][29.99,34.99,39.99][29.99,34.99,39.99]
$.store.inventory[?(@.price < 35)].title["Refactoring","Clean Code"]["Refactoring","Clean Code"]["Refactoring","Clean Code"]["Refactoring","Clean Code"]["Refactoring","Clean Code"]
$.store.inventory[?@.price < 35].title["Refactoring","Clean Code"][]error["Refactoring","Clean Code"]error
$.store.inventory[?(@.inStock == true && @.price > 30)].title["Domain-Driven Design"]["Domain-Driven Design"]["Domain-Driven Design"]error["Domain-Driven Design"]
$.store.inventory[?@.ebook].title["Refactoring","Domain-Driven Design"][]error["Refactoring","Domain-Driven Design"]error
$.store.inventory[?length(@.title) > 10].title["Refactoring","Domain-Driven Design"][]errorerrorerror
$.store.inventory[?match(@.title, 'C.*')].title["Clean Code"][]errorerrorerror
$.store.inventory[?(@.title =~ /.*Code/)].titleerrorerrorerrorerror["Clean Code"]
$.store.inventory.length()error[]errorerror3
$.store.inventory[?(@.price > 30)]['title','price']["Clean Code",34.99,"Domain-Driven Design",39.99]["Clean Code",34.99,"Domain-Driven Design",39.99]["Clean Code",34.99,"Domain-Driven Design",39.99]["Clean Code",34.99,"Domain-Driven Design",39.99]two objects with title and price
store.nameerror["Tech Books"]["Tech Books"]["Tech Books"]"Tech Books"
$.store.missing[][][][]error

What the differences mean in practice:

  • Silent empty results are the dangerous ones. jsonpath-plus returned [] for a negative index, a filter without parentheses, an existence test and every RFC function. Code that treats “no match” as “no data” will not notice. [-1:] (a slice) works in all five for the last element.
  • && versus &. jsonpath-ng’s extended parser rejects &&; its own syntax for AND is &. [?(@.inStock == true & @.price > 30)] also worked in jsonpath-plus and jsonpath, while the ZeroTool tester and Jayway rejected it, so neither spelling is portable across all five.
  • Jayway returns shapes, not nodelists. A definite path such as $.store.name returns the scalar "Tech Books", an indefinite one returns a list, a missing path throws PathNotFoundException, and a union of member names returns objects instead of values. Option.ALWAYS_RETURN_LIST and Option.SUPPRESS_EXCEPTIONS give ["Tech Books"] and [] instead. Jayway also accepts =~ regexes and length() as a path function, which is why it is the only library that answers those two rows.
  • The leading $. Four libraries accept store.name. RFC 9535 requires $, and the ZeroTool tester reports a query must start with $ (at character 1).

If a JSONPath travels between systems, for example an expression stored in a config file and evaluated by services in different languages, restrict it to the rows where all columns agree: names, wildcards, non-negative indexes, slices, .. and simple comparisons inside [?( … )]. The JSONPath Compliance Test Suite is the RFC working group’s test set if you need to check a library more thoroughly.

Running a query in Python and Java

jsonpath-ng’s extended parser understands negative indexes, filters with or without parentheses and ..:

# query_store.py — run: python query_store.py   (needs: pip install jsonpath-ng)
from jsonpath_ng.ext import parse

doc = {"store": {"name": "Tech Books", "inventory": [
    {"title": "Refactoring", "price": 29.99, "inStock": True, "ebook": True},
    {"title": "Clean Code", "price": 34.99, "inStock": False},
    {"title": "Domain-Driven Design", "price": 39.99, "inStock": True, "ebook": False},
]}}

for expr in [
    "$.store.inventory[-1].title",
    "$.store.inventory[?@.price < 35].title",
    "$.store.inventory[?(@.inStock == true & @.price > 30)].title",
]:
    print(expr, [m.value for m in parse(expr).find(doc)])
# $.store.inventory[-1].title ['Domain-Driven Design']
# $.store.inventory[?@.price < 35].title ['Refactoring', 'Clean Code']
# $.store.inventory[?(@.inStock == true & @.price > 30)].title ['Domain-Driven Design']

Each match also carries m.full_path, but in jsonpath-ng’s own notation rather than an RFC normalized path. The plain jsonpath_ng.parse (without .ext) has no filters at all: every filter row above fails with Unexpected character: ?.

In Java, decide up front whether you want Jayway’s scalar results or RFC-like lists:

Configuration conf = Configuration.defaultConfiguration()
    .addOptions(Option.ALWAYS_RETURN_LIST, Option.SUPPRESS_EXCEPTIONS);
DocumentContext doc = JsonPath.using(conf).parse(json);
List<String> name = doc.read("$.store.name");      // ["Tech Books"]
List<Object> none = doc.read("$.store.missing");   // []

JSONPath, jq and JMESPath

Some tools that look like JSONPath are not RFC 9535:

  • kubectl supports “JSONPath templates” in -o jsonpath=: expressions inside {…} plus range / end loops, and its documentation states that regular expressions are not supported and suggests jq instead.
  • AWS CLI --query uses JMESPath, a different language (Reservations[].Instances[].InstanceId, no $), per the AWS CLI user guide.
  • jq is a full transformation language. .store.inventory[] | select(.price < 35) | .title matches the filter row above, and jq can also build new objects, which JSONPath cannot.

Use JSONPath when you only need to select values and want an expression other systems can evaluate; use jq or JMESPath when the tool you are working in expects them or when you need to reshape the output.

Testing queries with the ZeroTool tester

The JSONPath Tester evaluates RFC 9535 in your browser as you type; the JSON is not uploaded. A single match is shown as the value itself and several matches as a JSON array, with a count such as “2 matches”. An empty nodelist shows “No matches found”. Syntax outside the RFC is reported with its position instead of an empty result, for example:

QueryMessage
$.store.inventory[?(@.title =~ /.*Code/)].titleUnsupported syntax: the =~ operator is not part of RFC 9535; use match() or search() (at character 29)
$.store.inventory.length()Unsupported syntax: unexpected ”(” (at character 25)
store.nameUnsupported syntax: a query must start with $ (at character 1)

The tester shows values only, not normalized paths, and it does not support library extensions such as Jayway’s path functions or jsonpath-plus’s @parent. If your production library is one of those in the table, test there as well before you rely on a query.