JSON Toolkit

JSONPath Filters in Depth: Recursion, Slices, and Multi-Condition Matching

Bottom line: Recursive descent, slices and multi-condition filters are where JSONPath gets powerful and misused. Covers filter semantics, result shapes and library differences.

Intermediate~10 min readPublished 2026-10-31Updated 2026-10-31
Cover for JSONPath Filters in Depth

Bottom line: locate a known depth first, then add slices and filters instead of reaching for $..; a bare path in a filter only tests existence, so test a boolean with @.stock == true.

Anyone can write $.store.book[0].title. What actually breaks JSONPath is combining $.. with slices and multiple conditions, then getting too many results, too few results, or an order you did not expect.

1. A Sample Document

{
  "store": {
    "book": [
      { "title": "JSON: The Definitive Guide", "price": 8.95, "stock": true, "tags": ["json", "spec"] },
      { "title": "A History of Data Formats", "price": 12.99, "stock": false, "tags": ["history"] },
      { "title": "How Parsers Work", "price": 8.99, "stock": true, "tags": ["parser", "json"] },
      { "title": "Streaming JSON in Practice", "price": 9.5, "stock": true, "tags": [] }
    ],
    "bicycle": { "color": "red", "price": 19.95 }
  },
  "updatedAt": "2026-10-31"
}

Every expression below uses this document as input. Keep two premises in mind: the result is a node set, and its order follows the document order rather than the order in which conditions were written. Inside a filter, @ refers only to the current candidate node.

2. Recursive Descent $..: Convenient but Expensive

.. means "search recursively at any depth from the current position". RFC 9535 defines it as a descendant segment, and it can be combined with names, wildcards, indices, slices, and filters:

$..price                  // every member named price at any depth
$..book[?(@.price < 10)]  // any book at any depth, then filter
$..[?(@.stock)]           // any node at any depth where stock exists

Three things to understand:

  1. $..book finds every node named book, regardless of its depth;
  2. The traversal is depth-first, and the output order depends on how the implementation visits children;
  3. .. cannot use any index, so its cost scales with the total number of nodes.

As a result, $..price and $.store.book[*].price may differ only by the bicycle price in their output, yet differ by an order of magnitude in cost. When you know the depth, always write the explicit path. If you only want to compare the boundaries of the two path languages, see JSONPath vs JSON Pointer.

3. Array Slices and Wildcards: Three Traps

Slice syntax is [start:end:step], inclusive of the start and exclusive of the end, with negative numbers counting from the tail:

Expression Meaning Result (with four books)
[0:2] First two books Books 1 and 2
[1:] From the second book to the end Books 2, 3, and 4
[:2] First two books Books 1 and 2
[-2:] Last two books Books 3 and 4
[::2] Every other book Books 1 and 3
[::-1] Reverse order Books 4, 3, 2, and 1

Three traps:

  • A step of zero is invalid. RFC 9535 requires a non-zero step, while some implementations silently return an empty set;
  • Negative steps are not universally supported. The original Goessner implementation supports them, but some minimal implementations accept only positive steps;
  • Filtering after a slice does not widen the scope. [0:2][?(@.stock)] only filters the two books produced by the slice; it does not go back to the whole array.

The wildcard [*] can be combined with slices, but [*] yields "all child nodes". If those children are objects, appending .title gives all titles. If a child is itself an array, .title will not implicitly flatten another level. Write [*] again when you need to expand; do not expect implicit flattening.

4. Multi-Condition Filters: Comparisons, Logic, and Existence

A filter [?(...)] can contain four kinds of constructs: comparisons, logical operators, existence tests, and functions. A common multi-condition query looks like this:

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

It means "price below 10 and stock is truthy". Pay attention to several semantic details:

  • @.stock is an existence test, not "the value is true". If the field exists but its value is false, the condition still holds. To test a boolean value you must write @.stock == true;
  • && binds tighter than ||. Add parentheses when mixing them instead of relying on memory;
  • Both sides of a comparison must have the same type. Numbers and strings are not implicitly converted; @.price < "10" is false or an error in strict implementations;
  • A missing field always makes a comparison false. @.missing < 10 does not raise an error; it simply matches nothing.

This is the easiest place to trip when writing filters as if they were JavaScript: if (obj.stock) tests truthiness in JavaScript, while JSONPath tests existence. For the complete standard syntax and functions, revisit JSONPath Query Language in Practice.

5. Script Expressions: More Power, Less Portability

