Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 4 – Querying JSON: JSONPath, jq, JMESPath and SQL

JSONPath (RFC 9535): selectors and segments

Write JSONPath queries the RFC 9535 way: names, wildcards, indexes, slices and descendants, read normalized paths, and see what changed from the 2007 article.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Select values with name, wildcard, index and slice selectors
  • Use child and descendant segments and read the normalized path of every node
  • Predict the order, the duplicates and the empty results of a nodelist
  • Explain how RFC 9535 differs from the 2007 article and from older libraries

Before you start

What you’ll learn · Before you start

What you will learn

  • Select values with name, wildcard, index and slice selectors
  • Use child and descendant segments and read the normalized path of every node
  • Predict the order, the duplicates and the empty results of a nodelist
  • Explain how RFC 9535 differs from the 2007 article and from older libraries

Before you start

On this page

The bookshop’s catalogue is one JSON document with 4 books, 1 gift card and 2 shelf sections, and a report needs every title, the code of the last book and every price. In JSONPath each of those is one short query, $.books[*].title, $.books[-1].sku and $..price_paise, and the last of them quietly returns 5 prices, not 4, because it finds the gift card’s price too.

You will write queries the way RFC 9535, the JSONPath standard, defines them: the root and the segments that make a query, the name, wildcard, index and slice selectors, the descendant segment and the order of its results, and the normalized path that names every node a query returns. You will also see what the standard changed from the 2007 article that started JSONPath, which is why older libraries disagree with it. Filters are the next lesson.

Every query starts at the root and returns a nodelist

A JSONPath query is a path through a document, read from left to right. It starts with $, the root identifier, which stands for the whole document, and goes on with segment: One step of a JSONPath query. A child segment such as [0] or .title picks children of every node found so far; a descendant segment such as ..title picks from those nodes and everything below them.. RFC 9535 calls everything a query can select a node: a value together with its place in the document. Each segment takes the nodes found so far and hands on a new list of nodes, and the list left at the end is the answer, a nodelist: The result of a JSONPath query: a list of nodes, each a value together with its place in the document. An empty nodelist means that nothing matched, which is not the same as a match whose value is null.. Step through one query on the bookshop’s catalogue:

Interactive · stage by stage One query, one segment at a time Step through $.books[*].series.title on the catalogue. Each stage is one segment, and its sub-label is the nodelist it hands on.
One query, one segment at a time, step 6 of 6. Showing $, .books, [*], .series, .title.$1 node: the catalogue.books1 node: the array[*]4 nodes: the books.series1 node: one series.title1 node: "Kahaniyan"

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Start at the root. The query begins with $, the root identifier. The nodelist so far holds one node: the whole catalogue, whose normalized path is $.
  2. .books picks one member. .books is shorthand for ['books']. Applied to the one node so far, it picks the member called books: one node, the array of four books.
  3. [*] takes every element. The wildcard picks every child of each node it is given. The array has four elements, so the nodelist grows to four nodes, $['books'][0] to $['books'][3].
  4. .series drops three books. Three of the four books have no member called series, so they add nothing, and nothing fails. Only BK-310 has one, and the nodelist shrinks to one node.
  5. .title finds one title. Applied to the series object, ['title'] picks its title. The query ends with one node, "Kahaniyan", at $['books'][3]['series']['title'].
  6. The nodelist is the answer. Had no book had a series, the query would have ended with an empty nodelist: a valid answer that says nothing matched, which is neither an error nor null.

Three of the four books had no series member, and they simply dropped out: a selector that finds nothing adds nothing, and the query goes on with the rest. That holds for the whole language. RFC 9535 says that a valid query must not produce errors when it runs, while a query that breaks the grammar is refused before the document is read. So there are two outcomes to tell apart: an empty nodelist, where nothing matched, and a nodelist that holds a node whose value happens to be null, which is a match like any other.

The programs of this lesson run their queries on this catalogue:

The bookshop's catalogue JSON · jsonpath/catalogue.json
{
  "books": [
    {
      "sku": "BK-101",
      "title": "Monsoon Maths",
      "lang": "en",
      "price_paise": 34900,
      "authors": ["Asha Rao"],
      "stock": { "Pune": 4, "Kochi": 2 }
    },
    {
      "sku": "BK-205",
      "title": "बादल और बूँदें",
      "lang": "hi",
      "price_paise": 19900,
      "authors": ["Ravi Kant"],
      "stock": { "Pune": 0, "Indore": 6 }
    },
    {
      "sku": "BK-233",
      "title": "The Tea Garden Ledger",
      "lang": "en",
      "price_paise": 52500,
      "authors": ["Meera Das", "Tenzin Lama"],
      "stock": { "Guwahati": 3 }
    },
    {
      "sku": "BK-310",
      "title": "कोड की कहानियाँ",
      "lang": "hi",
      "price_paise": 27900,
      "authors": ["Sana Iqbal"],
      "stock": { "Mysuru": 1, "Pune": 7 },
      "series": { "title": "Kahaniyan", "number": 2 }
    }
  ],
  "gift_cards": [{ "code": "GC-500", "price_paise": 50000 }],
  "sections": {
    "new arrivals": ["BK-310", "BK-233"],
    "children's": ["BK-101"]
  }
}

The first program runs twelve queries and prints every node of each nodelist, with its normalized path, the exact place of the node in the document. Press Run, then Edit one of the queries and run it again:

Twelve queries on the catalogue, with every node they select Python · jsonpath/queries.py
"""Twelve JSONPath queries on the bookshop's catalogue, each printed with every node it selected."""
import json

from jsonpath_mini import query

with open("catalogue.json", encoding="utf-8") as f:
    catalogue = json.load(f)

QUERIES = [
    "$.books[0].title",
    "$['books'][0]['title']",
    "$.books[*].title",
    "$.books[-1].sku",
    "$.books[0, 2].sku",
    "$.books[*].authors[0]",
    "$.sections['new arrivals'][*]",
    "$.sections[\"children's\"][0]",
    "$.books[0].stock.*",
    "$.books[*].publisher",
    "$.books[9].title",
    "$.books.title",
]

for q in QUERIES:
    nodes = query(q, catalogue)
    print(f"{q}   {len(nodes)} node{'' if len(nodes) == 1 else 's'}")
    for path, value in nodes:
        print(f"    {path} = {json.dumps(value, ensure_ascii=False)}")

Output

$.books[0].title   1 node
    $['books'][0]['title'] = "Monsoon Maths"
$['books'][0]['title']   1 node
    $['books'][0]['title'] = "Monsoon Maths"
$.books[*].title   4 nodes
    $['books'][0]['title'] = "Monsoon Maths"
    $['books'][1]['title'] = "बादल और बूँदें"
    $['books'][2]['title'] = "The Tea Garden Ledger"
    $['books'][3]['title'] = "कोड की कहानियाँ"
$.books[-1].sku   1 node
    $['books'][3]['sku'] = "BK-310"
$.books[0, 2].sku   2 nodes
    $['books'][0]['sku'] = "BK-101"
    $['books'][2]['sku'] = "BK-233"
$.books[*].authors[0]   4 nodes
    $['books'][0]['authors'][0] = "Asha Rao"
    $['books'][1]['authors'][0] = "Ravi Kant"
    $['books'][2]['authors'][0] = "Meera Das"
    $['books'][3]['authors'][0] = "Sana Iqbal"
$.sections['new arrivals'][*]   2 nodes
    $['sections']['new arrivals'][0] = "BK-310"
    $['sections']['new arrivals'][1] = "BK-233"
$.sections["children's"][0]   1 node
    $['sections']['children\'s'][0] = "BK-101"
$.books[0].stock.*   2 nodes
    $['books'][0]['stock']['Pune'] = 4
    $['books'][0]['stock']['Kochi'] = 2
$.books[*].publisher   0 nodes
$.books[9].title   0 nodes
$.books.title   0 nodes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 queries.py

