Skip to content
Matthew Adams By Matthew Adams Co-Founder · 4 min read
Introducing Corvus.Text.Json V5: JSONata - Query and Transform JSON

At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at the standalone evaluator and annotations.

Now we're moving on to query languages. V5 includes three, and we'll start with JSONata, the most expressive of the trio.

What is JSONata?

JSONata is a functional query and transformation language for JSON. It gives you path navigation, filtering, higher-order functions ($map, $filter, $reduce, $sort), object construction, string manipulation, arithmetic, regex, and user-defined functions. It's Turing-complete, and it's the standard expression language for tools like Node-RED and IBM Cloud Pak.

The Corvus implementation passes all 1,665 official test suite cases. It achieves 100% conformance on both .NET 10.0 and .NET Framework 4.8.1.

Quick start

Install the packages:

dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.Jsonata

The simplest approach is string in, string out:

using Corvus.Text.Json.Jsonata;

string? result = JsonataEvaluator.Default.EvaluateToString(
    "FirstName & ' ' & Surname",
    """{"FirstName": "Fred", "Surname": "Smith", "Age": 28}""");

Console.WriteLine(result); // "Fred Smith"

That's three lines of code to evaluate a JSONata expression, with no document model, workspace management, or disposal to worry about.

Zero-allocation evaluation

For production code where you control the lifetime of parsed documents, use the full API:

using Corvus.Text.Json;
using Corvus.Text.Json.Jsonata;

using var dataDoc = ParsedJsonDocument<JsonElement>.Parse(
    """
    {
        "FirstName": "Fred",
        "Surname": "Smith",
        "Age": 28,
        "Address": { "City": "London" }
    }
    """);

JsonElement result = JsonataEvaluator.Default.Evaluate(
    "FirstName & ' ' & Surname",
    dataDoc.RootElement);

Console.WriteLine(result.GetRawText()); // "Fred Smith"

On subsequent calls with the same expression, the compiled delegate tree is cached internally, so you get zero-allocation evaluation from the second call onward.

If you want explicit control over the workspace that pools intermediate memory, pass one in:

using JsonWorkspace workspace = JsonWorkspace.Create();

JsonElement result = JsonataEvaluator.Default.Evaluate(
    "FirstName & ' ' & Surname",
    dataDoc.RootElement,
    workspace);

Practical example: reshaping API responses

JSONata really shines when you need to reshape data. Say you get a complex employee record from an API and need a simpler structure:

{
    "name": FirstName & " " & Surname,
    "mobile": Contact.Phone[type = "mobile"].number,
    "city": Address.City
}

This navigates nested objects, filters arrays by a predicate, and constructs a new shape. It all happens in a single expression. In C#:

string expression = """
    {
        "name": FirstName & " " & Surname,
        "mobile": Contact.Phone[type = "mobile"].number,
        "city": Address.City
    }
    """;

string? result = JsonataEvaluator.Default.EvaluateToString(
    expression,
    employeeJson);

Evaluating to a strongly-typed result

EvaluateToString is convenient for interop with existing code, but the real power is evaluating directly to a generated JSON type. The generic Evaluate<T> overload returns the result as any schema-generated type:

// ContactSummary is generated from a JSON Schema:
// { "type": "object", "properties": {
//     "name": { "type": "string" },
//     "mobile": { "type": "string" },
//     "city": { "type": "string" }
// }}

using var dataDoc = ParsedJsonDocument<JsonElement>.Parse(employeeJson);
using JsonWorkspace workspace = JsonWorkspace.Create();

ContactSummary summary = JsonataEvaluator.Default.Evaluate<ContactSummary>(
    expression,
    dataDoc.RootElement,
    workspace);

// Strongly-typed access - no casting, no string parsing
string name = (string)summary.Name;
string city = (string)summary.City;

// Schema validation is built in
bool isValid = summary.EvaluateSchema();

The JSONata expression reshapes the data, and the generated type gives you compile-time safety over the result. You can validate the output against its schema, pass it to other V5 APIs, mutate it with a builder, or serialize it directly.

Three modes of evaluation

1. Interpreted (runtime)

Best when expressions are determined at runtime. Use this for user-supplied queries, configuration-driven transforms, or any scenario where the expression isn't known at compile time.

2. Source generator

When expressions are known at build time, the source generator compiles them to optimized static C#:

using Corvus.Text.Json.Jsonata;

namespace MyApp.Expressions;

[JsonataExpression("Expressions/full-name.jsonata")]
public static partial class FullNameExpression;

The expression file (full-name.jsonata) contains the JSONata expression and is registered as an AdditionalFiles item in your project. The generated code eliminates delegate dispatch and enables constant folding. Pure arithmetic like 1 + 2 * 3 is folded to a literal value at compile time, giving sub-nanosecond "evaluation."

3. CLI code generation

For expressions managed outside the build pipeline:

corvusjson jsonata Expressions/full-name.jsonata \
    --className FullNameExpression \
    --namespace MyApp.Expressions \
    --outputPath Generated/

Performance

Here are some representative benchmarks comparing the Corvus interpreted runtime, code-generated evaluator, and Jsonata.Net.Native v3.0.0 (the reference .NET implementation). All measured on .NET 10.0:

Employee transform (multi-step expression)

{
    "name": Employee.FirstName & " " & Employee.Surname,
    "mobile": Contact.Phone[type = "mobile"].number
}
Method Mean Allocated
Corvus (interpreted) 1,658 ns 960 B
Corvus (code-gen) 1,585 ns 240 B
Jsonata.Net.Native 3,032 ns 9,920 B

The interpreted evaluator is 1.8× faster with 90% less allocation. Code-gen is 1.9× faster with 97% less allocation.

Property navigation and math

Scenario Corvus RT Code-Gen Jsonata.Net.Native RT Alloc Native Alloc
Deep path 650 ns 458 ns 544 ns 120 B 1,816 B
Array index 87 ns 79 ns 317 ns 0 B 1,408 B
$round 461 ns 298 ns 936 ns 0 B 3,224 B
$contains 252 ns 153 ns 895 ns 0 B 3,136 B
String concat 366 ns 177 ns 332 ns 0 B 1,408 B

Across 32 benchmarks, the geometric mean speedup is approximately for the interpreted evaluator, with 90–100% less memory in almost every scenario. The memory reduction is the stronger headline. Most benchmarks show zero allocation where the reference implementation allocates 1–10 KB.

The full benchmark suite has 95 scenarios. We quote the geometric mean rather than the maximum because individual expression patterns vary considerably. You can run the benchmarks yourself from the repository.

Try it in your browser

The JSONata Playground lets you experiment with expressions using the Corvus interpreted runtime - no installation needed.

Next up

In the next post, we'll look at JMESPath - a simpler, standardised query language used by AWS CLI and Azure CLI, where the performance story is even more dramatic.

FAQs

What is JSONata? JSONata is an expressive, Turing-complete functional query and transformation language for JSON. It supports path navigation, filtering, higher-order functions, object construction, string manipulation, arithmetic, and user-defined functions.
How does Corvus compare to Jsonata.Net.Native? On average 2× faster across 32 benchmark scenarios with 90–100% less memory allocation. The source generator can be even faster, with constant folding reducing pure arithmetic to sub-nanosecond evaluation.
Can I try JSONata without writing any code? Yes. The Corvus JSONata Playground at /playground-jsonata/ lets you evaluate expressions in your browser using the interpreted runtime.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.