The original Goessner implementation and jsonpath-plus allow cross-node comparisons such as [?(@.price < $.max)], and even arbitrary scripts. RFC 9535 explicitly permits only access to the current node @ and forbids $ inside a filter.

// Works in older implementations; not allowed by RFC 9535
$..book[?(@.price < $.expensive)]

// Standard approach: pass the threshold from the caller
query(document, '$..book[?(@.price < threshold)]', { threshold: 10 })

Script expressions create three problems:

  1. Portability drops to zero. Switching libraries means rewriting every expression;
  2. The security boundary disappears. When expressions come from users, script evaluation hands over code execution;
  3. Static optimization becomes impossible. The implementation can only interpret node by node, which is slow and hard to cache.

If you truly need cross-node comparison, pass the threshold as a query parameter and filter a second time in application code. That is usually more stable than depending on an extension syntax.

6. Result Shapes: You Think You Get Values, but You Get Nodes

The same expression can return at least three shapes across libraries:

["JSON: The Definitive Guide", "How Parsers Work", "Streaming JSON in Practice"] // value array
[{ "path": "$['store']['book'][0]['title']", "value": "..." }]                 // path + value
[{ "pointer": "/store/book/0/title", "value": "..." }]                          // JSON Pointer + value

This affects three things directly: how the frontend reads results, how results are deduplicated, and how query results become write operations. The nodelist defined by RFC 9535 is a set of node references, but JavaScript implementations usually return only values because references cannot cross process boundaries.

Practical advice:

  • Always read a single value through [0] or value(); never assume the result is a scalar;
  • When you need to write back, choose an implementation that returns JSON Pointers or build the pointer yourself;
  • An empty result and a result of [null] are different; check length instead of truthiness.

7. Library Differences: Run the Compliance Suite Before Migrating

Capability Original Goessner jsonpath-plus Strict RFC 9535
Filter parentheses ?() Required Compatible Optional
Cross-root reference $.x Supported Supported Forbidden
Script expressions Supported Supported Forbidden
Negative indices and steps Supported Supported Supported
Return paths Configurable Configurable Not specified
Functions such as length() None Extensions Standard functions

Before migrating, verify at least four things: whether parentheses are required, whether $ may appear inside a filter, how negative steps behave, and the result structure. The official compliance test suite covers most syntax differences and prevents silent failures such as "three rows disappeared after switching libraries".

8. Performance and Misuse Checklist

Performance comes down to one sentence: JSONPath is a full traversal, and .. is the most expensive kind of full traversal.

  • For documents with more than 100,000 nodes, narrow the scope with an explicit path first, then filter the subset;
  • Compile an expression once and reuse it; do not re-parse the string inside a loop;
  • Switch to jq or code for aggregation (summing, grouping, sorting); JSONPath only selects;
  • Put conditions as early as possible: $.store.book[?(@.price < 10)] scans far fewer irrelevant nodes than $..[?(@.price < 10)].

Misuse checklist:

  • [ ] Treating an existence test as a boolean truth test;
  • [ ] Using $.. instead of a known depth;
  • [ ] Depending on script expressions for cross-node comparison;
  • [ ] Assuming the result is always an array of values;
  • [ ] Allowing user-controlled expressions without length and nesting limits.

FAQ

Why does @.stock still match when the field is false?

Because a bare path inside a filter means "the field exists", not "the value is true". To test the boolean, compare explicitly with @.stock == true. This is the biggest semantic difference between JSONPath and JavaScript truthiness.

Is the order of recursive descent stable?

No. RFC 9535 specifies node-set semantics, and traversal order depends on how the implementation visits children. When you need a fixed order, locate the array and read by index, or sort the results in application code.

Can slices and filters be combined?

Yes, but remember the scope. $.store.book[0:2][?(@.stock)] filters only the two books produced by the slice. To filter first and slice second, write $.store.book[?(@.stock)][0:2]; a different order gives a different result.

Why does jsonpath-plus support $.expensive when the standard forbids it?

Because filters are defined only in terms of the current node @. A cross-root reference makes an expression depend on the whole document context, which is hard to analyze statically and to port across languages. Pass the threshold as a parameter instead of embedding it in the expression.

How do I keep recursive descent from scanning irrelevant nodes?

Write the explicit path whenever you know the depth, narrow the scope first, and filter only the subset instead of using $.. in place of a known level. Put conditions as early as possible: $.store.book[?(@.price < 10)] scans far fewer irrelevant nodes than $..[?(@.price < 10)]. For documents with more than 100,000 nodes, do this first.