The first two queries are one query in dot and bracket notation: .books is shorthand for ['books']. The last three select nothing, for three different reasons: no book has a publisher, there is no tenth book, and books is an array, which has no member called title. None of them is an error, and none of them returns null.

Key idea

A query is the root followed by segments; each segment turns the nodelist so far into a new one, and a step that finds nothing adds nothing to it.

Checkpoint

Check: Queries, segments and nodelists

  1. Question 1 of 2 What does $.books[*].publisher return on the catalogue?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty nodelist, because no book has a publisher member

    Selectors that find nothing add nothing, so the nodelist ends empty. Only a query that breaks the grammar is refused, and that happens before the document is read.

  2. Question 2 of 2 How many nodes does $.books[*].stock.* select on the catalogue?

    Type a number.

    A hint

    Look at the stock object of each of the four books and count its members.

    Show the answer: Question 2 of 2

    Answer: 7 nodes

    [] hands on the 4 books, .stock their 4 stock objects, and . every member value of each: 2 for BK-101, 2 for BK-205, 1 for BK-233 and 2 for BK-310, so 7 nodes.

Names, wildcards and indexes pick children

Every segment holds one or more selector: One item inside a JSONPath segment that picks children of a node: a name, the wildcard *, an index, a slice or a filter. A segment may hold several, separated by commas., and each selector picks children of the nodes it is given. Three selectors cover most queries:

Selector Selects Given anything else
Name: ['title'], ["title"] or .title the member of an object with that name nothing: only objects have members
Wildcard: [*] or .* every member value of an object, every element of an array nothing: strings and numbers have no children
Index: [0], [-1] one element of an array, counted from 0, or from the end when negative nothing, nor does a position past the end

The dot form of a name is shorthand that works only for names that start with a letter or _ and go on with letters, digits and _. Letters beyond ASCII count, so a Devanagari name such as दाम works after a dot, but a name with a space, an apostrophe or a hyphen needs brackets and quotes, as the catalogue’s sections do: $.sections['new arrivals'] and $.sections["children's"]. Inside single quotes an apostrophe is written \'; inside double quotes it needs no escape.

A segment may hold several selectors separated by commas. $.books[0, 2].sku returned BK-101 and BK-233, in the order of the selectors, and a nodelist keeps duplicates, so [0, 0] would select the first book twice. The wildcard on an object, $.books[0].stock.*, returned 4 and 2, the stock in Pune and in Kochi. RFC 9535 fixes the order of an array’s elements but not of an object’s members, so another library may return 2 before 4.

A name on an array

Wrong: $.books.title, expecting the title of every book. It returned 0 nodes, because books is an array and a name selects members of objects only. Right: $.books[*].title, which steps into every element first.

Checkpoint

Check: Name, wildcard and index selectors

  1. Question 1 of 2 Which query selects the list of new arrivals?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: $.sections['new arrivals']

    The shorthand .name works for names made of letters, digits and _ that do not start with a digit. Every other name is written in brackets, in single or double quotes.

  2. Question 2 of 2 $.books.title returned 0 nodes on the catalogue. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: books is an array, and a name selector selects members of objects only

    $.books[*].title steps into every element of the array first, and then the name selector finds a title in each book.

Slices count like Python’s, inside the array’s bounds

An array slice, start:end:step, selects a run of elements: it begins at position start, moves by step, and stops before it reaches end. A part left out takes a default: the step is 1, and with a positive step the start is 0 and the end is the length of the array. The program applies eleven slices to the six days the shop opens:

Eleven slices on the six days the shop opens Python · jsonpath/slices.py
"""Array slices on the six days the shop opens: the positions each one selects, by RFC 9535's rules."""
from jsonpath_mini import query

week = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]}

SLICES = ["[1:4]", "[:2]", "[-2:]", "[::2]", "[::-1]", "[4:1:-1]", "[5:1:-2]", "[-10:2]", "[10:]", "[2:2]", "[1:5:0]"]

for s in SLICES:
    nodes = query(f"$.days{s}", week)
    positions = [path[path.rindex("[") + 1 : -1] for path, _ in nodes]
    shown = " ".join(value for _, value in nodes) or "(nothing)"
    print(f"{s:<10} positions {', '.join(positions) or '-':<18} {shown}")

try:
    week["days"][::0]
except ValueError as e:
    print(f"Python's own days[::0]: ValueError: {e}")

Output

[1:4]      positions 1, 2, 3            Tue Wed Thu
[:2]       positions 0, 1               Mon Tue
[-2:]      positions 4, 5               Fri Sat
[::2]      positions 0, 2, 4            Mon Wed Fri
[::-1]     positions 5, 4, 3, 2, 1, 0   Sat Fri Thu Wed Tue Mon
[4:1:-1]   positions 4, 3, 2            Fri Thu Wed
[5:1:-2]   positions 5, 3               Sat Thu
[-10:2]    positions 0, 1               Mon Tue
[10:]      positions -                  (nothing)
[2:2]      positions -                  (nothing)
[1:5:0]    positions -                  (nothing)
Python's own days[::0]: ValueError: slice step cannot be zero

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 slices.py

A negative start or end counts from the end, so [-2:] gave Fri and Sat. A negative step walks backwards: [4:1:-1] began at Fri, position 4, and stopped before position 1, so it gave Fri, Thu and Wed. With a negative step the defaults turn round too, the start becoming the last position and the end the place before the first, which is why [::-1] returned all six days in reverse order.

Section 2.3.4 of RFC 9535 gives the exact rules. It adds the length of the array to a negative start or end, then clamps both into the array, so [-10:2] became positions 0 and 1, Mon and Tue, with no complaint about the −10. A start past the end, as in [10:], or an empty range, as in [2:2], selects nothing, and so does a step of 0.

Expecting an error for a step of 0

Python refuses a zero step: days[::0] raised ValueError: slice step cannot be zero in the last line of the run. A JSONPath slice never fails, so $.days[1:5:0] quietly selected nothing. Check the length of the nodelist rather than waiting for an error.

Checkpoint

Check: Array slices

  1. Question 1 of 2 Which days does $.days[5:1:-2] select from Mon, Tue, Wed, Thu, Fri, Sat?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Sat and Thu

    The run printed positions 5, 3 for [5:1:-2]: Sat and Thu. With a negative step the slice walks from the start down to the end, without reaching the end.

  2. Question 2 of 2 What does $.days[1:5:0] select?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing

    A step of 0 is valid syntax and selects nothing. Python's days[::0] raised ValueError in the same run, which is one of the places where the two languages part ways.

A descendant segment searches every level, in a fixed order

Two dots make a descendant segment: ..title applies the name selector ['title'] to the node it starts from and to every node below it, at any depth. It is the quickest way to find a name whose place you do not know, and the easiest way to find more than you meant:

What a descendant segment finds, and in which order Python · jsonpath/descendants.py
"""Descendant segments (..) on the catalogue: what they find, and in which order."""
import json

from jsonpath_mini import query

with open("catalogue.json", encoding="utf-8") as f:
    catalogue = json.load(f)


def show(q):
    nodes = query(q, catalogue)
    print(f"{q}   {len(nodes)} nodes")
    for path, value in nodes:
        print(f"    {path} = {json.dumps(value, ensure_ascii=False)}")


show("$..title")
show("$..price_paise")
show("$.books[*].price_paise")
show("$.books[3]..title")
show("$.books[1]..*")
show("$.books[0]['sku', 'sku']")
print("$..* selects", len(query("$..*", catalogue)), "nodes: every member value and element in the catalogue")

Output

$..title   5 nodes
    $['books'][0]['title'] = "Monsoon Maths"
    $['books'][1]['title'] = "बादल और बूँदें"
    $['books'][2]['title'] = "The Tea Garden Ledger"
    $['books'][3]['title'] = "कोड की कहानियाँ"
    $['books'][3]['series']['title'] = "Kahaniyan"
