At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at pooled-memory parsing with ParsedJsonDocument<T>.
Now let's look at the other side of the coin: mutation.
The V4/V5 trade-off
This is the fundamental design decision in V5, so it's worth being explicit about it.
In V4, every document is immutable. If you want to change a property, you call a With*() method that returns a new instance with the modification applied. The old instance is unchanged. This is the functional approach. It is safe, thread-friendly, and easy to reason about.
V4 is smarter than a naive copy-on-write: it avoids copying unmodified parts of the document and defers serialization to write operations. But it still has to create new immutable data structures wherever objects or arrays are modified. In a pipeline where you parse, modify, and write JSON repeatedly, that adds up to a lot of short-lived allocations.
V5 takes the opposite approach. JsonDocumentBuilder lets you mutate documents in place, using pooled memory managed by a JsonWorkspace. You create a workspace, build or modify documents, write the output, and dispose everything. The workspace recycles the memory for the next operation.
This is the builder approach. It is fast and low-allocation, but you need to be mindful of lifetimes and ownership. V5 includes version tracking to catch stale references at runtime, which mitigates the most common class of bugs.
Neither approach is universally better. If you want safety guarantees, use V4. If you want maximum throughput, use V5.
The workspace
Every mutable operation starts with a JsonWorkspace:
using JsonWorkspace workspace = JsonWorkspace.Create();
The workspace manages pooled buffers and Utf8JsonWriter instances. When you dispose it, all resources go back to the pool. You can create multiple builders within a single workspace. They share the pooled resources.
Building from scratch
The most common pattern is building an object using a builder delegate:
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = JsonElement.CreateBuilder(
workspace,
new(static (ref objectBuilder) =>
{
objectBuilder.AddProperty("name"u8, "Alice"u8);
objectBuilder.AddProperty("age"u8, 30);
objectBuilder.AddProperty("active"u8, true);
}));
Console.WriteLine(doc.RootElement.ToString());
// {"name":"Alice","age":30,"active":true}
That new(...) is a target-typed new. The compiler knows it needs a JsonElement.Source from the CreateBuilder parameter type. The static modifier on the delegate prevents accidental closure allocations. Use UTF-8 string literals (u8) for property names to avoid transcoding overhead.
What is Source?
JsonElement.Source is a ref struct that acts as a discriminated union. It can hold any value that might appear in a JSON document. It has implicit conversions from over 30 .NET types, so you rarely need to think about it:
- Primitives:
bool, int, long, double, decimal, float, short, byte, and the unsigned variants, plus Half, Int128, UInt128
- Strings:
string, ReadOnlySpan<char>, ReadOnlySpan<byte> (UTF-8)
- Dates:
DateTime, DateTimeOffset, and NodaTime types (LocalDate, OffsetDateTime, Period, etc.)
- Other:
Guid, Uri, BigNumber, BigInteger, JsonElement, JsonElement.Mutable
- Delegates:
JsonElement.ObjectBuilder.Build for nested objects, JsonElement.ArrayBuilder.Build for nested arrays
This is what makes the builder API feel natural. JsonElement.ObjectBuilder.AddProperty has direct overloads for all these types, so objectBuilder.AddProperty("age"u8, 30) just works. The int matches directly. For CreateBuilder and SetProperty, your value implicitly converts to a Source. Either way, you just pass values. The type system handles the rest.
For objects and arrays, you pass a builder delegate:
// Object builder - delegate receives ref JsonElement.ObjectBuilder
objectBuilder.AddProperty("address"u8, static (ref addressBuilder) =>
{
addressBuilder.AddProperty("city"u8, "London"u8);
});
// Array builder - delegate receives ref JsonElement.ArrayBuilder
objectBuilder.AddProperty("tags"u8, static (ref tagsBuilder) =>
{
tagsBuilder.AddItem("admin"u8);
tagsBuilder.AddItem("user"u8);
});
There's also a generic Source<TContext> variant for passing context to a delegate without allocating a closure - useful in hot paths where even a single delegate allocation matters.
Nested objects and arrays
Builder delegates compose naturally:
using var doc = JsonElement.CreateBuilder(
workspace,
new(static (ref objectBuilder) =>
{
objectBuilder.AddProperty("user"u8, static (ref userBuilder) =>
{
userBuilder.AddProperty("name"u8, "Alice"u8);
userBuilder.AddProperty("roles"u8, static (ref rolesBuilder) =>
{
rolesBuilder.AddItem("admin"u8);
rolesBuilder.AddItem("editor"u8);
});
});
}));
// {"user":{"name":"Alice","roles":["admin","editor"]}}
Parse-and-mutate
In most real-world scenarios, you're receiving JSON, modifying it, and sending it on. Parse directly into a mutable builder for the best performance:
using JsonWorkspace workspace = JsonWorkspace.Create();
// Single pass - UTF-8 bytes become the builder's backing store
using var builder = JsonDocumentBuilder<JsonElement.Mutable>.Parse(
workspace,
"""{"status":"pending","count":5}""");
JsonElement.Mutable root = builder.RootElement;
root.SetProperty("status", "completed"u8);
root.SetProperty("count", 10);
Console.WriteLine(root.ToString());
// {"status":"completed","count":10}
All the same Parse overloads are available - from strings, UTF-8 bytes, streams, or a Utf8JsonReader.
Retaining the original
If you need to keep an immutable copy alongside the mutable version - for auditing, comparison, or read-only queries - use the two-step approach:
using JsonWorkspace workspace = JsonWorkspace.Create();
using var sourceDoc = ParsedJsonDocument<JsonElement>.Parse(
"""{"name":"Original","value":100}""");
// Convert to mutable - sourceDoc remains unchanged
using var builder = sourceDoc.RootElement.CreateBuilder(workspace);
builder.RootElement.SetProperty("name", "Modified"u8);
Console.WriteLine(sourceDoc.RootElement.ToString()); // {"name":"Original",...}
Console.WriteLine(builder.RootElement.ToString()); // {"name":"Modified",...}
Version tracking
Here's how V5 catches the aliasing bugs that in-place mutation can introduce.
Every JsonDocumentBuilder tracks a ulong version number. When you obtain a mutable element reference, it captures the current version. If you modify the document through a different reference and then try to use the stale one, V5 throws InvalidOperationException:
JsonElement.Mutable root = builder.RootElement;
JsonElement.Mutable name = root.GetProperty("name");
// Mutate through root - this bumps the version
root.SetProperty("name", "Changed"u8);
// Try to use the stale reference
name.GetString(); // throws InvalidOperationException
This won't catch every possible misuse, but it catches the most common class of bugs: holding a reference across a mutation boundary.
Clone and freeze
When you're working with a mutable document, you sometimes need an immutable copy. It is a value that won't go stale when you mutate the builder again. Freeze() gives you exactly that.
Freeze() performs a fast blit of the metadata and value backing arrays into a new immutable document registered in the same workspace, without a serialization round-trip. The result is immutable, and you can keep mutating the original builder while the frozen element stays valid:
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument<JsonElement>.Parse("""{"name": "Alice", "age": 30}""");
using var builder = doc.RootElement.CreateBuilder(workspace);
builder.RootElement.SetProperty("age"u8, 31);
// Freeze - cheap immutable copy, stays in the workspace
JsonElement frozen = builder.RootElement.Freeze();
// Keep mutating - the frozen element is unaffected
builder.RootElement.SetProperty("age"u8, 99);
Assert.Equal(31, frozen.GetProperty("age"u8).GetInt32()); // still 31
The frozen element is tied to the workspace's lifetime. It's backed by pooled memory and will be cleaned up with the workspace.
On the other hand, if you need data to escape the workspace entirely (e.g. to return it to a caller who shouldn't need to worry about workspaces or lifetimes) then that's what Clone() is for.
Clone() serializes the mutable element into a fresh immutable ParsedJsonDocument that owns its own memory on the GC heap. The clone is completely independent of both the builder and the workspace, so it remains valid after both are disposed:
JsonElement clone;
using (JsonWorkspace workspace = JsonWorkspace.Create())
using (var doc = ParsedJsonDocument<JsonElement>.Parse("[[[]]]"))
using (var builder = doc.RootElement.CreateBuilder(workspace))
{
clone = builder.RootElement[0].Clone();
// builder and workspace are disposed here
}
// clone is still valid - it owns its own memory
Assert.Equal("[[]]", clone.GetRawText());
Use Freeze() for cheap immutable copies within the workspace scope. Use Clone() when the result needs to escape entirely.
Snapshots for rollback
JsonDocumentBuilderSnapshot<T> captures the complete state of a builder so you can restore it later. This is useful for speculative mutations where you may want to roll back if something goes wrong. We mentioned this briefly in the context of JSON Patch, but it's a general-purpose mechanism.
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument<JsonElement>.Parse("""{"status": "pending", "retries": 0}""");
using var builder = doc.RootElement.CreateBuilder(workspace);
// Take a snapshot before applying changes
using var snapshot = builder.CreateSnapshot();
builder.RootElement.SetProperty("status"u8, "processing");
builder.RootElement.SetProperty("retries"u8, 1);
// Something went wrong - roll back to the snapshot
builder.Restore(snapshot);
Assert.Equal("pending", builder.RootElement.GetProperty("status"u8).GetString());
Assert.Equal(0, builder.RootElement.GetProperty("retries"u8).GetInt32());
CreateSnapshot() creates a rented copy of the builder's internal state, and Restore() copies it back. The snapshot is IDisposable and must be disposed to return the rented buffers to the pool.
Dynamic construction with runtime data
Real-world JSON isn't all static strings. Here's how you mix structure with runtime data:
string[] tags = ["admin", "user", "active"];
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = JsonElement.CreateBuilder(
workspace,
new((ref objectBuilder) =>
{
objectBuilder.AddProperty("id"u8, Guid.NewGuid());
// Runtime collection becomes a JSON array
objectBuilder.AddProperty("tags"u8, (ref tagsBuilder) =>
{
foreach (string tag in tags)
{
tagsBuilder.AddItem(tag);
}
});
}));
The delegate in this example captures tags from the enclosing scope, so it can't be static. That's often fine, but if you need to avoid the closure allocation there is a Source<TContext> overload that lets you pass context explicitly:
string[] tags = ["admin", "user", "active"];
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = JsonElement.CreateBuilder(
workspace,
tags,
static (in string[] tags, ref JsonElement.ObjectBuilder objectBuilder) =>
{
objectBuilder.AddProperty("id"u8, Guid.NewGuid());
objectBuilder.AddProperty("tags"u8, tags,
static (in string[] tags, ref JsonElement.ArrayBuilder tagsBuilder) =>
{
foreach (string tag in tags)
{
tagsBuilder.AddItem(tag);
}
});
});
The context parameter is passed by in reference, so there is no copying or boxing. Every delegate is static, so there are no closure allocations.
Generated types and the builder
Everything in this post uses JsonElement and JsonElement.Mutable, but the same builder API works for all generated types. If you have a Person type generated from a JSON Schema, you can create a builder, mutate it, and freeze it in exactly the same way.
The difference is that the generated types only emit .NET members that are compatible with the constraints in their schema. A Person.Mutable will have SetProperty for the properties defined in the schema, and conversions to and from .NET types that match the schema's type constraints. For example, numeric conversions are only available if the schema allows the value to be a number. This means the compiler catches type errors at build time rather than at runtime.
At a glance
|
JsonNode |
V4 With*() |
V5 JsonDocumentBuilder |
| Mutation |
In-place, per-node |
Returns new immutable instance |
In-place, pooled |
| Memory |
Managed heap per node |
Managed heap per copy |
ArrayPool via workspace |
| Safety |
No aliasing protection |
Immutability prevents aliasing |
Version-tracked stale detection |
| Best for |
Long-lived trees |
Safety-critical pipelines |
High-throughput request/response |
Next up
In the next post, we'll look at the standalone evaluator. It's a lightweight code generation mode that produces just a validator and annotation collector, without the full type hierarchy. It's ideal for schema-driven tooling like form generators and configuration editors.