🍱 Lunchbox Hands

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.”

SelectorMeansOn the sample
$The root nodeThe whole document
.regionChild by name (shorthand)"us-east"
['region']Child by name (bracket form)same
[0]Array indexFirst 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]FilterKeeps 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.

JobJSONPathjqJMESPath
Top-level key$.region.regionregion
Field of first element$.services[0].name.services[0].nameservices[0].name
Last element$.services[-1].name.services[-1].nameservices[-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[]
Countnot expressible.services|lengthlength(services)
Rename keysnot expressible.services|map({service:.name, pods:.replicas})services[*].{service: name, pods: replicas}
Sumnot expressible[.services[].replicas]|add → 17sum(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, @uri have 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.

ExpressionRFC 9535jsonpath-plusjsonpath
$.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)].nameparse error["cron"]["cron"]
$.services[?(length(@.ports) == 0)].name["worker"]length is not definedrejected as unsafe

Five separate traps in eight rows:

  1. 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.
  2. ?@ versus ?(@). The RFC’s ABNF is filter-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-plus doesn’t error on the bare form — it returns an empty nodelist, so your query silently matches nothing.
  3. Number-versus-string comparison. RFC 9535 makes cross-type comparison false; it gives 13 == '13' → false as an example. JavaScript-backed engines evaluate the filter with JS semantics, so "2" > 1 is true and @.replicas > "3" matches. A config value like "cpu": "2" will silently participate in numeric filters it has no business in.
  4. There is no truthiness in a filter. A bare @.healthy in RFC 9535 is an existence test, so healthy: false matches — three results, not two. Everything JS-backed reads it as truthiness. jq agrees with the JS libraries here (select(.healthy) drops worker, select(has("healthy")) keeps it), which means the three-way disagreement is not academic.
  5. .length and (@.length-1) are JavaScript leaking through. The document has no member named length; those engines are reading a JS array property. The RFC’s answer is the length() function, which they don’t have — and jsonpath 1.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 the eval-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

  1. 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.
  2. 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.
  3. Write filters in the portable dialect. Use ?(@.x == "y") with parentheses, [-1:] instead of [-1], and compare numbers to numbers. Save ?@, length(), and match() for engines you know are RFC 9535.
  4. Check which language your host actually speaks. $ and @. mean JSONPath; backticked literals and no $ mean JMESPath; a leading / means JSON Pointer.
  5. 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 -s or --slurp before 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.