$..price_paise   5 nodes
    $['books'][0]['price_paise'] = 34900
    $['books'][1]['price_paise'] = 19900
    $['books'][2]['price_paise'] = 52500
    $['books'][3]['price_paise'] = 27900
    $['gift_cards'][0]['price_paise'] = 50000
$.books[*].price_paise   4 nodes
    $['books'][0]['price_paise'] = 34900
    $['books'][1]['price_paise'] = 19900
    $['books'][2]['price_paise'] = 52500
    $['books'][3]['price_paise'] = 27900
$.books[3]..title   2 nodes
    $['books'][3]['title'] = "कोड की कहानियाँ"
    $['books'][3]['series']['title'] = "Kahaniyan"
$.books[1]..*   9 nodes
    $['books'][1]['sku'] = "BK-205"
    $['books'][1]['title'] = "बादल और बूँदें"
    $['books'][1]['lang'] = "hi"
    $['books'][1]['price_paise'] = 19900
    $['books'][1]['authors'] = ["Ravi Kant"]
    $['books'][1]['stock'] = {"Pune": 0, "Indore": 6}
    $['books'][1]['authors'][0] = "Ravi Kant"
    $['books'][1]['stock']['Pune'] = 0
    $['books'][1]['stock']['Indore'] = 6
$.books[0]['sku', 'sku']   2 nodes
    $['books'][0]['sku'] = "BK-101"
    $['books'][0]['sku'] = "BK-101"
$..* selects 54 nodes: every member value and element in the catalogue

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 descendants.py

$..title found 5 titles, not 4: the fifth is the title of the series that BK-310 belongs to. $..price_paise found 5 prices, the four books’ and the gift card’s 50000, so a report that adds them up counts a gift card as a book. $.books[*].price_paise names the place and found exactly the four.

The order of the results is fixed by section 2.5.2 of RFC 9535: the segment visits the node it starts from, then the nodes below it, each node before its descendants and the elements of an array in order, and joins what the selector found at each visit. Step through $.books[3]..title, the last book searched from top to bottom:

Interactive · step by step Where $.books[3]..title looks, and in which order Step through the descendant segment from the last book. It visits the book, then every node below it, and applies ['title'] at each visit.
Where $.books[3]..title looks, and in which order, step 6 of 6. Showing books[3], title (result 1), authors (no title here), stock (no title here), series, series title (result 2).books[3]BK-310titleresult 1authorsno title herestockno title hereseriestitle and numberseries titleresult 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The book itself first. A descendant segment visits the node it starts from before anything below it, and applies its selector there. ['title'] finds the book's own title: result 1.
  2. Then its children, in order. Next come the book's member values, here in the document's order, which RFC 9535 leaves open for objects. sku, title, lang and price_paise are strings and numbers with no children, so ['title'] finds nothing in them.
  3. authors holds no title. authors is an array. A name selector finds nothing in an array, and its one element, "Sana Iqbal", is a string with no children of its own.
  4. stock holds no title. stock is an object with the members Mysuru and Pune, and neither is called title. The walk visits both numbers, which have no children.
  5. series has a title. series is an object with a member called title, so ['title'] selects it: result 2, "Kahaniyan".
  6. The walk ends. series.title and series.number have no children, so the walk is over. The nodelist holds the two titles in the order the walk met them: the book's, then the series'.

The walk met the book’s own title first and the series title last, which is also the order of the two titles in the program’s run. The same rule explains $.books[1]..*: the book’s six member values come first, then the author inside authors and the two numbers inside stock. Only the order of an object’s members is left open, as for the wildcard. Duplicates stay here as everywhere: $.books[0]['sku', 'sku'] returned the same node twice.

Name the place when you know it

Write the path you mean, such as $.books[*].price_paise, and keep .. for documents whose shape you do not control. When you do use it, print the normalized path of every node it returns before you trust a total.

Checkpoint

Check: Descendant segments and their order

  1. Question 1 of 2 How many nodes does $..price_paise select on the catalogue?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 5 nodes

    The descendant segment applies ['price_paise'] at every level, so it finds the four books' prices and the gift card's 50000, as the run printed.

  2. Question 2 of 2 In which order does $.books[3]..title return its two titles, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The book's title, then the series title, because a node is visited before its descendants

    Section 2.5.2 visits the starting node, then the nodes below it, each node before its descendants and arrays in order. The figure and the run both show the book's title first.

Every node has exactly one normalized path

Every node of a nodelist comes with a normalized path: The one JSONPath form that names a single node exactly, such as $['books'][0]['title']: every name in single quotes and every array position counted from 0. RFC 9535 section 2.7 defines it., the one form of JSONPath that names a single node exactly. Section 2.7 of RFC 9535 defines it: $, then one pair of brackets for each step, every name in single quotes and every position as a number counted from 0. $.books[-1] and $.books[3] select the same node, and its normalized path is $['books'][3] either way.

Inside a name, a normalized path escapes only what it must: an apostrophe as \', a backslash as \\, the control characters backspace, form feed, line feed, carriage return and tab as \b, \f, \n, \r and \t, and every other character below U+0020 as \u with four lowercase hexadecimal digits. Everything else stays as it is, a slash, a tilde or Devanagari letters included:

Normalized paths for names that a dot cannot reach Python · jsonpath/paths.py
"""Normalized paths for member names that a dot cannot reach: spaces, quotes, slashes, control characters."""
from jsonpath_mini import query

doc = {
    "sections": {"new arrivals": 2, "children's": 1},
    "codes": {"a/b": "slash", "m~n": "tilde", "line\nbreak": "newline", "bell\u0007": "control", "दाम": "Hindi"},
}

for q in ["$.sections.*", "$.codes.*", "$.codes['a/b']", "$.codes.दाम", "$['codes']['line\\nbreak']"]:
    print(q)
    for path, value in query(q, doc):
        print(f"    {path}   ({value})")

Output

$.sections.*
    $['sections']['new arrivals']   (2)
    $['sections']['children\'s']   (1)
$.codes.*
    $['codes']['a/b']   (slash)
    $['codes']['m~n']   (tilde)
    $['codes']['line\nbreak']   (newline)
    $['codes']['bell\u0007']   (control)
    $['codes']['दाम']   (Hindi)
$.codes['a/b']
    $['codes']['a/b']   (slash)
$.codes.दाम
    $['codes']['दाम']   (Hindi)
$['codes']['line\nbreak']
    $['codes']['line\nbreak']   (newline)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 paths.py

Because every node has one normalized path, and every normalized path names one node, the path is a safe label for a value: in a log line, an error report, or the list of differences between two versions of a document. It is also close to a JSON Pointer: A string that names one value inside a JSON document, such as /items/0/qty, defined by RFC 6901. Inside a name, ~ is written ~0 and / is written ~1. More in “JSON interop: duplicates, key order, I-JSON”, the form of RFC 6901 that JSON Patch uses: $['codes']['a/b'] is /codes/a~1b there, with / written ~1 and ~ written ~0. Appendix C of RFC 9535 notes that a normalized path converts to a pointer by its syntax alone, while the other direction needs the document, because a pointer step such as 0 may be an array position or a member called "0". The second exercise of this lesson builds that conversion, and the JSON Pointer lesson covers pointers themselves.

Checkpoint

Check: Normalized paths

  1. Question 1 of 2 What is the normalized path of the list of children's books?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: $['sections']['children\'s']

    A normalized path writes every step in brackets, names in single quotes, and escapes an apostrophe as \' and a backslash as \\.

  2. Question 2 of 2 Which JSON Pointer names the node at $['codes']['a/b']?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /codes/a~1b

    A normalized path converts to a pointer by its syntax alone: each step becomes / and the name, with ~ written ~0 and / written ~1.

RFC 9535 is stricter than the 2007 article

JSONPath began as an article by Stefan Gössner in 2007 and spread through libraries in many languages before the IETF published RFC 9535 in 2024, as a Proposed Standard. The RFC says it is “based on Stefan Gössner’s popular JSONPath proposal”, and it turns that proposal into rules that every implementation must follow exactly. The differences are where older libraries still disagree with it:

Topic The 2007 article RFC 9535
Script expressions ( ) is a “script expression, using the underlying script engine”, as in $..book[(@.length-1)] none; the last element is [-1]
Filters ?( ) “applies a filter (script) expression” a filter grammar of its own (the next lesson), with optional parentheses
No match the article’s implementation returns false an empty nodelist
Slices “borrowed from ES4”, with no exact rules the exact rules of section 2.3.4
Order and duplicates not stated arrays in order, nodes before their descendants, duplicates kept
Result paths paths such as $['store']['book'][0]['author'] the same form, defined exactly as normalized paths

Here are queries written in the article’s style, read by an evaluator that follows the RFC:

Queries in the style of the 2007 article, read by the RFC's rules Python · jsonpath/old_syntax.py
"""Queries in the style of the 2007 article, read by an evaluator that follows RFC 9535."""
import json

from jsonpath_mini import JSONPathSyntaxError, query

with open("catalogue.json", encoding="utf-8") as f:
    catalogue = json.load(f)

for q in ["$..books[(@.length-1)].sku", "$..books[-1:].sku", "$..books[-1].sku", "$..books[0,1].sku", "$.books[*].isbn"]:
    try:
        nodes = query(q, catalogue)
        print(f"{q:<30} {[value for _, value in nodes]}")
    except JSONPathSyntaxError as e:
        print(f"{q:<30} refused: {e}")

Output

$..books[(@.length-1)].sku     refused: (…) is a script expression of the 2007 article, which RFC 9535 does not have (at position 9)
$..books[-1:].sku              ['BK-310']
$..books[-1].sku               ['BK-310']
$..books[0,1].sku              ['BK-101', 'BK-205']
$.books[*].isbn                []

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 old_syntax.py

The script expression was refused before the catalogue was read, while the article’s other forms, the slice [-1:], the union [0,1] and the two dots, mean the same today. The last query found no isbn, and its answer is an empty list, not false.

Libraries written before 2024 often follow the article, or their own reading of it, so one query can give different nodelists in two languages. The JSONPath Compliance Test Suite, a public set of queries, documents and the nodelists RFC 9535 expects, is the yardstick: python-jsonpath, for one, says that it follows RFC 9535 and tests against the suite. The evaluator that the programs on this page import reads every query with the RFC’s grammar before it looks at the document, and runs nothing as code. Read it to see each rule as a few lines of Python:

jsonpath_mini.py: the selectors and segments of RFC 9535 in Python Python · jsonpath/jsonpath_mini.py
"""jsonpath_mini: the selectors and segments of JSONPath (RFC 9535), written with the standard library only.

    from jsonpath_mini import query
    for path, value in query("$.books[*].title", catalogue):
        print(path, value)

query() reads the whole query before it looks at the document, so a query that RFC 9535 does not allow raises
JSONPathSyntaxError, with the position of the problem, before anything is selected. Nothing in a query is ever run as
code. The result is the nodelist, a list of (normalized path, value) pairs in the order RFC 9535 gives, with object
members in the order of the document. Filter selectors (?…) are left out here: the next lesson's evaluator adds them.
Section numbers in the comments are those of RFC 9535.
"""

import re

MAX_EXACT = 2**53 - 1  # integers in a query stay in the I-JSON range of exact values (section 2.1)
BLANK = " \t\n\r"  # the blank space allowed between the parts of a query (section 2.1.1)
DIGITS = "0123456789"


class JSONPathSyntaxError(ValueError):
    """The text is not a JSONPath query of RFC 9535; .position is where the problem was found."""

    def __init__(self, message, position):
        super().__init__(f"{message} (at position {position})")
        self.position = position


def query(text, document):
    """The nodelist that the query selects from the document: a list of (normalized path, value)."""
    segments = _Parser(text).parse()
    return [(normalized_path(path), value) for path, value in _apply(segments, [((), document)])]


# ---- running segments and selectors --------------------------------------------------------------------------------
# A node is (path, value): the path is a tuple of member names and array positions from the root.


def _apply(segments, nodes):
    """Applies each segment to every node of the nodelist so far, in order (section 2.1.2)."""
    for descendant, selectors in segments:
        out = []
        for node in nodes:
            for target in _self_and_descendants(node) if descendant else (node,):
                for selector in selectors:
                    out.extend(selector(target))
        nodes = out
    return nodes


def _children(node):
    """The member values of an object, or the elements of an array, in order (sections 1.1, 2.3.2)."""
    path, value = node
    if isinstance(value, dict):
        return [(path + (name,), item) for name, item in value.items()]
    if isinstance(value, list):
        return [(path + (i,), item) for i, item in enumerate(value)]
    return []


def _self_and_descendants(node):
    """The node, then its descendants: every node before its descendants, arrays in order (section 2.5.2).
    A list of nodes still to visit, not recursion, so a deeply nested document cannot overflow the stack."""
    stack = [node]
    while stack:
        current = stack.pop()
        yield current
        stack.extend(reversed(_children(current)))


def _name_selector(name):
    def select(node):
        path, value = node
        if isinstance(value, dict) and name in value:
            return [(path + (name,), value[name])]
        return []  # nothing is selected from a value that is not an object (section 2.3.1.2)

    return select


def _wildcard_selector(node):
    return _children(node)


def _index_selector(index):
    def select(node):
        path, value = node
        if isinstance(value, list):
            i = index + len(value) if index < 0 else index  # -1 is the last element (section 2.3.3.2)
            if 0 <= i < len(value):
                return [(path + (i,), value[i])]
        return []

    return select


def slice_positions(length, start, end, step):
    """The positions that start:end:step selects in an array of this length (section 2.3.4.2.2)."""
    step = 1 if step is None else step
    if step == 0:
        return []
    if start is None:
        start = 0 if step > 0 else length - 1
    if end is None:
        end = length if step > 0 else -length - 1

    def normalize(i):
        return i if i >= 0 else length + i

    if step > 0:
        lower = min(max(normalize(start), 0), length)
        upper = min(max(normalize(end), 0), length)
        return list(range(lower, upper, step))
    upper = min(max(normalize(start), -1), length - 1)
    lower = min(max(normalize(end), -1), length - 1)
    return list(range(upper, lower, step))


def _slice_selector(start, end, step):
    def select(node):
        path, value = node
        if not isinstance(value, list):
            return []
        return [(path + (i,), value[i]) for i in slice_positions(len(value), start, end, step)]

    return select


# ---- normalized paths (section 2.7) --------------------------------------------------------------------------------

_PATH_ESCAPES = {"\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t", "'": "\\'", "\\": "\\\\"}


def normalized_path(path):
    """$['books'][0]['title']: single quotes, the escapes of section 2.7, non-negative positions."""
    parts = ["$"]
    for step in path:
        if isinstance(step, int):
            parts.append(f"[{step}]")
        else:
            name = "".join(_PATH_ESCAPES.get(c) or (f"\\u{ord(c):04x}" if ord(c) < 0x20 else c) for c in step)
            parts.append(f"['{name}']")
    return "".join(parts)


# ---- the parser (the ABNF of sections 2.1 to 2.5) -----------------------------------------------------------------


