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.

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:
- Whether filter parentheses are required;
- Whether negative indices such as
-1are supported; - 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.bookfirst and then filter, don't write$..book; - Compile expressions once and reuse them (most libraries offer
compileor 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.colordirectly; - 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]orvalue()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.