Introducing Corvus.Text.Json V5: JMESPath - On Average 28× Faster JSON Queries
At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at JSONata for query and transformation.
Now let's look at JMESPath. It's a simpler, standardised alternative where the performance story is, frankly, quite something.
What is JMESPath?
JMESPath is a query language for JSON. You've likely already used it. It's the expression language behind aws --query, az --query, and jp (the JMESPath CLI). It supports path navigation, projections, filtering, slicing, multiselect, pipe expressions, and built-in functions like sort, sum, min, max, join, and contains.
Unlike JSONata, JMESPath is deliberately limited. There is no arithmetic, no user-defined functions, and no side effects. It's a pure query language with a standardised specification and a cross-implementation conformance test suite.
The Corvus implementation passes all 892 test cases. It achieves 100% conformance.
Quick start
dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.JMESPath
This is the simplest approach, with a cloned result and no workspace management:
using Corvus.Text.Json;
using Corvus.Text.Json.JMESPath;
using ParsedJsonDocument<JsonElement> document = ParsedJsonDocument<JsonElement>.Parse("""
{
"locations": [
{"name": "Seattle", "state": "WA"},
{"name": "New York", "state": "NY"},
{"name": "Bellevue", "state": "WA"},
{"name": "Olympia", "state": "WA"}
]
}
"""u8);
JsonElement result = JMESPathEvaluator.Default.Search(
"locations[?state == 'WA'].name | sort(@) | {WashingtonCities: join(', ', @)}",
document.RootElement);
Console.WriteLine(result);
// {"WashingtonCities":"Bellevue, Olympia, Seattle"}
That expression filters an array by state, extracts the name field, sorts alphabetically, and constructs a new object with a joined string. All of that happens in a single pipeline.
Zero-allocation evaluation
For production use with controlled lifetime:
using Corvus.Text.Json;
using Corvus.Text.Json.JMESPath;
using var dataDoc = ParsedJsonDocument<JsonElement>.Parse(jsonData);
using JsonWorkspace workspace = JsonWorkspace.Create();
JsonElement result = JMESPathEvaluator.Default.Search(
"people[?age > `30`].name",
dataDoc.RootElement,
workspace);
The workspace pools all intermediate memory. The expression is compiled and cached on first use. Subsequent calls with the same expression are zero-allocation.
Performance
Here's where JMESPath gets interesting. All benchmarks use JmesPath.Net as the baseline:
| Benchmark | JmesPath.Net | Corvus RT | Corvus CG | RT Alloc | CG Alloc |
|---|---|---|---|---|---|
Simple field (a) |
6,446 ns | 44 ns | 35 ns | 0 B | 0 B |
Sub-expression (a.b.c) |
6,659 ns | 51 ns | 49 ns | 0 B | 0 B |
| Long string (1 KB value) | 3,874 ns | 54 ns | 11 ns | 0 B | 0 B |
| Chained filter | 2,697 ns | 18 ns | 11 ns | 0 B | 0 B |
| 50 chained fields | 21,692 ns | 563 ns | 80 ns | 0 B | 0 B |
| 50 chained pipes | 24,274 ns | 384 ns | 92 ns | 0 B | 0 B |
Deep projection ([*].[*].[*]) |
82,276 ns | 148 ns | 18 ns | 0 B | 0 B |
| Nested sum | 53,033 ns | 3,857 ns | 3,846 ns | 0 B | 0 B |
| Min by age | 18,015 ns | 1,591 ns | 1,588 ns | 0 B | 0 B |
Across all 21 benchmarks, the geometric mean speedup is approximately 28× for the interpreted runtime. Almost every scenario shows zero allocation. That compares with 3–80 KB per query for JmesPath.Net.
The speedups come from several architectural choices:
- No per-expression object model - JmesPath.Net parses expressions into an intermediate AST with per-node allocations. Corvus compiles to delegate trees with pooled workspace memory.
- Pipe fusion - consecutive pipe stages are fused into a single pass where possible.
- UTF-8 throughout - property comparisons operate on raw UTF-8 bytes, avoiding transcoding.
- Source-generator optimisation - the code generator eliminates delegate dispatch entirely, which is why CG times are often even lower.
We quote the geometric mean rather than the maximum because the range is enormous (5× to 556×). Individual results depend heavily on the expression pattern. Aggregate functions (sum, min, max) show smaller speedups because both implementations spend most of their time doing arithmetic; simple navigation and projection show the largest gains because the overhead of the expression model dominates in JmesPath.Net.
Source generator
When expressions are known at build time:
using Corvus.Text.Json.JMESPath;
namespace MyApp.Expressions;
[JMESPathExpression("Expressions/active-people.jmespath")]
public static partial class ActivePeopleExpression;
The expression file is registered as an AdditionalFiles item in your project.
CLI code generation
For expressions managed outside the build pipeline:
corvusjson jmespath Expressions/active-people.jmespath \
--className ActivePeopleQuery \
--namespace MyApp.Queries \
--outputPath Generated/
This reads the expression file, generates an optimized static C# class, and writes it to the output path. The generated code is identical to what the source generator produces.
When to choose JMESPath vs JSONata
| JMESPath | JSONata | |
|---|---|---|
| Complexity | Simple, standardised | Expressive, Turing-complete |
| Arithmetic | No | Yes |
| String manipulation | join, reverse, sort |
Full: $substring, $replace, $split, regex |
| User-defined functions | No | Yes |
| Ecosystem | AWS CLI, Azure CLI, jp | Node-RED, IBM Cloud Pak |
| Best for | Querying and extracting | Transforming and reshaping |
If your team already uses JMESPath through the AWS or Azure CLI, it's the natural choice for JSON querying in your .NET code. If you need more expressive power, JSONata is the right tool. That includes arithmetic, string manipulation, and custom functions.
Next up
In the next post, we'll complete the query language trilogy with JsonLogic - a safe, side-effect-free rule engine for evaluating business rules stored as JSON.