Skip to content
devtoolbox.cc

What is JSONPath and how to use it?

· Updated · 14 min read

Open the JSONPath tool

Say a payment provider sends you a 3 MB webhook, and you need one thing from it: the emails of customers whose orders were refunded. The usual route is a nested loop, or a .filter().map().flat() chain, and then the same again for the next question, and the one after that.

The same question fits in one line:

$.orders[?@.status == 'refunded'].customer.email

That line is JSONPath. This article explains what it is, walks through the whole syntax with examples you can run, and lists the pitfalls that tend to show up once queries hit production data. If you want to follow along, open the JSONPath tester in another tab: every query below works there, and your data never leaves the browser.

TL;DR

  • JSONPath is a query language for JSON, the way XPath is for XML. A query such as $.orders[*].id selects values from a document.
  • It has been an official standard since February 2024: RFC 9535. Before that, every library had its own dialect.
  • The building blocks are $ (the root), .name or ['name'] (a child), [0] (an array index), [*] (all children), .. (search at any depth), slices such as [0:2] and filters such as [?@.price < 10].
  • The result is always a list of matches, even if there is only one match or none at all.

What is JSONPath, exactly?

JSONPath is a small language for addressing parts of a JSON document. You write a path expression, run it against a document, and get back the matching values (the RFC calls them nodes).

The idea comes from a 2007 article by Stefan Gössner, who wanted “XPath for JSON”. The idea caught on and got implemented in Java, JavaScript, Python, Go and many more languages. But there was no spec, so each library filled in the gaps differently. The same query could return different results in Postman, in a Java service and in a Python script.

In 2024 the IETF published RFC 9535. It pins down the grammar, what filters may do, how comparisons work, and which parts of the result order are fixed and which are left open. In this article, “JSONPath” means RFC 9535. Our online JSONPath tester implements it and passes the official compliance test suite.

Where you’ll meet JSONPath in real life:

  • API testing: Spring’s MockMvc jsonPath(…) assertions, Karate, JMeter’s JSON Extractor and SoapUI.
  • Configuration and infrastructure: kubectl -o jsonpath and AWS Step Functions (InputPath, ResultPath) use it or a close relative.
  • Data pipelines and low-code tools, which extract fields from webhook payloads without writing code.
  • Your own code, when you want the “what to extract” part to be configurable instead of hard-coded.

The sample document

All examples use this small order export from an imaginary coffee shop. Paste it into the JSONPath tester and try the queries as you read. If your own data arrives minified on one line, run it through the JSON formatter first so you can see its shape.

{
  "store": "Brew & Bytes",
  "orders": [
    {
      "id": 1001,
      "status": "paid",
      "total": 12.5,
      "customer": { "name": "Anna", "email": "anna@example.com" },
      "items": [
        { "sku": "ESP", "qty": 2 },
        { "sku": "CRS", "qty": 1 }
      ]
    },
    {
      "id": 1002,
      "status": "refunded",
      "total": 4.2,
      "customer": { "name": "Boris", "email": "boris@example.com" },
      "items": [{ "sku": "LAT", "qty": 1 }]
    },
    {
      "id": 1003,
      "status": "paid",
      "total": 31,
      "coupon": "AUTUMN10",
      "customer": { "name": "Chen" },
      "items": [
        { "sku": "BNS", "qty": 3 },
        { "sku": "ESP", "qty": 1 }
      ]
    }
  ]
}

The basics: root, children and arrays

Every query starts with $, the root of the document. From there you step down one level at a time.

Query What it means Result
$.store The store member of the root object ["Brew & Bytes"]
$.orders[0].id The id of the first order [1001]
$.orders[-1].id Negative indexes count from the end [1003]
$['orders'][1]['status'] Bracket notation, same as .orders[1].status ["refunded"]
$.orders[*].id * is a wildcard: every element of the array [1001,1002,1003]
$.orders[0,2].customer.name Several selectors at once (a union) ["Anna","Chen"]

Dot notation (.status) is shorter. Bracket notation (['status']) works for any key, including keys with spaces, dots or dashes, such as $['user-agent'] or $['a.b']. When in doubt, use brackets.

A wildcard also works on objects: $.orders[0].* returns all values of the first order (its id, status, total, customer and items). Unlike array elements, those values don’t come in a guaranteed order. The next section explains why.

Result order: what the standard guarantees

JSON objects are unordered, and RFC 9535 takes that literally. When a wildcard or a filter selects the members of an object, “the order in which the children of an object appear in the resultant nodelist is not stipulated” (section 2.3.2.2, and the same wording for filters in section 2.3.5.2). Array elements are different: they always come out in array order.

Query What it means Result
$.orders[0].customer.* Wildcard on an object ["Anna","anna@example.com"] in any order
$.orders[*].id Wildcard on an array [1001,1002,1003]
$.orders[0].customer['email','name'] An explicit list of member names ["anna@example.com","Anna"]
$.orders[2,0].id An explicit list of indexes [1003,1001]

