What JSONPath is
JSONPath is a query language for picking values out of a JSON document. $.store.books[0].title reads like a path in code, but JSONPath also has wildcards, slices and filters, so one expression can pull the same field out of every element of an array.
You meet it in Postman tests, kubectl -o jsonpath, AWS Step Functions, API gateways and many low-code integration tools. For years every implementation worked from Stefan Goessner's 2007 article and filled its gaps differently. In February 2024 the IETF published RFC 9535, which standardized the syntax for the first time.
This cheat sheet uses RFC 9535 syntax. Every result shown was produced by running the expression against the sample document below, not written by hand.
The sample document
Every example runs against this order response. It has nested arrays, a field that only some orders have (giftMessage), an empty array, and two keys that break dot notation (x-request-id and rate.limit).
{
"store": "Northwind Books",
"orders": [
{
"id": "ord_1001",
"status": "shipped",
"total": 249.99,
"items": [{ "sku": "KB-204", "qty": 1 }, { "sku": "MS-310", "qty": 2 }]
},
{
"id": "ord_1002",
"status": "pending",
"total": 15.5,
"giftMessage": "Happy birthday",
"items": [{ "sku": "CB-001", "qty": 3 }]
},
{
"id": "ord_1003",
"status": "shipped",
"total": 89,
"items": []
}
],
"meta": { "page": 1, "x-request-id": "req_7f3a9c21", "rate.limit": 100 }
}
Results come from jsonpath-rfc9535 2.0.0, a Python implementation of the standard. Where the popular JavaScript library jsonpath-plus 10.4.0 gives a different answer, the section on disagreements says so.
The cheat sheet
| Expression | What it selects | Result |
|---|---|---|
$ | The root: the whole document | The whole document |
$.store | A member by name | ["Northwind Books"] |
$.orders[0].id | An element by index, counting from 0 | ["ord_1001"] |
$.orders[-1].id | An index counted from the end | ["ord_1003"] |
$.orders[0:2].id | A slice: from 0 up to, but not including, 2 | ["ord_1001", "ord_1002"] |
$.orders[::-1].id | A slice with step -1: every element, reversed | ["ord_1003", "ord_1002", "ord_1001"] |
$.orders[0,2].id | Several indexes at once | ["ord_1001", "ord_1003"] |
$.orders[*].id | Wildcard: every element of an array | ["ord_1001", "ord_1002", "ord_1003"] |
$.meta.* | Wildcard on an object: every value | [1, "req_7f3a9c21", 100] |
$..sku | Descendants: sku at any depth | ["KB-204", "MS-310", "CB-001"] |
$.orders[*].items[*].sku | The same three, by an explicit route | ["KB-204", "MS-310", "CB-001"] |
Two rules explain most surprises:
- A query always returns a list. One match is a list of one. No match is an empty list, not an error, so a typo in a field name fails silently.
- Object order is not promised. The standard does not fix the order of
$.meta.*results. Arrays keep their order.
Filters
A filter keeps the elements for which a condition is true. Inside it, @ is the element being tested.
| Expression | Keeps | Result |
|---|---|---|
$.orders[?@.status == 'shipped'].id | Orders whose status equals a value | ["ord_1001", "ord_1003"] |
$.orders[?@.total > 50].id | Orders above a number | ["ord_1001", "ord_1003"] |
$.orders[?@.giftMessage].id | Orders that have the field at all | ["ord_1002"] |
$.orders[?!@.giftMessage].id | Orders that do not have it | ["ord_1001", "ord_1003"] |
$.orders[?@.status == 'shipped' && @.total < 100].id | Both conditions | ["ord_1003"] |
$.orders[?@.status == 'pending' || @.total > 200].id | Either condition | ["ord_1001", "ord_1002"] |
$..items[?@.qty > 1].sku | Matching elements at any depth | ["MS-310", "CB-001"] |
RFC 9535 also defines five functions for use in filters:
| Expression | Keeps | Result |
|---|---|---|
$.orders[?length(@.items) == 0].id | Orders with an empty items array | ["ord_1003"] |
$.orders[?count(@.items[*]) >= 2].id | Orders with two or more items | ["ord_1001"] |
$.orders[?match(@.id, 'ord_100[12]')].id | IDs matching a regular expression in full | ["ord_1001", "ord_1002"] |
$.orders[?search(@.giftMessage, 'birthday')].id | Strings containing a match anywhere | ["ord_1002"] |
The fifth, value(), turns a single-node result into a plain value for comparisons.
Three details catch people out:
- Equality is
==. A single=is a syntax error:unexpected '=', did you mean '=='? ?@.giftMessagetests that the field exists, not that it is truthy. Under the standard, a field whose value isnull,falseor0still passes. To find real nulls, write?@.giftMessage == null.match()must match the whole string, whilesearch()finds a match anywhere.match(@.id, 'ord')would match nothing here;search(@.id, 'ord')matches all three.
Bracket notation for awkward keys
Dot notation is shorthand for names made of letters, digits and underscores. Any other key needs brackets and quotes, single or double:
| Expression | Result |
|---|---|
$['meta']['x-request-id'] | ["req_7f3a9c21"] |
$.meta["x-request-id"] | ["req_7f3a9c21"] |
$.meta['rate.limit'] | [100] |
$.meta.rate.limit | [] |
The last row is the dangerous one. It looks for a limit key inside a rate object, finds nothing, and returns an empty list without any error. Whenever a key contains a dot, a space or a hyphen, use brackets.
Some libraries, including both tested here, also accept $.meta.x-request-id. The standard's grammar does not allow a hyphen in dot notation, so another tool may reject it. Brackets work everywhere.
Brackets are also the canonical form. RFC 9535 defines a normalized path for every match, written entirely in brackets. Asking the Python library where $..sku matched returns:
$['orders'][0]['items'][0]['sku']
$['orders'][0]['items'][1]['sku']
$['orders'][1]['items'][0]['sku']
JSONPath, jq and JSON Pointer side by side
Three notations address values inside JSON, and each tool you use picks one of them. Here are the same tasks in all three, tested with jq 1.7:
| Task | JSONPath | jq | JSON Pointer |
|---|---|---|---|
| First order's ID | $.orders[0].id | .orders[0].id | /orders/0/id |
| Every order ID | $.orders[*].id | [.orders[].id] | Not possible |
| Shipped order IDs | $.orders[?@.status == 'shipped'].id | [.orders[] | select(.status == "shipped") | .id] | Not possible |
Every sku, at any depth | $..sku | [.. | .sku? // empty] | Not possible |
| A key with a hyphen | $.meta['x-request-id'] | .meta["x-request-id"] | /meta/x-request-id |
Which to use depends on the job:
- JSONPath selects values. It is what Postman,
kubectl, API gateways and integration platforms expect. - jq selects and also reshapes.
{shipped: [.orders[] | select(.status == "shipped") | .id], revenue: ([.orders[].total] | add)}builds a new object,{"shipped":["ord_1001","ord_1003"],"revenue":354.49}, which JSONPath cannot do. - JSON Pointer (RFC 6901) points at exactly one value, with no wildcards or filters. JSON Patch documents, JSON Schema
$refand OpenAPI references all use it. In a key,/is written~1and~is written~0.
Where implementations disagree
The standard is new, and many libraries predate it. Running the same expressions through jsonpath-rfc9535 and jsonpath-plus turned up these differences:
| Expression | RFC 9535 | jsonpath-plus 10.4.0 |
|---|---|---|
$.orders[?@.status == 'shipped'].id | ["ord_1001", "ord_1003"] | [] |
$.orders[?(@.total > 50)].id | ["ord_1001", "ord_1003"] | ["ord_1001", "ord_1003"] |
$.orders[-1].id | ["ord_1003"] | [] (use [-1:]) |
$.orders[::-1].id | Reversed list | [] |
$.orders.length | [] | [3] |
$.orders[?(@.items.length == 0)].id | [] | ["ord_1003"] |
$[?@.a] on [{"a": null}, {"a": false}, {"a": 0}, {}] | The first three | [] |
Notice that none of these is an error. Every disagreement shows up as an empty or different list, which is easy to miss.
The patterns behind them:
- Filter syntax. Older libraries need the filter wrapped in parentheses,
?(...). The standard does not, but it accepts them, so the parenthesized form is the more portable one. - JavaScript leaking through.
jsonpath-plusevaluates filters as JavaScript, so.lengthworks and existence tests follow JavaScript truthiness. The standard haslength()instead, and tests whether a field exists, whatever its value. - Indexes and slices. Negative indexes and negative steps are standard, but not universal.
Tools also wrap JSONPath in their own syntax. kubectl, for example, drops the $ and uses braces: kubectl get pods -o jsonpath='{.items[*].metadata.name}'.
The rule that follows: test an expression on the engine you will actually run it on, with a document where you know the right answer.
Generating expressions instead of writing them
Writing the first expression is the slow part, because you need to know the document's shape. The JSONPath Generator on JSON Keyper starts from the document instead. Paste it, tick Collapse array indices, and you get an expression for every field. For the sample above:
$.store
$.orders
$.orders[*].id
$.orders[*].status
$.orders[*].total
$.orders[*].items
$.orders[*].items[*].sku
$.orders[*].items[*].qty
$.orders[*].giftMessage
$.meta
$.meta.page
$.meta['x-request-id']
$.meta['rate.limit']
Keys like x-request-id and rate.limit come out in brackets, so the paths work in strict engines too. Pick the path you need, then add a filter by hand where you want one. The document is processed in your browser and never uploaded.
Generate JSONPath for Every Field
Paste a JSON document and get a JSONPath expression for every field, with awkward keys already in bracket notation. Free, in your browser, no login.
Open the JSONPath Generator