json
JSONPath vs jq: One Selects, One Transforms
JSONPath vs jq: one selects nodes, one is a transformation language. A syntax cheat sheet, RFC 9535's rules, and where implementations still disagree.
You copied a JSONPath filter out of a Stack Overflow answer, it returned an empty array in your library, and the person who posted it swears it works. JSONPath and jq look like competitors because both take a squiggly string and pull values out of JSON, but one is a selector and the other is a programming language — and the selector spent seventeen years with no specification at all, so “it works in the other tool” is the normal condition, not a bug report. Here is where the line between them actually falls, what each one physically cannot do, and which of the expressions you find online are portable.
The sample document for everything below — a service listing, because that is where most people first meet -o jsonpath=:
{
"region": "us-east",
"services": [
{ "name": "api", "replicas": 4, "healthy": true, "ports": [8080, 9090], "owner": { "team": "core", "email": "[email protected]" } },
{ "name": "worker", "replicas": 12, "healthy": false, "ports": [], "owner": { "team": "data", "email": "[email protected]" } },
{ "name": "cron", "replicas": 1, "healthy": true, "ports": [8080], "owner": { "team": "core", "email": "[email protected]" } }
],
"limits": { "cpu": "2", "memory": "4Gi" }
}
Paste it into our JSON path finder and you get every path to every value, which is the step before either language: you cannot write a selector for a shape you have not seen.
The actual distinction
JSONPath returns a subset of the nodes that were already in the document. jq returns a new document.
That is the whole thing. A JSONPath query is a filter over an input nodelist — every result is a value that existed at some location in the input, and the query can also tell you where it was. It has no way to invent a key, add two numbers, or change the shape of what it found.
jq is a stream-processing language with variables, functions, arithmetic, string interpolation, reduce, and a module system. .name is not jq’s syntax for “a path”; it is a filter that happens to be an object accessor, and it composes with every other filter through |. The path-shaped subset of jq is the first five minutes of it.
The overlap — “reach into this document and pull out some values” — is real but small.
JSONPath’s core syntax
RFC 9535 (“JSONPath: Query Expressions for JSON”, February 2024, Standards Track) is the first real specification, and it is based on Stefan Gössner’s original proposal dated 2007-02-21. The RFC is blunt about why it exists: “As JSONPath was implemented in more environments, JSONPath expressions became decreasingly portable.”
| Selector | Means | On the sample |
|---|---|---|
$ | The root node | The whole document |
.region | Child by name (shorthand) | "us-east" |
['region'] | Child by name (bracket form) | same |
[0] | Array index | First service |
[-1] | Index from the end | $.services[-1].name → "cron" |
[0:2] | Slice, end-exclusive | $.services[0:2].name → ["api", "worker"] |
* | Wildcard — every member or element | $.limits.* → ["2", "4Gi"] |
.. | Descendant segment (recursive descent) | $..email → all three email nodes |
[0,2] | Union of selectors | $.services[0,2].name → ["api", "cron"] |
[[email protected] > 3] | Filter | Keeps api and worker |
Two details that catch people. A query always returns a nodelist — even $.region gives you ["us-east"], not "us-east"; the unwrapping is your client library’s convenience, not the spec’s. And the RFC defines a Normalized Path: the canonical bracket-only spelling that identifies exactly one node, like $['services'][0]['owner']['email']. That is the form a path-finder tool hands you, and it is guaranteed to round-trip.
Inside a filter, RFC 9535 gives you five functions and no more: length(), count(), match(), search(), and value(). So $.services[?length(@.ports) == 0].name returns ["worker"] and $.services[?search(@.owner.email, 'core')].name returns ["api", "cron"]. There is no sum(), no sort(), and no way to call a function outside a filter.
The same ten jobs, three languages
jq outputs below are from jq 1.8.2; JSONPath from an RFC 9535-conformant engine; JMESPath from the reference JavaScript implementation.
| Job | JSONPath | jq | JMESPath |
|---|---|---|---|
| Top-level key | $.region | .region | region |
| Field of first element | $.services[0].name | .services[0].name | services[0].name |
| Last element | $.services[-1].name | .services[-1].name | services[-1].name |
| Slice | $.services[0:2].name | [.services[0:2][].name] | services[0:2].name |
| Field of every element | $.services[*].name | [.services[].name] | services[*].name |
| Two specific indices | $.services[0,2].name | [.services[0,2].name] | [services[0].name, services[2].name] |
Any email anywhere | $..email | [..|objects|select(has("email")).email] | no equivalent |
| Filter on a number | $.services[[email protected] > 3].name | .services|map(select(.replicas > 3).name) | services[?replicas > `3`].name |
| Flatten nested arrays | $..ports[*] | [.services[].ports[]] | services[*].ports[] |
| Count | not expressible | .services|length | length(services) |
| Rename keys | not expressible | .services|map({service:.name, pods:.replicas}) | services[*].{service: name, pods: replicas} |
| Sum | not expressible | [.services[].replicas]|add → 17 | sum(services[*].replicas) → 17 |
The bottom three rows are the point. JMESPath is the interesting middle case: multiselect hashes mean it can rename and reshape, and it has sum/sort_by/max_by — but it has no recursive descent, so $..email has no JMESPath spelling at all.
What JSONPath structurally cannot do
Anything where the answer is not already sitting in the document:
- Rename or restructure.
{"service": ..., "pods": ...}is a new object. - Compute. Sums, averages, differences, percentages, string concatenation.
- Group or aggregate.
.services | group_by(.owner.team)has no analogue. - Sort. The nodelist comes back in document order, full stop.
- Correlate two parts of the document. No variables, no joins.
- Emit anything but JSON values. jq’s
@csv,@tsv,@base64,@urihave no counterpart.
If your “JSONPath expression” needs any of those, you are looking for jq, and every extra minute you spend trying to express it in a selector is wasted.
Where jq is overkill
The reverse case is just as real, and it is mostly about where the expression lives.
kubectl ships a JSONPath dialect precisely so you don’t need a second binary in the pipeline: kubectl get pods -o jsonpath='{.items[*].metadata.name}'. Its docs list the supported operators — ./[], .., *, [start:end:step], unions, ?() filters, plus range/end for iteration — and note two things worth memorizing: “The $ operator is optional since the expression always starts from the root object by default,” and “JSONPath regular expressions are not supported. If you want to match using regular expressions, you can use a tool such as jq.” Dotted keys need escaping: {.items[0].metadata.labels.kubernetes\.io/hostname}.
The same logic applies anywhere a path string is a configuration value rather than a script: a field mapping, a log-pipeline extractor, a test assertion, a payload template. Those are single-location lookups, and reaching for a transformation language there means you now have a program to review.
--query is not JSONPath
A large share of “my JSONPath doesn’t work” is someone pasting JSONPath into something that speaks JMESPath. The AWS CLI’s --query, the Azure CLI’s --query, and Ansible’s json_query filter are all JMESPath, a separate specification with a separate grammar.
The giveaways: JMESPath has no leading $, its filters drop the @. prefix, and its literals go in backticks — so the AWS spelling of “services with more than three replicas” is services[?replicas > `3`].name, which is a syntax error in every JSONPath engine, and the JSONPath spelling $.services[[email protected] > 3] is a syntax error in JMESPath. And .. is not merely unsupported in JMESPath; it is a parse error.
Then there is the fourth path syntax, which you have also already met: JSON Pointer (RFC 6901), the /services/1/ports form used by JSON Patch and by JSON Schema error output. Our JSON Schema validator reports every failure at an instance path like /user/age for exactly that reason — Pointer identifies one location and cannot query, which is the right trade for an error report.
Seventeen years of drift, measured
Here is the same document through three JavaScript libraries: an RFC 9535 engine, jsonpath-plus 10.4.0, and jsonpath 1.3.0 (the classic Gössner port). Every cell below was run, not remembered.
| Expression | RFC 9535 | jsonpath-plus | jsonpath |
|---|---|---|---|
$.services[-1].name | ["cron"] | [] | [] |
$.services[[email protected] > 3].name | ["api","worker"] | [] | parse error |
$.services[?(@.replicas > 3)].name | ["api","worker"] | ["api","worker"] | ["api","worker"] |
$.services[?(@.replicas > "3")].name | [] | ["api","worker"] | ["api","worker"] |
$.services[?(@.healthy)].name | ["api","worker","cron"] | ["api","cron"] | ["api","cron"] |
$.services.length | [] | [3] | [3] |
$.services[(@.length-1)].name | parse error | ["cron"] | ["cron"] |
$.services[?(length(@.ports) == 0)].name | ["worker"] | length is not defined | rejected as unsafe |
Five separate traps in eight rows:
- Negative indices are specified and widely unimplemented.
[-1]is a normative RFC 9535 selector; both popular JS libraries return nothing. The slice form[-1:]works everywhere, which is why you see it in the wild. ?@versus?(@). The RFC’s ABNF isfilter-selector = "?" S logical-expr, and it says outright: “It is not required that logical-expr consist of a parenthesized expression (which was required in [JSONPath-orig]), although it can be, and the semantics are the same.” Gössner-era libraries demand them. Worse,jsonpath-plusdoesn’t error on the bare form — it returns an empty nodelist, so your query silently matches nothing.- Number-versus-string comparison. RFC 9535 makes cross-type comparison false; it gives
13 == '13'→falseas an example. JavaScript-backed engines evaluate the filter with JS semantics, so"2" > 1is true and@.replicas > "3"matches. A config value like"cpu": "2"will silently participate in numeric filters it has no business in. - There is no truthiness in a filter. A bare
@.healthyin RFC 9535 is an existence test, sohealthy: falsematches — three results, not two. Everything JS-backed reads it as truthiness. jq agrees with the JS libraries here (select(.healthy)dropsworker,select(has("healthy"))keeps it), which means the three-way disagreement is not academic. .lengthand(@.length-1)are JavaScript leaking through. The document has no member namedlength; those engines are reading a JS array property. The RFC’s answer is thelength()function, which they don’t have — andjsonpath1.3.0 now refuses script expressions outright with “script and filter expressions may only access the current node (@) with safe property names”, which is that library’s answer to theeval-shaped risk baked into the original design.
One more the RFC is explicit about: do not depend on the order of $.. results across objects. Array elements are visited in array order and parents before descendants, but “the order in which the children of an object are visited is not stipulated, since JSON objects are unordered.”
What our path finder does, exactly
Be clear about the tool’s job, because it is deliberately not an engine. The JSON path finder parses your JSON and walks it, listing the path to every leaf value in dot-and-bracket notation — $.services[1].owner.email, and $["user-id"] when a key isn’t a valid identifier. It shows the first 200 paths; an empty [] or {} is a leaf too, so it gets its own row rather than vanishing from the table.
Click a row and it fills the query box, which resolves that one concrete path and prints the value. The query box tokenizes the same three forms the listing prints — dot segments, numeric [0] brackets, and bracket-quoted keys in either quote style ($["user-id"], $['a.b']) — so a path with an awkward key round-trips instead of failing. What it does not do is anything with a branching answer: no wildcards, no filters, no .., and no JSONPath library underneath.
That is the useful half of the workflow anyway: the hard part of writing a selector is knowing the exact shape and the exact spelling of the keys, and once $.services[0].owner.email is in front of you, widening it to $.services[*].owner.email or $..email for your real engine is the easy step. Run messy input through the JSON formatter first if you can’t read it.
What to do
- Decide which side of the line you’re on before you start typing. If the output has a key the input didn’t have, or a number the input didn’t contain, it is jq. If it’s a lookup, a selector is smaller and lives happily inside a config file.
- Find the concrete path first, generalize second. Paste the document into the path finder, grab the exact path to the value you want, then widen it.
- Write filters in the portable dialect. Use
?(@.x == "y")with parentheses,[-1:]instead of[-1], and compare numbers to numbers. Save?@,length(), andmatch()for engines you know are RFC 9535. - Check which language your host actually speaks.
$and@.mean JSONPath; backticked literals and no$mean JMESPath; a leading/means JSON Pointer. - Use the right neighbour for the job. Comparing two documents is not a text diff; a log file of one object per line is not a JSON array and needs
jq -sor--slurpbefore any$..will see across records; and if your end goal is a spreadsheet, the JSON flattener gets you path-keyed columns in one step, with the caveats that flattening always carries. For the narrow case of turning a flat object into URL parameters, the JSON ↔ query string converter is the shortest path. And if the reason you’re reading paths is that you’re about to model the document in a typed language, what each converter guesses covers the other half of the job.
The short version: JSONPath answers “which nodes,” jq answers “what should the output be,” and JMESPath answers a bit of both in a grammar that resembles neither. Most of the expressions on the internet were written against a pre-2024 library, so assume a dialect until you’ve run it.