The first query has two correct answers, ["Anna","anna@example.com"] and ["anna@example.com","Anna"], and a library that returns either one conforms to the standard. The second always returns [1001,1002,1003].

A list of selectors in brackets is a separate case. The results come in the order you wrote the selectors (section 2.5.1.2), so ['email','name'] gives the email first even though the object lists name first, and [2,0] gives order 1003 before 1001. That order comes from your query, not from the object. If the list contains a wildcard, as in [*,'name'], the wildcard’s results still come before the name’s, but among themselves they follow the object rule.

Recursive descent (.., covered below) is in between. The RFC requires array elements to be visited in array order and every node to be visited before its descendants (section 2.5.2.2). It doesn’t fix the order of object members or how different branches interleave. Its own examples list two correct results for $..j.

How our tester behaves: the JSONPath tester returns object members in the order they appear in your JSON text, and walks .. depth-first. Paste {"b": 1, "a": 2} and run $.*: you get [1,2] every time, not the alphabetical [2,1]. That’s repeatable in this tester, but it’s a property of this implementation, not something RFC 9535 promises. A library that keeps objects in a hash map can return [2,1] and still conform.

So when you assert on a query that can reach object members or uses .., compare the results as a multiset: the same values, each the same number of times, in any order. Don’t turn them into a set. $..sku below returns "ESP" twice because two orders contain an espresso, and a set comparison would hide a missing one.

Slices: taking a range of an array

Slices work like Python’s: [start:end:step], where end is excluded and every part is optional.

Query What it means Result
$.orders[0:2].id Elements 0 and 1 (2 is excluded) [1001,1002]
$.orders[::2].id Every second element [1001,1003]
$.orders[::-1].id A negative step walks backwards [1003,1002,1001]

Out-of-range slice bounds are simply clamped, and an index that doesn’t exist is not an error either: $.orders[5] returns an empty list. Keep that in mind when you write assertions: “no match” and “the field is null” are two different results.

Recursive descent: .. finds things at any depth

Two dots mean “this node and all its descendants”. It’s the JSONPath equivalent of “search everywhere below here”.

Query What it means Result
$..sku Every sku, however deep ["ESP","CRS","LAT","BNS","ESP"] in any order
$..email Every email; Chen has none, so only two ["anna@example.com","boris@example.com"] in any order

Recursive descent is handy for exploring an unfamiliar API response. In production code, explicit paths are safer. $..id will happily match an id you didn’t expect, in a nested object that some developer adds next year.

Filters: the part that makes JSONPath worth learning

A filter selector [?…] keeps only the children for which a condition is true. Inside the condition, @ is the current element and $ is still the root.

Query What it means Result
$.orders[?@.status == 'paid'].id Orders with status “paid” [1001,1003]
$.orders[?@.total > 10].customer.name Customers who spent more than 10 ["Anna","Chen"]
$.orders[?@.coupon].id Orders that have a coupon field [1003]
$.orders[?!@.customer.email].id Orders whose customer has no email [1003]
$.orders[?@.status == 'paid' && @.total < 20].id Both conditions (AND) [1001]
$.orders[?@.status == 'refunded' || @.total > 30].id Either condition (OR) [1002,1003]
$.orders[?@.total > $.orders[0].total].id Compare with another part of the document [1003]
$.orders[?@.items[?@.sku == 'ESP']].id Nested filter: orders containing an espresso [1001,1003]
$.orders[*].items[?@.qty >= 2].sku Line items with quantity 2 or more ["ESP","BNS"]

A few things to notice:

  • An existence test (?@.coupon) is true when the field exists, even if its value is false or null. To check the value, compare it explicitly: ?@.coupon == null.
  • Comparison operators are ==, !=, <, <=, > and >=. You combine them with &&, ||, ! and parentheses.
  • Strings use single or double quotes. Older articles show filters as [?(@.price < 10)]. The parentheses are still allowed, but RFC 9535 doesn’t require them.

The RFC also defines five functions you can use inside filters:

Query What it does Result
$.orders[?length(@.customer.name) == 4].customer.name length() of a string, array or object ["Anna","Chen"]
$.orders[?count(@.items[*]) > 1].id count() of the nodes a query selects [1001,1003]
$.orders[?match(@.customer.name, 'B.*')].id match(): the whole string matches a regex [1002]
$.orders[?search(@.customer.email, 'example')].id search(): the regex is found anywhere [1001,1002]
$.orders[?value(@..sku) == 'LAT'].id value() turns a single-node result into a value [1002]

Common JSONPath pitfalls

These are the mistakes that pass a quick test and then return the wrong result on real data.

1. count(@.items) is not the array length

A natural first attempt at “orders with more than one item” is count(@.items) > 1. It returns nothing. The reason: count() counts nodes, and @.items selects exactly one node, the array itself. You want either length(@.items) > 1 or count(@.items[*]) > 1:

