A JSONPath cheat sheet for real queries
Most day-to-day querying uses a small set of operators. Keep this table handy:
| Expression | Returns |
|---|---|
$.store.book[0].title | First book’s title |
$.store.book[*].author | Every book’s author |
$..author | All author values, any depth |
$.store.book[-1] | Last book |
$.store.book[0:2] | First two books (end-exclusive) |
$.store.book[?(@.price < 10)] | Books cheaper than 10 |
$..* | Every value in the document |
The $ is always the root, @ refers to the current element inside a filter, and .. scans recursively at every depth.
Reading an expression you didn’t write
Most JSONPath tools assume you are composing a query. The harder job is the opposite one: a colleague’s config, a Jenkins step, or a Grafana panel contains $.items[?(@.status=='active')].meta.tags[-1] and you need to know what it selects before you change it. This tool will break an expression into its segments and describe each one, which is why the explain mode exists alongside the evaluator.
The reading order is strictly left to right, and each segment narrows or expands the set of nodes carried forward:
| Segment | Effect on the set |
|---|---|
$ | Start with one node: the whole document |
.items | Replace it with the value at items |
[?(@.status=='active')] | Keep only members where the test passes |
.meta | Replace each survivor with its meta value |
.tags | Replace each with its tags value |
[-1] | Keep the last element of each |
Two habits make this reliable. Evaluate prefixes — run $.items, then $.items[?(@.status=='active')], and watch where the count collapses; the segment that empties the result is the one that is wrong. And remember that the result is always a list of nodes, never a single value: $.items[0].name returns a one-element list, not a string, which is why so many pipelines end with an index or a [0] that looks redundant.
Why the same expression gives different answers in different tools
JSONPath spent twenty years as a blog post rather than a standard. Independent implementations diverged on genuinely ambiguous cases — how a slice with a negative step behaves, whether recursive descent visits the root itself, whether duplicate matches are collapsed, and whether an expression that matches nothing is an empty result or an error. Two libraries could both be defensible and still disagree about the same query.
RFC 9535 settled it in February 2024. If you are debugging a mismatch between a query that works locally and one that fails in production, check the two implementations’ RFC compliance before assuming your expression is wrong — a pre-RFC library and a compliant one can legitimately return different node lists for identical input. The RFC also defines a small function set (length(), count(), match(), search(), value()), so a filter using any of those requires a reasonably current implementation on both ends.
The two operators that cause the most confusion
Recursive descent (..) is powerful but blunt — $..author finds every author anywhere in the tree, which is exactly what you want until it returns far more than expected on a large document. Narrow it with a specific key, and prefer an explicit path ($.store.book[*].author) when you know the structure.
Filters (?()) trip people on syntax: the current element must be @ (not $), string comparisons need quotes (?(@.name == 'Jane')), and the operator is == not ===. Get those three right and filters become the most useful tool in the set.
JSONPath, JMESPath, or jq?
They solve overlapping problems with different trade-offs:
- JSONPath — XPath-style, great for selecting values from API responses; now RFC 9535.
- JMESPath — also query-focused, with a more consistent spec; used by AWS CLI’s
--query. - jq — a full command-line language that can transform, reshape, and compute, not just select. Steeper to learn, far more powerful for editing.
If you only need to find values, JSONPath is the simplest. If you need to reshape JSON, that’s jq territory.
Debugging a query that returns nothing
An empty result almost always means a path mismatch, and the usual suspects are: case sensitivity ($.User ≠ $.user), a missing array index (a value inside an array needs [0] or [*]), or the wrong nesting level. Start broad with $.* to list the top-level keys, then walk down level by level. Everything runs locally in your browser, so you can iterate on sensitive API responses without anything leaving your device.