Skip to main content

How to Test a JSONPath Query Without Writing a Throwaway Script

A practical guide to testing JSONPath queries — the $ root, [*] array iteration, .. recursive descent, filters, and debugging why an API response returns nothing.

Published By Li Lei
#jsonpath #json #api #developer-tools #debugging

How to Test a JSONPath Query Without Writing a Throwaway Script

Every developer who works with JSON APIs eventually hits the same wall: you have a 20 KB response on your screen, you know the value you want is buried somewhere five levels down, and you do not want to write a one-off Node script just to pull it out. JSONPath is the answer — a tiny query language for JSON, the way XPath is for XML. The catch is that JSONPath has just enough syntax quirks that most people write a query, get an empty result, and assume the data is wrong when really the path is wrong.

This guide walks through the syntax that actually matters, then shows you how to debug a real API response when the query returns nothing.

The Five Pieces of Syntax You Actually Use

JSONPath looks intimidating in the spec, but day-to-day work leans on a small handful of symbols.

  • $ is the root. Every JSONPath starts here. $ means "the whole document." If your query does not begin with $, most evaluators reject it outright.
  • . is child access. $.store reaches into the store key. Chain them: $.store.book walks two levels down.
  • [] is array indexing or bracketed names. $.store.book[0] is the first book. $['store']['book'] is the same as $.store.book written the bracket way, which you need when a key has a space or a hyphen in it.
  • [*] is the wildcard — it iterates an array. This is the one people miss. $.store.book[*] means "every element of the book array," and $.store.book[*].author pulls the author field out of each one. The * does the looping for you.
  • .. is recursive descent. $..price finds every price key at any depth, no matter how nested. It is the sledgehammer of JSONPath: powerful, and a little dangerous, because it really does mean every price, including ones you forgot were there.

Two more you will reach for quickly: slices like [0:3] (first three elements, Python-style), and filter expressions like [?(@.price < 10)], where @ refers to the current element being tested. That @ trips up nearly everyone — more on it below.

A Worked Example: Pulling Author Names from a Bookstore

The classic JSONPath teaching dataset is a small bookstore catalog. Picture this shape:

{
  "store": {
    "book": [
      { "category": "reference", "author": "Nigel Rees", "price": 8.95 },
      { "category": "fiction", "author": "Evelyn Waugh", "price": 12.99 },
      { "category": "fiction", "author": "Herman Melville", "price": 8.99 },
      { "category": "fiction", "author": "J. R. R. Tolkien", "price": 22.99 }
    ],
    "bicycle": { "color": "red", "price": 19.95 }
  }
}

Say you want every author. The query is:

$.store.book[*].author

Read it left to right: start at the root ($), step into store, step into book, iterate every element ([*]), and from each one grab author. The result is a clean array:

["Nigel Rees", "Evelyn Waugh", "Herman Melville", "J. R. R. Tolkien"]

Now contrast two price queries. $.store.book[*].price returns only the four book prices. But $..price returns five values — the four books and the bicycle, because recursive descent reaches the bicycle.price you might have forgotten was sitting alongside the array. That difference between scoped iteration and recursive descent is the single most useful thing to internalize, and the fastest way to see it is to paste this JSON into the JSONPath query tester and watch the matched values update as you switch between the two queries.

Filters: Where Most Queries Quietly Break

Filters are where JSONPath earns its keep and where empty results come from. To find books cheaper than 10:

$.store.book[?(@.price < 10)]

The @ inside the brackets is the current array element. So @.price means "this book's price." Leave off the @. and write [?(price < 10)], and the parser cannot resolve price — the filter context is the element, not the root, and there is no bare variable called price.

Three things break filters more than anything else:

  1. Missing @. — the error above. The filter sees each element as @, full stop.
  2. String vs. number comparison. If the JSON stores "price": "8.95" with quotes, then @.price < 10 is comparing a string to a number and matches nothing. Numbers compare numerically; strings do not.
  3. Case sensitivity. @.Price and @.price are different keys. JSON is case-sensitive, and so is every JSONPath field reference.

When a filter returns zero matches, run $..price first. If that lists the values, you know the field exists and where it lives, and you can narrow back down from there.

Debugging a Real API Response

Here is the loop I run constantly. A teammate pings me: "the webhook says the beta flag is on for every tenant, can you confirm?" I have the production dump in front of me — a few hundred tenant objects, each with a nested flags object.

Instead of opening a database client, I paste the dump into the tester and type:

$.tenants[?(@.flags.beta == true)].id

The match count is the answer. If 312 tenants are in the dump and the query returns 312 ids, the rollout is complete. If it returns 309, the path list shows me exactly which three are missing, and I can read off their ids without writing a single line of SQL. The whole thing takes under a minute, and because the query lives in the URL, I can paste the link straight back into chat so the teammate sees the same result without re-deriving the path. That habit — query in the address bar, JSON stays local — has quietly replaced a lot of curl | jq one-liners in my workflow.

The reason this works is that the tester evaluates everything client-side: the JSON, the parsed syntax tree, the matching, all of it stays in the browser tab. Production responses, internal ids, tokens — none of it leaves your machine, which is exactly what you want when you are debugging real payloads.

JSONPath vs. jq vs. JMESPath

JSONPath is not the only JSON query language, and knowing when to switch saves time.

  • jq is the terminal workhorse. Once your query is right, translating $.store.book[*].author to jq '.store.book[].author' lets you pipe a live curl response straight through it. jq also has functions JSONPath lacks — length, map, group_by. Prototype the path interactively, then take it to the shell.
  • JMESPath is what AWS CLI uses for --query. It drops the $ and @ sigils and uses backticks for literals: store.book[?price < + "10" + ]. Before you run a --query against prod, test it on a sample response so you are not burning real API calls on syntax fixes.

Once you have your matched values, the next step is usually to clean up or reshape the JSON. If your raw payload is minified or malformed, run it through the JSON formatter first so you can read the structure before you write the path — a well-indented document makes the levels you need to traverse obvious at a glance.

Wrapping Up

JSONPath rewards a small mental model: $ is the root, . walks into children, [*] iterates an array, .. recurses to any depth, and [?(@.field ...)] filters with @ as the current element. Most "the query returns nothing" frustration comes from three predictable causes — a missing @., a string-versus-number mismatch, or a casing typo. Test the path live, watch the matched values, and you stop guessing.


Made by Toolora · Updated 2026-06-13