Query What it means Result
$.orders[?count(@.items) > 1].id One node (the array): never > 1 []
$.orders[?length(@.items) > 1].id Number of array elements [1001,1003]

2. Types are compared strictly

'31' is a string and 31 is a number, and JSONPath doesn’t convert between them. If an API sends numbers as strings (many do, especially for money), your filter silently matches nothing:

Query What it means Result
$.orders[?@.total == '31'].id Number compared with a string []
$.orders[?@.total == 31].id Number compared with a number [1003]

3. match() is anchored, search() is not

match(@.status, 'pa') means “the entire string is pa”, so it doesn’t match "paid". search() looks for the pattern anywhere, like grep:

Query What it means Result
$.orders[?match(@.status, 'pa')].id Whole value must be “pa” []
$.orders[?search(@.status, 'pa')].id “pa” appears somewhere [1001,1003]

4. There is no .length property

In JavaScript you’d write orders.length. In JSONPath, $.orders.length looks for a member named “length”, finds none and returns []. Use length() inside a filter, or just count the results.

5. Script expressions are gone, and that’s a good thing

Old tutorials are full of queries like $.orders[(@.length-1)]. These are script expressions, and many libraries implemented them with eval(). That’s exactly how CVE-2024-21534 happened: a remote-code-execution bug in jsonpath-plus, one of the most popular JavaScript JSONPath packages. RFC 9535 has no script expressions. Use $.orders[-1] for the last element and filters for everything else. Our tester points this out when you paste such a query, instead of just saying “syntax error”.

6. Big numbers and float precision

JSON allows integers of any size, but a JavaScript number is exact only up to 2^53. An order id like 9007199254740993 silently becomes …992 in many JS-based tools, and a filter comparing ids gives the wrong answer. The devtoolbox JSONPath tester keeps numbers exactly as written and compares them exactly. Most libraries don’t, so test with realistic ids.

7. Libraries still disagree

Even in 2026, many libraries implement the pre-RFC “Goessner dialect”. The usual differences are:

  • the order of results from .. and from wildcards on objects (often treated as fixed, though the RFC leaves it open)
  • whether a filter can be applied to an object’s members
  • regex flavours
  • whether a missing result is [], false or an exception

When a query behaves strangely in your language, check whether the library claims RFC 9535 support.

JSONPath vs jq vs JMESPath vs JSON Pointer

JSONPath is not the only way to query JSON. Here is how they compare:

Tool Best for Standard Can transform data?
JSONPath Selecting values; assertions in tests and configs RFC 9535 (2024) No, only selects
JSON Pointer Pointing at exactly one known location (/orders/0/id) RFC 6901 No
JMESPath Selecting and reshaping (AWS CLI --query) Its own spec Yes (projections)
jq Command-line processing and full transformations De facto (the jq tool) Yes (a full language)

A rule of thumb:

  • Pick JSON Pointer when you know the exact location, as in JSON Patch or $ref in JSON Schema.
  • Pick JSONPath when you need “find all X where Y”, especially inside tools that already support it.
  • Pick jq when you are in a terminal and want to reshape data, not just pick it.

Using JSONPath in code

A few popular options (check the docs of the version you use):

// JavaScript: jsonpath-plus (keep it up to date, see the CVE above)
import { JSONPath } from 'jsonpath-plus';
const emails = JSONPath({ path: "$.orders[?(@.status == 'refunded')].customer.email", json: data });
# Python: python-jsonpath (RFC 9535 syntax)
import jsonpath
emails = jsonpath.findall("$.orders[?@.status == 'refunded'].customer.email", data)
// Java: Jayway JsonPath
List<String> emails = JsonPath.read(json, "$.orders[?(@.status == 'refunded')].customer.email");
# Kubernetes: kubectl has its own JSONPath-style templates in curly braces
kubectl get pods -o jsonpath='{.items[*].metadata.name}'

Notice that the filter syntax is slightly different in each one. That’s the dialect problem from trap 7 in real life. Prototype the query in an RFC 9535 tester first, then adapt it to your library if needed.

Cheat sheet

Syntax Meaning
$ Root of the document
@ Current element (inside filters)
.name, ['name'] Child member
[0], [-1] Array element, from start or end
[*], .* All children
..name, ..* Descendants at any depth
[1:5:2] Slice: start, end (excluded), step
[0,2] Union of selectors
[?@.x > 1] Filter
&&, ||, ! Logical operators in filters
length(), count(), match(), search(), value() Standard functions

Wrapping up

JSONPath is one of those tools that takes an hour to learn and then saves you a little time every week, for years. It’s a tiny language: roots, children, wildcards, slices, recursive descent and filters. Since RFC 9535 it finally behaves the same everywhere that follows the standard.

The best way to learn it is to play. Open the JSONPath tester, paste an API response you work with every day, and try to answer three questions about it with a single query each. You’ll be surprised how often a nested loop turns into one line.

← All posts