class _Parser:
    def __init__(self, text):
        self.text, self.pos = text, 0

    def fail(self, message, at=None):
        raise JSONPathSyntaxError(message, self.pos if at is None else at)

    def peek(self, s):
        return self.text.startswith(s, self.pos)

    def eat(self, s):
        if self.text.startswith(s, self.pos):
            self.pos += len(s)
            return True
        return False

    def blank(self):
        while self.pos < len(self.text) and self.text[self.pos] in BLANK:
            self.pos += 1

    def parse(self):
        if not self.eat("$"):
            self.fail("a query starts with $")
        segments = []
        while True:  # *(S segment): blank space is allowed before a segment, never after the last one
            before = self.pos
            self.blank()
            if self.eat(".."):  # a descendant segment: ..[…], ..* or ..name (section 2.5.2)
                segments.append((True, self.bracketed() if self.peek("[") else [self.dotted()]))
            elif self.eat("."):  # .* or .name, shorthand for [*] and ['name'] (section 2.5.1)
                segments.append((False, [self.dotted()]))
            elif self.peek("["):
                segments.append((False, self.bracketed()))
            else:
                self.pos = before
                break
        if self.pos != len(self.text):
            self.fail("unexpected text")
        return segments

    def dotted(self):
        """What follows a dot: * or a member name of letters, digits and _ that does not start with a digit."""
        if self.eat("*"):
            return _wildcard_selector
        start = self.pos
        if self.pos < len(self.text) and _name_first(self.text[self.pos]):
            self.pos += 1
            while self.pos < len(self.text) and (_name_first(self.text[self.pos]) or self.text[self.pos] in DIGITS):
                self.pos += 1
            return _name_selector(self.text[start : self.pos])
        self.fail("expected a member name or * after the dot")

    def bracketed(self):
        """[ selector, selector … ]: one or more selectors, separated by commas (section 2.5.1.1)."""
        self.pos += 1  # [
        selectors = []
        while True:
            self.blank()
            selectors.append(self.selector())
            self.blank()
            if self.eat("]"):
                return selectors
            if not self.eat(","):
                self.fail("expected , or ]")

    def selector(self):
        c = self.text[self.pos : self.pos + 1]
        if c in ("'", '"'):
            return _name_selector(self.string())
        if c == "*":
            self.pos += 1
            return _wildcard_selector
        if c == "?":
            self.fail("filter selectors (?…) are not part of this evaluator; the next lesson's evaluator runs them")
        if c == "(":
            self.fail("(…) is a script expression of the 2007 article, which RFC 9535 does not have")
        start = self.integer()
        after = self.pos
        self.blank()
        if not self.peek(":"):
            self.pos = after
            if start is None:
                self.fail("expected a selector")
            return _index_selector(start)
        self.pos += 1  # the slice selector: [start S] ":" S [end S] [":" [S step]] (section 2.3.4.1)
        self.blank()
        end = self.integer()
        if end is not None:
            after = self.pos
            self.blank()
            if not self.peek(":"):
                self.pos = after
        step = None
        if self.eat(":"):
            after = self.pos
            self.blank()
            step = self.integer()
            if step is None:
                self.pos = after
        return _slice_selector(start, end, step)

    def integer(self):
        """An integer of the query (section 2.3.3.1): no leading zeros, no -0, inside the I-JSON range; or None."""
        m = re.compile(r"-?[0-9]+").match(self.text, self.pos)
        if not m:
            return None
        digits = m.group().lstrip("-")
        if (digits.startswith("0") and len(digits) > 1) or m.group() == "-0":
            self.fail("integers have no leading zeros, and -0 is not one")
        value = int(m.group())
        if abs(value) > MAX_EXACT:
            self.fail("the integer is outside the range -(2^53 - 1) to 2^53 - 1")
        self.pos = m.end()
        return value

    def string(self):
        """A name in single or double quotes, with the escapes of section 2.3.1.1."""
        quote = self.text[self.pos]
        self.pos += 1
        out = []
        while True:
            if self.pos >= len(self.text):
                self.fail("the name has no closing quote")
            c = self.text[self.pos]
            self.pos += 1
            if c == quote:
                return "".join(out)
            if c == "\\":
                out.append(self.string_escape(quote))
            elif ord(c) < 0x20:
                self.fail("a control character must be escaped", self.pos - 1)
            else:
                out.append(c)

    def string_escape(self, quote):
        c = self.text[self.pos : self.pos + 1]
        self.pos += 1
        simple = {"b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t", "/": "/", "\\": "\\", quote: quote}
        if c in simple:
            return simple[c]
        if c != "u":
            self.fail(f"\\{c} is not an escape in a {'single' if quote == chr(39) else 'double'}-quoted name", self.pos - 2)
        code = self.hex4()
        if 0xDC00 <= code <= 0xDFFF:
            self.fail("a low surrogate needs a high surrogate before it")
        if 0xD800 <= code <= 0xDBFF:
            if not self.eat("\\u"):
                self.fail("a high surrogate needs a low surrogate after it")
            low = self.hex4()
            if not 0xDC00 <= low <= 0xDFFF:
                self.fail("a high surrogate needs a low surrogate after it")
            return chr(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
        return chr(code)

    def hex4(self):
        digits = self.text[self.pos : self.pos + 4]
        if len(digits) != 4 or any(d not in "0123456789abcdefABCDEF" for d in digits):
            self.fail("\\u needs four hexadecimal digits")
        self.pos += 4
        return int(digits, 16)


def _name_first(c):
    """ALPHA, _, or any character from U+0080 on (section 2.5.1.1)."""
    return c == "_" or ("a" <= c <= "z") or ("A" <= c <= "Z") or ord(c) >= 0x80

Checkpoint

Check: RFC 9535 and the 2007 article

  1. Question 1 of 2 What does an RFC 9535 implementation do with $..books[(@.length-1)]?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It refuses the query before it reads the document

    The query breaks RFC 9535's grammar, so it is refused before anything is selected. $..books[-1] asks the same question the standard way.

  2. Question 2 of 2 A script written for an old library detects "not found" with if result is False. What happens after a move to an RFC 9535 library?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The test is never true, because the library answers with an empty nodelist

    Test the length of the nodelist instead. An empty list means that nothing matched, whatever library returns it.

Practise: query a timetable, then turn paths into pointers

The first exercise gives you a train timetable and eight questions to answer with one query each; its sample tests run your queries with this lesson’s evaluator. The second turns a normalized path into the JSON Pointer of the same node, with the escapes of both formats:

Code lab · 2 steps

Query a timetable, then name its nodes

Answer eight questions about a timetable with one JSONPath query each, then convert normalized paths into JSON Pointers.

  1. Eight questions about a timetable, eight queries To do
  2. From a normalized path to a JSON Pointer To do

Exercise · Easy · Python

Eight questions about a timetable, eight queries

Fill in QUERIES in timetable.py with one JSONPath query (RFC 9535) for each question. The sample tests run every query on this timetable with the lesson's evaluator and compare the values it selects, in order:

{
  "trains": [
    { "number": "T-301", "name": "Monsoon Link",
      "stops": [{ "station": "Mumbai", "dep": "06:10" }, { "station": "Ratnagiri", "arr": "11:05", "dep": "11:10" },
                { "station": "Madgaon", "arr": "15:40" }],
      "fares": { "sleeper": 450, "ac3": 1180 } },
    { "number": "T-302", "name": "Deccan Dawn",
      "stops": [{ "station": "Pune", "dep": "05:45" }, { "station": "Satara", "arr": "07:50", "dep": "07:55" },
                { "station": "Kolhapur", "arr": "10:30" }],
      "fares": { "sleeper": 310, "ac3": 820, "ac2": 1150 } },
    { "number": "T-303", "name": "Coastal Night",
      "stops": [{ "station": "Mangaluru", "dep": "21:00" }, { "station": "Udupi", "arr": "22:10", "dep": "22:12" },
                { "station": "Karwar", "arr": "01:35" }],
      "fares": { "sleeper": 290 } }
  ],
  "platforms": { "Madgaon Junction": 2, "Pune Junction": 4 }
}
  1. train_names: the name of every train.
  2. second_train_start: the first station of the second train.
  3. last_stations: the last station of every train.
  4. all_departures: every departure time (dep) in the timetable, in the document's order.
  5. first_two_numbers: the numbers of the first two trains, with a slice.
  6. first_train_backwards: the stations of the first train, from its last stop to its first.
  7. second_train_fares: every fare of the second train.
  8. pune_platform: the platform of Pune Junction.

Starter code · timetable.py

"""One JSONPath query for each question about the timetable (the timetable is in the prompt and the tests)."""

QUERIES = {
    "train_names": "",
    "second_train_start": "",
    "last_stations": "",
    "all_departures": "",
    "first_two_numbers": "",
    "first_train_backwards": "",
    "second_train_fares": "",
    "pune_platform": "",
}
The sample tests · test_timetable.py
"""Sample tests: each one runs your query on the timetable and compares the values it selects, in order.

The evaluator at the end of this file is the one the lesson's examples import (jsonpath_mini.py), so a query that
RFC 9535 does not allow fails its test with the reason and the position."""
from timetable import QUERIES

TIMETABLE = {
    "trains": [
        {
            "number": "T-301",
            "name": "Monsoon Link",
            "stops": [{"station": "Mumbai", "dep": "06:10"}, {"station": "Ratnagiri", "arr": "11:05", "dep": "11:10"}, {"station": "Madgaon", "arr": "15:40"}],
            "fares": {"sleeper": 450, "ac3": 1180},
        },
        {
            "number": "T-302",
            "name": "Deccan Dawn",
            "stops": [{"station": "Pune", "dep": "05:45"}, {"station": "Satara", "arr": "07:50", "dep": "07:55"}, {"station": "Kolhapur", "arr": "10:30"}],
            "fares": {"sleeper": 310, "ac3": 820, "ac2": 1150},
        },
        {
            "number": "T-303",
            "name": "Coastal Night",
            "stops": [{"station": "Mangaluru", "dep": "21:00"}, {"station": "Udupi", "arr": "22:10", "dep": "22:12"}, {"station": "Karwar", "arr": "01:35"}],
            "fares": {"sleeper": 290},
        },
    ],
    "platforms": {"Madgaon Junction": 2, "Pune Junction": 4},
}


def values(name):
    """The values that QUERIES[name] selects from the timetable, in order."""
    return [value for _, value in query(QUERIES[name], TIMETABLE)]


def test_train_names():
    """the name of every train"""
    assert values("train_names") == ["Monsoon Link", "Deccan Dawn", "Coastal Night"]


def test_second_train_start():
    """the first station of the second train"""
    assert values("second_train_start") == ["Pune"]


def test_last_stations():
    """the last station of every train"""
    assert values("last_stations") == ["Madgaon", "Kolhapur", "Karwar"]


def test_all_departures():
    """every departure time, in the document's order"""
    assert values("all_departures") == ["06:10", "11:10", "05:45", "07:55", "21:00", "22:12"]


def test_first_two_numbers():
    """the numbers of the first two trains, with a slice"""
    assert values("first_two_numbers") == ["T-301", "T-302"]
    assert ":" in QUERIES["first_two_numbers"], "use a slice, such as [start:end]"


def test_first_train_backwards():
    """the stations of the first train, from its last stop to its first"""
    assert values("first_train_backwards") == ["Madgaon", "Ratnagiri", "Mumbai"]


def test_second_train_fares():
    """every fare of the second train"""
    assert values("second_train_fares") == [310, 820, 1150]


def test_pune_platform():
    """the platform of Pune Junction"""
    assert values("pune_platform") == [4]


# ---- the lesson's evaluator (examples/jsonpath/jsonpath_mini.py), which runs your queries ----------------------------

import re

MAX_EXACT = 2**53 - 1  # integers in a query stay in the I-JSON range of exact values (section 2.1)
BLANK = " \t\n\r"  # the blank space allowed between the parts of a query (section 2.1.1)
DIGITS = "0123456789"


class JSONPathSyntaxError(ValueError):
    """The text is not a JSONPath query of RFC 9535; .position is where the problem was found."""

    def __init__(self, message, position):
        super().__init__(f"{message} (at position {position})")
        self.position = position


def query(text, document):
    """The nodelist that the query selects from the document: a list of (normalized path, value)."""
    segments = _Parser(text).parse()
    return [(normalized_path(path), value) for path, value in _apply(segments, [((), document)])]


# ---- running segments and selectors --------------------------------------------------------------------------------
# A node is (path, value): the path is a tuple of member names and array positions from the root.


def _apply(segments, nodes):
    """Applies each segment to every node of the nodelist so far, in order (section 2.1.2)."""
    for descendant, selectors in segments:
        out = []
        for node in nodes:
            for target in _self_and_descendants(node) if descendant else (node,):
                for selector in selectors:
                    out.extend(selector(target))
        nodes = out
    return nodes


def _children(node):
    """The member values of an object, or the elements of an array, in order (sections 1.1, 2.3.2)."""
    path, value = node
    if isinstance(value, dict):
        return [(path + (name,), item) for name, item in value.items()]
    if isinstance(value, list):
        return [(path + (i,), item) for i, item in enumerate(value)]
    return []


def _self_and_descendants(node):
    """The node, then its descendants: every node before its descendants, arrays in order (section 2.5.2).
    A list of nodes still to visit, not recursion, so a deeply nested document cannot overflow the stack."""
    stack = [node]
    while stack:
        current = stack.pop()
        yield current
        stack.extend(reversed(_children(current)))


def _name_selector(name):
    def select(node):
        path, value = node
        if isinstance(value, dict) and name in value:
            return [(path + (name,), value[name])]
        return []  # nothing is selected from a value that is not an object (section 2.3.1.2)

    return select


def _wildcard_selector(node):
    return _children(node)


def _index_selector(index):
    def select(node):
        path, value = node
        if isinstance(value, list):
            i = index + len(value) if index < 0 else index  # -1 is the last element (section 2.3.3.2)
            if 0 <= i < len(value):
                return [(path + (i,), value[i])]
        return []

    return select


def slice_positions(length, start, end, step):
    """The positions that start:end:step selects in an array of this length (section 2.3.4.2.2)."""
    step = 1 if step is None else step
    if step == 0:
        return []
    if start is None:
        start = 0 if step > 0 else length - 1
    if end is None:
        end = length if step > 0 else -length - 1

    def normalize(i):
        return i if i >= 0 else length + i

    if step > 0:
        lower = min(max(normalize(start), 0), length)
        upper = min(max(normalize(end), 0), length)
        return list(range(lower, upper, step))
    upper = min(max(normalize(start), -1), length - 1)
    lower = min(max(normalize(end), -1), length - 1)
    return list(range(upper, lower, step))


def _slice_selector(start, end, step):
    def select(node):
        path, value = node
        if not isinstance(value, list):
            return []
        return [(path + (i,), value[i]) for i in slice_positions(len(value), start, end, step)]

    return select


# ---- normalized paths (section 2.7) --------------------------------------------------------------------------------

_PATH_ESCAPES = {"\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t", "'": "\\'", "\\": "\\\\"}


def normalized_path(path):
    """$['books'][0]['title']: single quotes, the escapes of section 2.7, non-negative positions."""
    parts = ["$"]
    for step in path:
        if isinstance(step, int):
            parts.append(f"[{step}]")
        else:
            name = "".join(_PATH_ESCAPES.get(c) or (f"\\u{ord(c):04x}" if ord(c) < 0x20 else c) for c in step)
            parts.append(f"['{name}']")
    return "".join(parts)


# ---- the parser (the ABNF of sections 2.1 to 2.5) -----------------------------------------------------------------


class _Parser:
    def __init__(self, text):
        self.text, self.pos = text, 0

    def fail(self, message, at=None):
        raise JSONPathSyntaxError(message, self.pos if at is None else at)

    def peek(self, s):
        return self.text.startswith(s, self.pos)

    def eat(self, s):
        if self.text.startswith(s, self.pos):
            self.pos += len(s)
            return True
        return False

    def blank(self):
        while self.pos < len(self.text) and self.text[self.pos] in BLANK:
            self.pos += 1

    def parse(self):
        if not self.eat("$"):
            self.fail("a query starts with $")
        segments = []
        while True:  # *(S segment): blank space is allowed before a segment, never after the last one
            before = self.pos
            self.blank()
            if self.eat(".."):  # a descendant segment: ..[…], ..* or ..name (section 2.5.2)
                segments.append((True, self.bracketed() if self.peek("[") else [self.dotted()]))
            elif self.eat("."):  # .* or .name, shorthand for [*] and ['name'] (section 2.5.1)
                segments.append((False, [self.dotted()]))
            elif self.peek("["):
                segments.append((False, self.bracketed()))
            else:
                self.pos = before
                break
        if self.pos != len(self.text):
            self.fail("unexpected text")
        return segments

    def dotted(self):
        """What follows a dot: * or a member name of letters, digits and _ that does not start with a digit."""
        if self.eat("*"):
            return _wildcard_selector
        start = self.pos
        if self.pos < len(self.text) and _name_first(self.text[self.pos]):
            self.pos += 1
            while self.pos < len(self.text) and (_name_first(self.text[self.pos]) or self.text[self.pos] in DIGITS):
                self.pos += 1
            return _name_selector(self.text[start : self.pos])
        self.fail("expected a member name or * after the dot")

    def bracketed(self):
        """[ selector, selector … ]: one or more selectors, separated by commas (section 2.5.1.1)."""
        self.pos += 1  # [
        selectors = []
        while True:
            self.blank()
            selectors.append(self.selector())
            self.blank()
            if self.eat("]"):
                return selectors
            if not self.eat(","):
                self.fail("expected , or ]")

    def selector(self):
        c = self.text[self.pos : self.pos + 1]
        if c in ("'", '"'):
            return _name_selector(self.string())
        if c == "*":
            self.pos += 1
            return _wildcard_selector
        if c == "?":
            self.fail("filter selectors (?…) are not part of this evaluator; the next lesson's evaluator runs them")
        if c == "(":
            self.fail("(…) is a script expression of the 2007 article, which RFC 9535 does not have")
        start = self.integer()
        after = self.pos
        self.blank()
        if not self.peek(":"):
            self.pos = after
            if start is None:
                self.fail("expected a selector")
            return _index_selector(start)
        self.pos += 1  # the slice selector: [start S] ":" S [end S] [":" [S step]] (section 2.3.4.1)
        self.blank()
        end = self.integer()
        if end is not None:
            after = self.pos
            self.blank()
            if not self.peek(":"):
                self.pos = after
        step = None
        if self.eat(":"):
            after = self.pos
            self.blank()
            step = self.integer()
            if step is None:
                self.pos = after
        return _slice_selector(start, end, step)

    def integer(self):
        """An integer of the query (section 2.3.3.1): no leading zeros, no -0, inside the I-JSON range; or None."""
        m = re.compile(r"-?[0-9]+").match(self.text, self.pos)
        if not m:
            return None
        digits = m.group().lstrip("-")
        if (digits.startswith("0") and len(digits) > 1) or m.group() == "-0":
            self.fail("integers have no leading zeros, and -0 is not one")
        value = int(m.group())
        if abs(value) > MAX_EXACT:
            self.fail("the integer is outside the range -(2^53 - 1) to 2^53 - 1")
        self.pos = m.end()
        return value

    def string(self):
        """A name in single or double quotes, with the escapes of section 2.3.1.1."""
        quote = self.text[self.pos]
        self.pos += 1
        out = []
        while True:
            if self.pos >= len(self.text):
                self.fail("the name has no closing quote")
            c = self.text[self.pos]
            self.pos += 1
            if c == quote:
                return "".join(out)
            if c == "\\":
                out.append(self.string_escape(quote))
            elif ord(c) < 0x20:
                self.fail("a control character must be escaped", self.pos - 1)
            else:
                out.append(c)

    def string_escape(self, quote):
        c = self.text[self.pos : self.pos + 1]
        self.pos += 1
        simple = {"b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t", "/": "/", "\\": "\\", quote: quote}
        if c in simple:
            return simple[c]
        if c != "u":
            self.fail(f"\\{c} is not an escape in a {'single' if quote == chr(39) else 'double'}-quoted name", self.pos - 2)
        code = self.hex4()
        if 0xDC00 <= code <= 0xDFFF:
            self.fail("a low surrogate needs a high surrogate before it")
        if 0xD800 <= code <= 0xDBFF:
            if not self.eat("\\u"):
                self.fail("a high surrogate needs a low surrogate after it")
            low = self.hex4()
            if not 0xDC00 <= low <= 0xDFFF:
                self.fail("a high surrogate needs a low surrogate after it")
            return chr(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
        return chr(code)

    def hex4(self):
        digits = self.text[self.pos : self.pos + 4]
        if len(digits) != 4 or any(d not in "0123456789abcdefABCDEF" for d in digits):
            self.fail("\\u needs four hexadecimal digits")
        self.pos += 4
        return int(digits, 16)


def _name_first(c):
    """ALPHA, _, or any character from U+0080 on (section 2.5.1.1)."""
    return c == "_" or ("a" <= c <= "z") or ("A" <= c <= "Z") or ord(c) >= 0x80
A hint

Every query starts with $ and names the path step by step: $.trains[1] is the second train, [-1] counts from the end, [*] takes every element, ..dep searches every level, and a name with a space needs brackets and quotes, such as ['Madgaon Junction']. Change one query at a time and run the sample tests: each failing test names its question.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · Python

From a normalized path to a JSON Pointer

Write to_json_pointer(path) in pointer.py. It takes a normalized path of RFC 9535, such as $['books'][0]['title'], and returns the JSON Pointer of RFC 6901 that names the same node, /books/0/title.

  • $ alone is the whole document, whose pointer is the empty string "".
  • A step in brackets and single quotes is a member name. Undo the escapes of a normalized path: \' is an apostrophe, \\ a backslash, \b, \f, \n, \r and \t are those control characters, and \u with four hexadecimal digits is the character with that code.
  • A step in brackets without quotes is an array position: its digits become the pointer's step.
  • In the pointer, every step starts with /, and inside a step ~ is written ~0 and / is written ~1.

So $['codes']['a/b'] becomes /codes/a~1b, and $['sections']['children\'s'][0] becomes /sections/children's/0.

Starter code · pointer.py

def to_json_pointer(path):
    """The RFC 6901 JSON Pointer of the node that an RFC 9535 normalized path names."""
    return path
The sample tests · test_pointer.py
from pointer import to_json_pointer


def test_root():
    """$ alone is the whole document: the empty pointer"""
    assert to_json_pointer("$") == ""


def test_names_and_positions():
    """names and array positions become pointer steps"""
    assert to_json_pointer("$['books'][0]['title']") == "/books/0/title"
    assert to_json_pointer("$['books'][3]['series']['title']") == "/books/3/series/title"
    assert to_json_pointer("$[10]") == "/10"


def test_pointer_escapes():
    """writes ~ as ~0 and / as ~1, the tilde first"""
    assert to_json_pointer("$['codes']['a/b']") == "/codes/a~1b"
    assert to_json_pointer("$['codes']['m~n']") == "/codes/m~0n"
    assert to_json_pointer("$['~1']") == "/~01"


def test_apostrophe_and_backslash():
    """undoes \\' and \\\\ in names"""
    assert to_json_pointer("$['sections']['children\\'s'][0]") == "/sections/children's/0"
    assert to_json_pointer("$['a\\\\b']") == "/a\\b"


def test_control_characters():
    """undoes \\n and the \\u escapes of control characters"""
    assert to_json_pointer("$['codes']['line\\nbreak']") == "/codes/line\nbreak"
    assert to_json_pointer("$['codes']['bell\\u0007']") == "/codes/bell\u0007"


def test_other_characters_stay():
    """keeps Devanagari letters, spaces and an empty name as they are"""
    assert to_json_pointer("$['codes']['दाम']") == "/codes/दाम"
    assert to_json_pointer("$['sections']['new arrivals'][1]") == "/sections/new arrivals/1"
    assert to_json_pointer("$['']") == "/"
A hint

Read the path from position 1, just after $. Each step starts with [: when the next character is ', collect the name up to the next ' that is not escaped, turning each backslash escape into its character as you go; otherwise the step is the digits up to ]. Replace ~ before /, or the ~1 you have just written would turn into ~01.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

JSONPath & JMESPath Tester Paste a document of your own and try queries on it, with the normalized path of every node they select.

Fifteen questions cover the six ideas, from $ to the 2007 article. Whichever of them your answers so far leave weakest comes first, and any question you miss is asked once more before the end:

Practice that adapts to you

Practise this lesson

15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Every JSONPath query outside a filter starts with which character?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: $

    Section 2.2 of RFC 9535 says every query, except those inside filters, must begin with $.

  2. Question 2 of 15 On the document {"note": null}, what does $.note return?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: One node, at $['note'], whose value is null

    A match with the value null and no match at all are different answers: one node against none. Count the nodes to tell them apart.

  3. Question 3 of 15 What does the wildcard .* select from an object?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Every member value

    On an object the wildcard selects every member value, in an order RFC 9535 leaves open; on an array, every element in order.

  4. Question 4 of 15 Which sku does $.books[-2].sku select on the catalogue?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: BK-233

    The array has 4 books, so -2 is position 4 − 2 = 2, BK-233.

  5. Question 5 of 15 What does $.books[0, 0, 1].sku select?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Three nodes: BK-101, BK-101 and BK-205

    Each selector of a segment adds its own results, in the order of the selectors, so the first book appears twice. The run showed the same for ['sku', 'sku'].

  6. Question 6 of 15 On an array of 6 elements, which start and end does [::-1] use?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: Start 5, and an end before position 0

    The defaults depend on the sign of the step. For a negative step they are the last position and the place before the first, which is why the run printed all six days in reverse.

  7. Question 7 of 15 How many days does $.days[-10:2] select from the six?

    Type a number.

    Show the answer: Question 7 of 15

    Answer: 2 days

    -10 + 6 = -4 is clamped to 0, so the slice runs from position 0 up to 2: Mon and Tue, as the run printed.

  8. Question 8 of 15 In Python, days[2:100] on the six days gives Wed to Sat. What does $.days[2:100] select?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Wed, Thu, Fri and Sat

    Slices clamp start and end into the array, the same as Python's slices; the two differ only for a step of 0.

  9. Question 9 of 15 A report totals the values of $..price_paise on the catalogue. What does it add up?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: The four books' prices and the gift card's 50000

    The run printed 5 nodes for $..price_paise, the fifth at $['gift_cards'][0]['price_paise'].

  10. Question 10 of 15 A new book arrives with "bundle": {"price_paise": 9900} inside it. Which query still totals only the books' own prices?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: $.books[*].price_paise

    A descendant segment searches every level below where it starts. Only a path that names each step stays where you meant it to.

  11. Question 11 of 15 How does a normalized path write a line feed inside a member name?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: As \n

    Backspace, form feed, line feed, carriage return and tab are written \b, \f, \n, \r and \t; every other character below U+0020 as \u with four lowercase hexadecimal digits.

  12. Question 12 of 15 $.books[-1] and $.books[3] select the same node. What are their normalized paths?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: Both are $['books'][3]

    That is what makes a normalized path a safe label: two queries that reach one node give it the same path.

  13. Question 13 of 15 Why can the JSON Pointer /stock/0 not be turned into a normalized path without the document?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Because 0 may be an array position or a member called "0", and only the document says which

    Appendix C of RFC 9535 notes that a normalized path converts to a pointer by its syntax alone, while the other direction needs the document.

  14. Question 14 of 15 In the 2007 article, what is ( ) in a query?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: A script expression, worked out by the underlying script engine

    RFC 9535 has no script expressions, so a query that holds one is refused.

  15. Question 15 of 15 Two libraries return different nodelists for the same query. Which evidence best shows which one follows RFC 9535?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Its documentation names RFC 9535 and it passes the JSONPath Compliance Test Suite

    Ask for the standard and the test suite: a library that states RFC 9535 and runs the compliance suite gives the nodelists the standard defines.

Interview questions

Warm-up (fresher to 2–5 years): what does a JSONPath query return when nothing matches, and how do you tell that from a match whose value is null?

A query always returns a nodelist, and RFC 9535 says that a valid query must not produce errors when it runs. When nothing matches, the nodelist is empty: $.books[*].publisher on the catalogue returned no nodes at all. A member whose value is null is a match: on {"note": null}, $.note returns one node, at $['note'], whose value is null, because JSON null is a value like any other (section 2.6). So test the length of the nodelist, not the truthiness of a value: no nodes means no match, and a node whose value is null means the member exists and holds null. The 2007 article’s implementation returned false when nothing matched, which is why older code still checks for false.

Two harder questions start from this lesson: a total that grew when a partner added gift cards, and a test that compares the nodelists of two services and fails only for some keys. Decide what you would check first, then open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A nightly job totals the values of $..price_paise in each partner's catalogue. One partner starts listing gift cards, and its total rises by exactly the gift cards' prices, with no error anywhere. Explain why, and how you would change the query and the job so that the next new kind of priced item cannot change a total unnoticed.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A contract test compares the nodelist of $.stock.* from a Python service with the one a Node.js service returns for the same document. The values always match as a set, but the test fails when stock is keyed by PIN codes such as 560001 and passes when it is keyed by city names. Explain the failure and fix the test.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does a JSONPath query start with, and what does it return?

    The root, $, then segments. It returns a nodelist, empty when nothing matches; a valid query never fails while it runs.

  2. When must a member name be written in brackets and quotes?

    When it holds spaces, apostrophes, hyphens or other characters the dot form cannot take.

  3. What does a JSONPath slice with a step of 0 select?

    Nothing. Python raises an error for such a slice, but RFC 9535 selects no elements. A slice is also clamped to the array.

  4. In which order does .. visit the nodes of a document?

    Each node before its descendants, and array elements in order. It finds a name at every depth, including matches you did not mean.

  5. What is a normalized path?

    The single canonical path of a node, such as $['books'][3]. Its syntax alone converts it to a JSON Pointer.

  6. How do you tell "no match" from "matched a null"?

    Count the nodes. No nodes means no match; one node holding null means the member exists and is null. The 2007 article's code returned false for no match.

  7. What did RFC 9535 remove from the 2007 JSONPath article?

    Script expressions run by an outside engine, such as [(@.length-1)]; the last element is now [-1]. It also defined slices, order and empty results exactly.

Key takeaways

  • A JSONPath query is $ followed by segments; each segment turns the nodelist so far into a new one, and a selector that finds nothing adds nothing, so a valid query answers with an empty nodelist instead of failing.
  • Names select members of objects, the wildcard every child, and an index one element counted from 0 or from the end; names with spaces, apostrophes or hyphens need brackets and quotes.
  • A slice start:end:step has defaults that depend on the sign of the step, clamps to the array, and selects nothing for a step of 0, where Python raises an error.
  • A descendant segment searches every level in a fixed order, each node before its descendants and arrays in order, so .. finds every match, including the ones you did not mean.
  • Every node has one normalized path, such as $['books'][3], a safe label that converts to a JSON Pointer by its syntax alone.
  • RFC 9535 dropped the 2007 article’s script expressions and fixed slices, order and empty results; choose libraries that follow the RFC and test against its compliance suite.

Terms in this lesson

nodelist
The result of a JSONPath query: a list of nodes, each a value together with its place in the document. An empty nodelist means that nothing matched, which is not the same as a match whose value is null.
normalized path
The one JSONPath form that names a single node exactly, such as $['books'][0]['title']: every name in single quotes and every array position counted from 0. RFC 9535 section 2.7 defines it.
segment
One step of a JSONPath query. A child segment such as [0] or .title picks children of every node found so far; a descendant segment such as ..title picks from those nodes and everything below them.
selector
One item inside a JSONPath segment that picks children of a node: a name, the wildcard *, an index, a slice or a filter. A segment may hold several, separated by commas.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.