JSONPath started as a blog post. Stefan Goessner published it in February 2007 as a way to do for JSON what XPath does for XML, sketched the grammar in a page of prose, and shipped a 300 line JavaScript reference implementation. Ports appeared in Java, Python, Go, PHP, C# and half a dozen other languages, each one filling the gaps in the sketch differently. Seventeen years later, in February 2024, RFC 9535 turned the sketch into a real specification. Most of the tooling you will meet today still runs the older dialect, which is why the same expression sometimes returns different answers in two systems.
What the three panels do
The bar at the top holds one expression. Every keystroke re-runs the query against the parsed document after a short pause, so the match count beside the Run button moves as you type. A green count means the engine found nodes. An amber count means the expression parsed cleanly and matched nothing, which is a different situation from a broken query and gets reported as such.
The left pane holds the document in two views. The tree is the interesting one. Every matched node gets an amber row, ancestors of a match open themselves so you never hunt through a collapsed branch for a hit, and clicking any key writes that node's exact path into the expression bar. Building a query by clicking down through real data beats guessing at bracket syntax, particularly when keys carry dots or spaces in them. Raw view is a plain editable textarea. Paste into either one.
The right pane has three formats, and the second is the one most testers leave out:
- Values gives the matched nodes as a JSON array, in document order. This is what a library returns to your code.
- Paths gives the normalized path to each match, one per line. A wildcard or a filter tells you nothing about where a value came from, so when you need to write a value back, log which record failed, or hand a concrete path to another system, this is the output you want.
- Path & value pairs the two, which reads best while you are still working out whether a filter caught the right rows.
Everything runs in the browser. The document never leaves the page, so pasting a production response with real customer records carries no upload risk.
The syntax, one operator at a time
An expression is a chain of steps applied left to right. Each step takes the set of nodes produced by the step before it and returns a new set. Start with the root, narrow as you go.
| Operator | Means | Against the sample document |
|---|---|---|
| $ | The root node, where every expression starts | The whole payload as a single match |
| .key | One named child | $.region returns eu-west-1 |
| ['key'] | The same child in bracket form, needed when a key has a dot, a space or a dash in it | $['build']['commit'] |
| * | Every child of the current node | $.limits.* returns the three numbers |
| ..key | Recursive descent, matching the key at any depth | $..owner reaches into every endpoint |
| [n] | One array element by index, counting from zero | $.hosts[0] |
| [a,b] | A union of two or more indexes or names | In the grammar since 2007, rejected outright by the engine behind this page. See the disagreements below |
| [start:end] | A slice, with end excluded, the same rule Python uses | $.endpoints[1:3] gives elements 1 and 2 |
| [-1:] | A slice counting back from the end | $.hosts[-1:] gives the last host |
| [?(@.x > 1)] | A filter, keeping array elements where the test holds | $.endpoints[?(@.p95Ms > 100)] |
The two dot operator is the one people underuse. $..price finds every price in a document without you knowing the shape, which is exactly the job when a vendor sends back four levels of wrapping around the field you wanted. The cost is speed on large documents, since recursive descent visits every node, and ambiguity when the same key name means two different things in two branches.
Filters, and the symbol that trips people up
Inside [?( )] the at sign stands for the element being tested, the way $ stands for the root. So $.endpoints[?(@.p95Ms > 100)] reads as: walk the endpoints array, and for each element, keep it when its p95Ms field is over 100. The filter returns whole elements, not fields. Add a step after the bracket to pull a field out of the survivors.
Comparisons behave as you would expect for numbers and strings. Equality is a double equals in most implementations, though a few accept a single one. A bare [?(@.isbn)] with no comparison tests existence, which is the shortest way to split records that carry an optional field from records that do not.
| Filter | What comes back |
|---|---|
| [?(@.deprecated == true)] | The legacy endpoint, the only one with the flag set |
| [?(@.owner)] | Endpoints with an owner, skipping the one where owner is null |
| [?(@.method == 'POST')] | String comparison, single quotes inside the filter |
| [?(@.errorRate > 0.01 && @.p95Ms > 200)] | Two conditions joined, supported by most engines but not all |
| [?(@.tags.length > 1)] | A property lookup inside the filter, and a common portability trap |
Null is where filters catch people out. In the sample document one endpoint has "owner": null. A test for existence treats that as absent in most engines, since null is falsy, so $.endpoints[?(@.owner)] returns three endpoints rather than four. If you meant "the key is present", you need [?(@.owner != null)] or an engine that separates presence from truthiness. RFC 9535 draws that line clearly. The older implementations mostly do not.
Where implementations disagree
This matters more than the syntax table, because an expression verified in one tool is not portable by default. The gaps below are the ones that cost real debugging time.
- Result ordering after recursive descent. Goessner's post never fixed an order for $..key. Some engines return document order, others return a breadth first walk. Code taking the first element of the result is quietly depending on whichever library shipped in that project.
- Index unions.$.endpoints[0,3] is in the original grammar and is answered correctly by Jayway and jsonpath-ng. The JavaScript engine on this page raises an error on it instead. Type it into the bar above and read the failure for yourself. When a query has to run in both places, filter on a field or write two expressions.
- Negative indexes and steps.$.hosts[-1] returns the last element in several engines and nothing at all in this one, while the slice form $.hosts[-1:] works everywhere. A reversing slice, [::-1], works here and in the Python port and returns nothing in some other JavaScript libraries.
- The length property.$.hosts.length works in engines that fall through to the host language's own property lookup and fails in engines treating length as an ordinary key name. The RFC settles on a length() function instead, so both older spellings are worth avoiding in code you plan to keep.
- Script expressions. The original grammar allows arbitrary code inside [( )], so $.hosts[(@.length-1)] is valid JSONPath in the 2007 sense. Running user supplied JavaScript to answer a query is a security hole, so most maintained libraries dropped the feature and the RFC leaves it out entirely.
- Filters over objects. Filtering an array is universal. Filtering the values of an object with $.limits[?(@>100)] works in some engines and is rejected by others.
- Roots that are not objects. A document whose top level is a bare array or a single string is fine for some parsers and a hard error for others.
The practical rule: verify an expression against the engine you will actually deploy on, not against a generic tester. This page runs the dchester JavaScript implementation, which is the one behind a great many Node tools and Postman scripts. An expression working here has a good chance of working in jsonpath-ng or Jayway, and no guarantee.
Where you meet JSONPath in real systems
The language shows up in more places than its low profile suggests, usually as the glue between two systems nobody wants to write code between.
| System | How it uses JSONPath |
|---|---|
| kubectl | -o jsonpath pulls fields out of resource objects. Its dialect is its own: expressions sit inside curly braces, it adds a range construct, and filter support is thinner than the reference grammar |
| AWS Step Functions | InputPath, ResultPath and OutputPath route data between states with JSONPath. Getting a path wrong here fails the execution rather than returning empty |
| Postman and Newman | Test assertions read response fields by path, and the runner ships the same JavaScript engine this page uses |
| Grafana and Splunk | JSON data sources map a path to a series or a field before anything gets plotted |
| Azure Logic Apps | Trigger conditions and data operations accept JSONPath expressions against webhook bodies |
| Karate and REST Assured | API test frameworks assert on response bodies by path, Jayway underneath in the Java case |
Because each of those carries its own dialect, use the Paths tab here as a portability check. If a query returns the paths you expected, those literal paths are usually accepted by every engine, even where the clever expression that produced them is not.
JSONPath, JSON Pointer and jq are three different things
They get confused constantly, and picking the wrong one wastes an afternoon.
JSON Pointer is RFC 6901, a much older and much smaller standard. A pointer looks like /endpoints/0/path and addresses exactly one location. No wildcards, no filters, no recursion. That limitation is the point: a pointer is unambiguous, which is why JSON Patch, JSON Schema errors and OpenAPI references all use pointers rather than paths. Reach for a pointer when you need to name one node. Reach for JSONPath when you need to search.
jq is a full stream processing language with its own syntax, and reads similarly at first glance. .endpoints[].path in jq resembles $.endpoints[*].path in JSONPath, then jq keeps going into map, reduce, string interpolation and user defined functions. JSONPath selects. jq selects and transforms. If your expression is growing a pipe, you have outgrown JSONPath.
JMESPath sits between the two and powers the AWS CLI's --query flag. Its syntax is close enough to JSONPath to mislead and different enough to break, most visibly in filters, which JMESPath writes as [?p95Ms > `100`] with backticks and no at sign.
Writing expressions that survive contact with real data
- Prefer explicit paths over recursive descent in production code.$..id is fine at a terminal and a liability in a parser, because the day someone adds an id field one level up, your extraction silently doubles in size.
- Use bracket notation for anything unusual. Keys with dots, dashes, spaces, digits at the front or non-ASCII characters need $['user-id']. Dot notation on those either fails or, worse, parses as two steps.
- Remember a filter returns elements. Chaining a field access after the closing bracket is what turns a filtered set of records into a list of values.
- Treat an empty result as a real case. Most libraries return an empty array rather than raising, so code doing result[0] hands you undefined instead of an error message. Check the length.
- Watch numeric strings. A field holding "42" as a string does not match [?(@.n == 42)] in strict engines and does match in loose ones. Normalize the data or quote the literal.
$.endpoints[?(@.errorRate > 0.01)].path // the paths that need attention$..endpoints[*].owner // every owner, wherever endpoints appear$['limits']['requestsPerMinute'] // one node, unambiguous, safe to store
Where this page stops
The document has to be a single valid JSON value. NDJSON, JSON with comments and trailing commas are rejected by the parser, so run those through the JSON fixer first. The tree stops drawing at 2500 rows to keep scrolling smooth, though the expression still runs against the whole document and the result panel stays complete. Documents past a few megabytes will feel slow, since parsing and rendering both happen on the main thread. Script expressions in the [( )] form still evaluate here, because the engine carries its own evaluator, and RFC 9535 drops them, so an expression leaning on one will not travel. Index unions such as [0,3] go the other way and fail here despite being legal JSONPath. Results follow the dchester JavaScript engine throughout, so the disagreements listed above apply: treat this as a fast way to work out what you meant, then confirm against the engine you ship on.
