Published
JSONPath is the shortest way to say "give me every author in this document" without writing a loop. For eighteen years it had no specification, only a 2007 blog post and a dozen libraries that each interpreted it slightly differently. RFC 9535 fixed that in February 2024. This guide covers the syntax, the results it produces on a real document, and the places where your library may still not match the standard.
What JSONPath is, and what it is not
JSONPath is a read-only selection language. An expression takes a JSON document and returns a nodelist: zero or more values drawn from the document, in a defined order. It does not transform, aggregate, or construct new structures. That restriction is a feature — it means an expression is safe to accept from a configuration file, and it is why JSONPath shows up embedded in Kubernetes `kubectl -o jsonpath`, in log pipelines, in API gateways, and in test assertions.
The design was published by Stefan Goessner in 2007, modelled on XPath, and every implementation since has followed that article plus its own interpretation of the gaps. The gaps were substantial: the article left the behaviour of descendant search on objects, the semantics of filters, and the exact result type all unspecified. RFC 9535, "JSONPath: Query Expressions for JSON", closed them in 2024. If you are writing a new expression today, write it to the RFC and then verify it against the library you will actually run.
The single most important structural fact is that expressions come in two kinds. A singular query — only name and index selectors, no wildcards, descendants, slices or filters — can match at most one node. Everything else is indefinite and may match any number of nodes, including zero. Some libraries return a bare value for a singular query and a list for an indefinite one, which means your calling code has to know which kind you wrote. The RFC always returns a nodelist.
| Syntax | Meaning | Example |
|---|---|---|
| `$` | The root node of the document | `$` |
| `@` | The current node, valid only inside a filter | `[email protected]` |
| `.name` | Child by name, dot notation | `$.store.bicycle` |
| `['name']` | Child by name, bracket notation; required for keys with spaces or dots | `$['store']['book']` |
| `['a','b']` | Union of several names | `$.store['book','bicycle']` |
| `*` | Wildcard: every member of an object or element of an array | `$.store.book[*]` |
| `..` | Descendant search: this node and all nodes beneath it | `$..author` |
| `[0]` | Array element by index, zero-based | `$.store.book[0]` |
| `[-1]` | Index counted from the end | `$.store.book[-1]` |
| `[0,2]` | Union of indices | `$.store.book[0,2]` |
| `[start:end:step]` | Slice; `end` is exclusive, all three parts optional | `$.store.book[0:2]` |
| `?expr` | Filter: keep members whose expression is true | `$.store.book[[email protected]]` |
| `length()`, `count()`, `match()`, `search()`, `value()` | The five functions defined by RFC 9535 | `?length(@.title) > 15` |
A document to query against
Every example below runs against this document — the bookstore from the original JSONPath article, which most libraries also use in their own test suites, so you can check any result here against your implementation directly.
Note the deliberate irregularity: only two of the four books have an `isbn`, and `bicycle` is an object rather than an array. Real documents look like this, and the irregularity is exactly what filters are for.
{
"store": {
"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", "price": 22.99 }
],
"bicycle": { "color": "red", "price": 19.95 }
}
}Selectors and slices, with real results
Descendant search is the operator people reach for first and understand least. `$..price` finds every `price` anywhere in the document, which here means the four book prices and the bicycle price. The four book prices come out in array order, because array order is defined. The position of the bicycle price relative to them depends on the order in which the implementation visits the members of the `store` object — and object member order is explicitly not guaranteed by the RFC. If you need a deterministic order, do not rely on a descendant search across objects.
Slices follow Python's convention: `[start:end:step]`, with `end` excluded, and negative values counting from the end. `[0:2]` gives the first two elements. `[-2:]` gives the last two. `[::2]` gives every second element. A negative step reverses the result. An out-of-range slice is not an error; it simply yields fewer nodes, which makes slices safe to apply to arrays of unknown length.
$.store.book[*].author
-> ["Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"]
$..author same four values, found by descendant search
-> ["Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"]
$..price
-> [8.95, 12.99, 8.99, 22.99, 19.95]
The four book prices are in array order. Whether 19.95 (the bicycle)
comes last depends on object member ordering, which is NOT guaranteed.
$.store.book[2].title -> ["Moby Dick"]
$.store.book[-1].title -> ["The Lord of the Rings"]
$.store.book[0:2].title -> ["Sayings of the Century", "Sword of Honour"]
$.store.book[0,2].price -> [8.95, 8.99]
$.store['book','bicycle'] -> [the book array, the bicycle object]
$.store.book[5] -> [] out of range is empty, not an errorFilters are where the value is
A filter selector tests each member of the array or object it is applied to and keeps the ones for which the expression is true. Inside the filter, `@` is the member under test. The RFC syntax is `?` followed by a logical expression; parentheses are grouping, so the older `?(...)` form that most libraries use is still valid and still reads more clearly to most people.
The comparison operators are `==`, `!=`, `<`, `<=`, `>`, `>=`, and the logical operators are `&&`, `||`, `!`. String literals may be single- or double-quoted. Comparisons between different types are simply false rather than an error, which is what you want when a field is missing from some records.
The most useful form has no operator at all. A bare query inside a filter is an existence test: `[email protected]` keeps every member that has an `isbn` member, whatever its value. This is how you find the irregular records in a document, and it is usually the first query worth running against an unfamiliar payload.
RFC 9535 also defined five functions. `length()` gives the length of a string, array or object. `count()` gives the number of nodes a query matched. `match()` and `search()` apply an I-Regexp pattern, anchored and unanchored respectively. `value()` converts a single-node result into a value so it can be compared. Library support for these is still uneven — they are the newest part of the specification — so check before depending on them.
$.store.book[[email protected] < 10].title
-> ["Sayings of the Century", "Moby Dick"] 8.95 and 8.99
$.store.book[[email protected]].title existence test
-> ["Moby Dick", "The Lord of the Rings"]
$.store.book[[email protected]].title negated existence
-> ["Sayings of the Century", "Sword of Honour"]
$.store.book[[email protected] == 'fiction' && @.price < 20].author
-> ["Evelyn Waugh", "Herman Melville"] Tolkien is 22.99
$.store.book[?length(@.title) > 15].title RFC 9535 function
-> ["Sayings of the Century", "The Lord of the Rings"]
22 and 21 characters. "Sword of Honour" is exactly 15, so it is excluded.
$..[[email protected] > 15] any node with a price
-> [the Tolkien book, the bicycle]
$.store.book[[email protected] > 100]
-> [] no match is an empty
nodelist, not an errorWhere implementations still disagree
The differences that bite hardest are about result shape. Jayway returns the value directly for a definite path and a list for an indefinite one, so `$.store.bicycle.color` gives you a string while `$..color` gives you a list of one. Some libraries return `null` for no match where others return an empty list, which changes how your caller must test for absence. Decide once how your code distinguishes "matched null" from "matched nothing", because those are genuinely different and several libraries conflate them.
The second cluster is extensions. `jsonpath-plus` adds a parent operator `^`, a property-name operator `~`, and type selectors such as `@string()`. These are genuinely useful and completely non-portable — an expression using them will not run anywhere else. Goessner's original also permitted script expressions like `$..book[(@.length-1)]`, evaluated by the host language. Those have been removed from every security-conscious implementation and are not in the RFC; treat any expression containing them as a red flag.
Practical advice: keep expressions in the intersection. Name and index selectors, wildcards, descendants, slices, and filters with plain comparisons run essentially everywhere. Functions, unions of names, negative indices and extensions are where you start depending on a particular library. Whatever you write, test it against a document that includes your edge cases before shipping it in a config file where a silent empty result looks exactly like a working query.
| Area | Common older behaviour | RFC 9535 |
|---|---|---|
| Filter syntax | `?(@.price < 10)` with mandatory parentheses | `[email protected] < 10`; parentheses are grouping only |
| No match | `null`, an empty list, or an exception | Always an empty nodelist |
| Definite path result | Often the bare value | Always a nodelist |
| Negative index `[-1]` | Often unsupported | Supported |
| Union of names `['a','b']` | Varies | Supported |
| Object member order in `..` | Insertion order in practice | Explicitly not guaranteed |
| Script expressions `[(...)]` | Goessner permitted them | Removed; never implement them |
| Regular expressions | `=~` operator in some libraries | `match()` and `search()` with I-Regexp |
| Parent / property-name access | `^` and `~` in `jsonpath-plus` | Not in the specification |
JSONPath, jq and JSON Pointer
JSON Pointer (RFC 6901) addresses exactly one location by a literal path: `/store/book/0/title`. It has no wildcards, no search and no filters, and it cannot fail to be unambiguous. That is why it is the addressing mechanism inside JSON Patch, JSON Schema error reports and OpenAPI `$ref` values. If you know exactly where something is and you need a stable, referenceable address, use a pointer, not a path expression.
jq is a complete programming language for JSON. It has pipes, variables, user-defined functions, reduction, and — crucially — it constructs new output. `.store.book[] | select(.price < 10) | {t: .title}` selects and then reshapes. JSONPath cannot reshape anything. If your task ends with "...and then group by category and sum the prices", you want jq, or a real programming language.
JSONPath's niche is between them: search and filter without transformation, in a syntax short enough to live in a YAML field or a command-line flag, and safe enough to accept from untrusted configuration because it cannot execute anything. That is a genuinely useful slot, and it is why `kubectl`, many API gateways and most contract-testing tools chose it.
| Task | Use | Example |
|---|---|---|
| Address one known location stably | JSON Pointer | `/store/book/0/title` |
| Find every value with a given key, anywhere | JSONPath | `$..price` |
| Filter records by a condition | JSONPath | `$.store.book[[email protected] < 10]` |
| Reshape output into a new structure | jq | `.store.book[] | {t: .title}` |
| Aggregate, group or sum | jq | `[.store.book[].price] | add` |
| Accept an expression from a config file | JSONPath | no execution surface |
| Describe a change to a document | JSON Patch, which uses JSON Pointer | `{"op":"replace","path":"/store/bicycle/color"}` |
What to remember
- Write new expressions against RFC 9535, then verify them against the library you will actually run, because every widely deployed implementation predates the standard.
- Use a bare query inside a filter as an existence test — `$.store.book[[email protected]]` — as the first thing you run against an unfamiliar document; it shows you which records are irregular.
- Never rely on the order of results from a descendant search that crosses object members, since the specification explicitly does not guarantee it; array order is guaranteed.
- Decide once how your code distinguishes a matched `null` from no match at all, because libraries variously return `null`, an empty list, or throw.
- Reach for JSON Pointer when you need one stable address and for jq when you need to reshape or aggregate; JSONPath is for searching and filtering without transformation.