JSON Toolkit

JSONPath Query Language in Practice

Bottom line: In 2024 JSONPath finally got a formal standard, RFC 9535. This article explains the syntax, filter semantics, functions, and the historical differences among implementations.

Intermediate~12 min readPublished 2026-10-30Updated 2026-10-30
Cover for JSONPath Query Language in Practice

Bottom line: locate with an explicit path first and filter on top of it, and write expressions against RFC 9535; use property access or JSON Pointer for a single fixed field, and jq or code for aggregation.

You have probably seen expressions like $.store.book[?(@.price < 10)].title, but it only became an international standard in 2024.

1. From De Facto Standard to Formal Standard

JSONPath was proposed by Stefan Gössner in 2007, and for a long time the only "specification" was a single blog post, with each language implementation doing its own thing:

  • whether filters need parentheses (?(@.a) vs ?@.a);
  • whether the array index [-1] is supported;
  • whether the result returns "values" or "values and paths";
  • whether script expressions (?(@.price < $.max)) are allowed.

RFC 9535, published in February 2024, converged these divergences into a single syntax and semantics and provided an official compliance test suite. New projects should follow RFC 9535; when migrating old projects, confirm the implementation version first.

2. Syntax Cheat Sheet

Syntax Meaning Example
$ Root node $.store
@ Current node inside a filter $[?@.price < 10]
.name Child node (the name must match [A-Za-z_][A-Za-z0-9_]*) $.store.book
['name'] Child node (any name, including non-ASCII and dots) $['store']['书']
[0] Array index; negative numbers count from the end $.book[0], $.book[-1]
[0,2] Multiple indices / multiple names $.book[0,2]
[1:3] Slice, inclusive start and exclusive end $.book[1:3]
[::2] Slice with a step $.book[::2]
[*] Wildcard for all child nodes $.book[*].title
..name Recursive descent, searching at any depth $..price
..[*] Recursively take all nodes $..*
[?<expr>] Filter selector $.book[?@.price < 10]

One semantic you must remember: JSONPath returns a node set (nodelist), not a single value. Even when only one node matches, the result is a list of length one.

3. Running Through the Syntax with One Sample

{
  "store": {
    "book": [
      { "title": "JSON 权威指南", "price": 8.95, "tags": ["json", "spec"] },
      { "title": "数据交换格式史", "price": 12.99, "tags": ["history"] },
      { "title": "解析器实现原理", "price": 8.99, "tags": ["parser", "json"] }
    ],
    "bicycle": { "color": "red", "price": 19.95 }
  },
  "expensive": 10
}
Expression Result
$.store.bicycle.color ["red"]
$..price 4 prices (including the bicycle price)
$.store.book[*].title 3 titles
$.store.book[?@.price < 10].title Titles of books 1 and 3
$.store.book[?@.tags] All books that have tags (existence test)
$.store.book[-1].title Title of the last book
$..book[0,1] The first two books
$.store.book[?@.price < $.expensive] Syntactically implementation-dependent; RFC 9535 does not allow cross-root references

The last row is a historical trap: many implementations allow $.expensive inside a filter for cross-node comparison, which RFC 9535 explicitly forbids — a filter can only access the current node @. When you need to compare against a global threshold, pass the threshold as an argument to the query function.

4. What Filters Can Express

RFC 9535 filter expressions support four kinds of constructs:

Logic:       &&  ||  !          e.g. ?@.price < 10 && @.tags
Comparison:  ==  !=  <  <=  >  >=
Existence:   a bare path        e.g. ?@.tags (tests existence)
Functions:   length() count() match() search() value()

A few practical examples:

// Price between 8 and 10
$.store.book[?@.price >= 8 && @.price <= 10]

// Title contains "JSON" (regex match; the second argument is an I-Regexp)
$.store.book[?match(@.title, ".*JSON.*")]

// More than one tag
$.store.book[?length(@.tags) > 1]

// Get the first matched value instead of a list (value() turns a single-node set into a value)
$.store.book[?@.price < 9].title.value()

match() is a full-string match and search() is a substring search; the underlying regex dialect is I-Regexp (a subset of RFC 9485), which supports neither backreferences nor lookaround — that is both a limitation and a handy shield against regex DoS.

5. Implementation Differences: Check Before Migrating

Implementation Language RFC 9535 support Notes
jsonpath-plus JavaScript Mostly compatible Supports extension functions and script expressions; more capable but not strictly standard
jsonpath (dchester) JavaScript Partial Long-standing library with ?() syntax and a different result structure
ojg / gjson Go ojg has good support gjson uses another path syntax and is not JSONPath
jsonpath-ng Python Partial Supports extensions and is fairly permissive
Jayway JsonPath Java Partial Broad ecosystem; historical behavior differs from the RFC

Three checks before migrating:

  1. Whether filter parentheses are required;
  2. Whether negative indices such as -1 are supported;
  3. Whether the result is an "array of values" or an "array of {path, value} objects".

The third is the easiest to trip over: the frontend reads result[0], and after switching libraries it receives an object, leaving the page blank.

6. Performance and Security

Performance

  • JSONPath is essentially a single traversal with O(number of nodes) complexity, and it cannot use indexes;
  • Recursive descent with .. is the most expensive; if you can locate with $.store.book first and then filter, don't write $..book;
  • Compile expressions once and reuse them (most libraries offer compile or caching) instead of re-parsing on every query;
  • For documents with more than 100,000 nodes, consider exact extraction with JSON Pointer first, or preprocessing on the server with jq.

Security

  • Expressions from user input must have limits on length and nesting depth;
  • Don't let JSONPath handle authorization — it can only read, and cannot express "which fields are invisible";
  • When the regex used by match() comes from users, limit its complexity; although it is safer than PCRE, a length cap is still recommended.

7. When Not to Use JSONPath

  • You only need one fixed field → use data.store.bicycle.color directly;
  • You need to write back after modifying → use JSON Pointer or a patch; JSONPath does not write back;
  • You need aggregation (summing, grouping) → use jq, SQL, or code; JSONPath only selects;
  • The document is huge and you need only a few fields → use a streaming parser instead of reading the whole tree into memory first.

8. Checklist

  • [ ] Expressions follow RFC 9535 and do not rely on cross-root references;
  • [ ] It is clear that the result is a node set; use [0] or value() to get a single value;
  • [ ] Expressions are precompiled and reused;
  • [ ] User-controlled expressions have length and complexity limits;
  • [ ] The official compliance test cases are run when switching implementations.

FAQ

How do I choose between JSON Pointer and JSONPath?

Use plain property access or a JSON Pointer when you only need one fixed field; reach for JSONPath when you need filters, wildcards, or recursive descent. JSONPath only selects and never writes back, so write-back belongs to JSON Pointer or a patch.

Why can't a filter reference $.expensive?

RFC 9535 explicitly forbids cross-root references; a filter may only access the current node @. Pass the threshold as an argument to the query function instead. Older implementations accept the syntax, but it breaks when you switch or upgrade libraries.

Should I rely on result[0] to read a value?

Only after confirming that the current implementation returns an array of values. Some libraries return {path, value} objects, so result[0] becomes an object and the page goes blank. Read single values through [0] or value(), and run the official compliance suite before switching implementations.