- RFC 9535
- Jayway dialect
- JSON Pointer
- Zero dependencies
- Swift 6
- MIT
JSONPath for Swift.
Both dialects. No dependencies.
Query, point into and rewrite JSON with the IETF standard or with the Jayway syntax your users already know. Pure Swift with no Foundation and no imports, so it runs anywhere Swift runs: iOS apps, macOS tools, Linux servers.
import JSONPath
let document = try JSONValue(parsing: json)
// RFC 9535: all 715 compliance cases pass
try JSONPath("$.store.book[?@.price < 10].title")
.values(in: document)
// ["Sayings of the Century", "Moby Dick"]
// The Jayway dialect, path functions included
try JSONPath("$.store.book.length()", dialect: .jayway)
.values(in: document) // [3]
// Rewrite every match, key order kept
var copy = document
try copy.replace(JSONPath("$..price"), with: 0)
What is JSONPath
XPath for JSON, now an IETF standard.
A JSONPath expression selects values from a JSON document, the way a CSS selector picks elements from a page. It started as an informal proposal, spread through Jayway JsonPath in Java, and became RFC 9535 in 2024. On the bookstore document from the README:
Every price, at any depth.
A filter on each book, then a member of the ones that pass.
A path function, from the dialect most existing queries are written in.
Each result comes with its location as a normalized path ($['store']['book'][0]['title']) or a JSON Pointer (/store/book/0/title), so you can go back and change it.
JSON Pointer
JSONPath finds values.
JSON Pointer says exactly where one is.
RFC 6901 is the addressing format the JSON ecosystem already speaks: $ref in JSON Schema and OpenAPI, path in JSON Patch, the locations validators report errors at. A pointer names one location, token by token, so resolving it is a walk down the document, never a search.
Each token is a member name, or an index when the value is an array.
~1 stands for / and ~0 for ~. Build a pointer from tokens and it escapes them for you.
The URI fragment form, as written after the # of a $ref, is accepted.
| JSONPath | JSON Pointer | |
|---|---|---|
| Selects | any number of nodes, from a search | exactly one location, or nothing |
| Looks like | $..book[?@.price < 10] | /store/book/0/price |
| Standard | RFC 9535 (and the Jayway dialect) | RFC 6901 |
| Use it to | query, filter and extract | store, report, reference and patch a location |
| In swift-jsonpath | JSONPath, NormalizedPath | JSONPointer |
- From a query to pointers. Every node a query returns carries its normalized path, and
.jsonPointerturns it into a pointer: search once, then store, report or patch each match by its pointer. - Set by pointer.
set(_:at:)creates a missing object member and appends to an array with-, like JSON Patch'sadd. A missing parent or an index past the end throws aMutationErrorinstead of guessing. - Strict where the RFC is.
~must be followed by0or1, and a non-empty pointer starts with/: anything else throws aJSONPointer.ParseError. An array index with leading zeros never matches. - A value you can keep.
JSONPointerisHashable,SendableandCodableas its string form, so a field likelet location: JSONPointerfits in your own models.
// From a query's results to pointers
try JSONPath("$..price").paths(in: document).map(\.jsonPointer.description)
// ["/store/book/0/price", "/store/book/1/price", "/store/book/2/price", "/store/bicycle/price"]
// Setting a value: missing object members are created, and `-` appends to an array
var copy = document
try copy.set("red", at: JSONPointer("/store/bicycle/color"))
try copy.set(["title": "Dune", "price": 9.99], at: JSONPointer("/store/book/-"))
Features
Everything you need to work with JSON by path.
One small package: the query languages, the JSON model they run on, and the tools around them.
RFC 9535, to the letter
The strict grammar, well-typedness checked when the path is parsed, and the length count match search value functions with I-Regexp. Passes all 715 cases of the official compliance suite.
The Jayway dialect, verified
[?(…)] filters, loose comparisons, =~ in contains anyof and the other operators, and path functions like .avg(). Passes Sextant's whole test suite. JSONPath(anyDialect:) accepts whichever a user types.
Mutations and JSON Pointer
Replace, remove or transform every node a path selects. Address one value with an RFC 6901 JSONPointer, convert a query's results into pointers, and set values by pointer, appending with -.
An order-preserving JSON model
JSONValue keeps object members in document order through parsing, querying, mutation and printing. A strict RFC 8259 parser, a pretty printer, and a Codable bridge that keeps key order too.
Built for editors
Token ranges for syntax colouring in both dialects, even on an expression that is still being typed. Typed errors with the position of the problem, and JSONPath.validate for a quick yes or no.
Zero dependencies, Swift 6
No Foundation and no packages: on Linux it links only the standard library and libm. Strict concurrency, and every public type is a Sendable value.
Two dialects, one API
The standard for new queries. Jayway for the ones you already have.
Pick the dialect per path. Both return the same JSONValue results and normalized paths, and the differences between them are written down, not discovered in production.
| Feature | .rfc9535 | .jayway |
|---|---|---|
| Names, wildcards, indices, slices, unions, descendants | ✓ | ✓ |
| Filters | [?expr]; on objects, tests the members | [?(expr)] / [?expr]; on objects, tests the object itself |
| Comparisons | strict and typed | loose ('1' == 1), plus === / !== |
| Functions in filters | length count match search value | same |
| Jayway operators | – | =~ /re/flags, in, nin, contains, size, empty, anyof, … |
| Path functions | – | length() avg() sum() min() max() concat() keys() … |
Implicit root (store.book) | – | ✓ |
| Relation on a missing path | false | error (the filter fails) |
The complete table is in the README.
Code tour
Short, typed, and checked by the compiler.
These snippets come from the README, which the test suite compiles and runs, so they stay correct.
Parse a document, then ask a path for values, normalized paths or both. Filters, descendants and the RFC 9535 functions work as the standard says.
import JSONPath
let document = try JSONValue(parsing: """
{"store": {"book": [
{"category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95},
{"category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99},
{"category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "price": 22.99}
], "bicycle": {"color": "red", "price": 19.95}}}
""")
let cheap = try JSONPath("$.store.book[?@.price < 10].title")
try cheap.values(in: document) // ["Sayings of the Century", "Moby Dick"]
try cheap.paths(in: document) // [$['store']['book'][0]['title'], $['store']['book'][1]['title']]
try cheap.nodes(in: document) // locations + values
try document.query("$..price") // [8.95, 8.99, 22.99, 19.95]
try JSONPath("$.store.book[?match(@.author, '.*Tolkien')].title").values(in: document)
try JSONPath("$.store.book[?count(@.isbn) > 0].title").values(in: document)Pass dialect: .jayway for queries written for Jayway JsonPath, or let anyDialect choose when you cannot know which one you were given.
let fiction = try JSONPath("store.book[?(@.category == 'fiction' && @.price > 10)].title", dialect: .jayway)
let pattern = try JSONPath("$..book[?(@.author =~ /.*REES/i)].price", dialect: .jayway)
let count = try JSONPath("$.store.book.length()", dialect: .jayway) // [3]
let inList = try JSONPath("$.store.book[?(@.category in ['fiction'])]", dialect: .jayway)try JSONPath(anyDialect: "$.store.book[?@.price < 10]").dialect // .rfc9535
try JSONPath(anyDialect: "$.store.book.length()").dialect // .jaywayAddress one value with an RFC 6901 pointer, turn a query's results into pointers, and set values by pointer.
let title = try JSONPointer("/store/book/0/title")
title.resolve(in: document) // "Sayings of the Century"
title.tokens // ["store", "book", "0", "title"]
try JSONPointer("#/store/bicycle") // the URI fragment form is accepted
try JSONPointer("/a~1b/m~0n").tokens // ["a/b", "m~n"]: `~1` is "/", `~0` is "~"
JSONPointer(tokens: ["a/b", "m~n"]).description // "/a~1b/m~0n"
try JSONPointer("/store/missing").resolve(in: document) // nil
// From a query's results to pointers
try JSONPath("$..price").paths(in: document).map(\.jsonPointer.description)
// ["/store/book/0/price", "/store/book/1/price", "/store/book/2/price", "/store/bicycle/price"]
// Setting a value: missing object members are created, and `-` appends to an array
var copy = document
try copy.set("red", at: JSONPointer("/store/bicycle/color"))
try copy.set(["title": "Dune", "price": 9.99], at: JSONPointer("/store/book/-"))Change every node a path selects, or one location. JSONValue is a value type: the original is untouched.
var copy = document
try copy.replace(JSONPath("$..price"), with: 0)
try copy.remove(JSONPath("$.store.bicycle"))
try copy.map(JSONPath("$..title")) { .string($0.stringValue!.uppercased()) } // return nil to remove
try copy.set("new", at: JSONPointer("/store/book/0/tag"))
try copy.set(1, at: NormalizedPath(["store", "book", 0, "price"]))Decode query results straight into your types, and encode them back without losing key order.
struct Book: Codable { let title: String; let price: Double }
let books = try JSONValueDecoder().decode(Book.self, from: JSONPath("$.store.book[*]").values(in: document))
let value = try JSONValue(encoding: books) // keeps key order
print(value.jsonString(prettyPrinted: true))Validate as the user types, show where the error is, and colour the expression with token ranges that never reject incomplete text.
JSONPath.validate("$.store[?@.price <]") // a description of the error, or nil when valid
do {
_ = try JSONPath("$.a[", dialect: .rfc9535)
} catch let error as JSONPath.ParseError {
print(error.message, error.position)
}let query = "$[?@.price < 10].title"
for token in JSONPath.tokens(in: query) {
print(token.kind, query[token.range]) // root $, bracket [, punctuation ?, current @, …
}Built for
Apps, tools and servers that read JSON they did not design.
Configuration and remote data
Pull the values you need out of a large or changing payload with one expression instead of a tower of Codable types, and decode just that part into your model.
API testing and workflows
Assert on responses and extract values between steps. swift-arazzo evaluates OpenAPI Arazzo JSONPath criteria with it.
Developer tools and editors
A query field that accepts both dialects, validates as you type and colours the expression, with results you can jump to by normalized path or JSON Pointer.
Porting from Java, Android or Sextant
Existing Jayway queries keep their meaning: loose comparisons, operators and path functions behave as Jayway's, checked against Sextant's own tests.
Quality and speed
Checked against the standard and against Jayway. Measured on every change.
A differential run against Sextant agrees on every input except where Sextant itself is buggy; those differences are commented in the tests.
| Benchmark on a 1.4 MB document (p50, Apple silicon) | 0.0.4 | 0.1.0 |
|---|---|---|
| Parse the JSON text | 26.4 ms | 7.9 ms |
Run 8 RFC 9535 queries, from $.store.book[0].title to $..* | 117 ms | 15.3 ms |
| Run the same 8 queries in the Jayway dialect | 66.7 ms | 9.3 ms |
Find every author with $..author | 9.9 ms | 1.3 ms |
Replace all 5,000 prices with $..price | 419 ms | 10.0 ms |
| Remove the books a filter selects | 111 ms | 2.4 ms |
Benchmarks use package-benchmark and live in the repository: swift package --package-path Benchmarks benchmark.
Install
Add it to your package.
dependencies: [
.package(url: "https://github.com/mackoj/swift-jsonpath.git", from: "0.1.0"),
],
targets: [
.target(name: "MyTool", dependencies: [.product(name: "JSONPath", package: "swift-jsonpath")]),
]
- Swift 6.0+ on Linux, macOS 13+, iOS 16+, tvOS 16+, watchOS 9+ and visionOS 1+.
- One product,
JSONPath: both dialects,JSONValue, JSON Pointer, mutations and theCodablebridge. - API reference on the Swift Package Index; guide and examples in the README.