Intro to Serialization with Source Generation in System.Text.Json

System.Text.Json serializer uses reflections for serialization and deserialization. Reflection is slow. 

.NET 5, alongside C# 9, introduces source generators. It allows the generation of code during compilation. Source generation is particularly useful for scenarios where code needs to be generated based on metadata, configuration, or other input at compile time.

You can create your source generators. However, Microsoft provides many built-in source generators. One of them can help boost serialization performance.  

Source Generation

It’s easy to start using source generation for serialization with System.Text.Json serializer.

You have to create your source generation context to pass it to the serializer.

public record Person(
    string FirstName,
    string LastName,
    int Age);

[JsonSerializable(typeof(Person))]
public partial class MySourceGenerationContext
        : JsonSerializerContext
{ }

We created MySourceGenerationContext derived from JsonSerializerContext for the record Person. 

The context must be partial because the source generator will augment it with the needed code. It’ll look like this.

[JsonSerializable(typeof(Person))]
public partial class MySourceGenerationContext
        : JsonSerializerContext
{
    public static MyJsonContext Default { get; }

    public JsonTypeInfo<Person> Person { get; }

    public MySourceGenerationContext(
        JsonSerializerOptions options) : base(options)
    { }

    public override JsonTypeInfo GetTypeInfo(Type type)
        => ...;
}

Why do we need this context? 

The serializer needs metadata to access the object’s properties. You can also configure how to serialize the object, for instance, using attributes or serializer options.

The serializer computes metadata at run time using reflection. Once metadata is generated, it is cached for reuse. But this “warm-up” phase takes time and allocation. 

The source generator computes metadata at compile time. It can also generate highly optimized serialization logic for some serialization features specified ahead-of-time. By default, the source generator generates type-metadata initialization logic and serialization logic, but we can configure it to generate only one of the outputs.

Now is the time to see how to use the context with a serializer.

var person = new Person("John", "Doe", 30);

// Reflection
JsonSerializer.Serialize(person);

// Source generated
JsonSerializer.Serialize(person, MySourceGenerationContext.Default.Person);

You need to pass the context as a parameter to the Serialize/Deserialize method.

Another way is to set the context in the JsonSerializerOptions with other options.

var options = new JsonSerializerOptions
{
    TypeInfoResolver = MySourceGenerationContext.Default,
    WriteIndented = true
};

var json = JsonSerializer.Serialize(person, options);
//{
//  "FirstName": "John",
//  "LastName": "Doe",
//  "Age": 30
//}

You can also set serializer options for source generation in your context and provide it with more types.

public record Address(
    string Street,
    string City,
    string State,
    string Zip);


[JsonSerializable(typeof(Person))]
[JsonSerializable(typeof(Address))]
[JsonSourceGenerationOptions(WriteIndented = true)]
public partial class MySourceGenerationContext
        : JsonSerializerContext
{
}

Metadata Mode

As mentioned before, the Source Generator, by default, generates type-metadata initialization logic and serialization logic.

We can configure the Source Generator to use only one mode based on the de(serialization) scenarios. 

[JsonSerializable(typeof(Person))]
[JsonSourceGenerationOptions(
    GenerationMode = JsonSourceGenerationMode.Metadata)]
public partial class MySourceGenerationContext
        : JsonSerializerContext
{
}

In this mode, the source generator collects metadata during compilation and generates source code files as an integral part of the application.

It improves de(serialization) performance and reduces startup overhead.

Serialization Mode

The serializer has many options that customize the output, such as naming policies. The serializer has to obtain metadata about these options at run time.

[JsonSerializable(typeof(Person))]
[JsonSourceGenerationOptions(
    GenerationMode = JsonSourceGenerationMode.Serialization)]
public partial class MySourceGenerationContext
        : JsonSerializerContext
{
}

In the Serialization mode, the source generator can optimize the serialization logic for chosen options, which speeds up the serialization.

The limitation is that optimized code doesn’t support all serialization options. If the feature is not supported, it’ll use the default serialization logic. Also, Serialization mode currently is not supported for deserialization.

Benchmarks

I use BenchmarkDotNet for benchmarks. In the first benchmark, I serialized just one instance of a Person record. 

    [Benchmark]
    public void SerializeReflection()
    {
        JsonSerializer.Serialize(_jsonWriter, _person);

        _memoryStream.SetLength(0);
        _jsonWriter.Reset();
    }

    [Benchmark]
    public void SerializeGenerated()
    {
        JsonSerializer.Serialize(_jsonWriter, _person,
            MySourceGenerationContext.Default.Person);

        _memoryStream.SetLength(0);
        _jsonWriter.Reset();
    }

I use Utf8JsonWriter to write JSON into memory to eliminate allocations of creating strings when the Serialize method returns the output.

The source generator is faster. Both benchmarks have zero memory allocation.

This is a simple benchmark. Let’s try to serialize many objects.

    [Params(10, 100, 1000)]
    public int Count;

    [Benchmark]
    public void SerializeReflection()
    {
        JsonSerializer.Serialize(_jsonWriter, _persons);

        _memoryStream.SetLength(0);
        _jsonWriter.Reset();
    }

    [Benchmark]
    public void SerializeGenerated()
    {
        JsonSerializer.Serialize(_jsonWriter, _persons,
            MySourceGenerationContext.Default.PersonArray);

        _memoryStream.SetLength(0);
        _jsonWriter.Reset();
    }

I ran a benchmark for three scenarios, where arrays of Persons have 10, 100, and 1000 items, respectively.

Not bad, huh? The source generator way is much faster. While the serializer based on reflection has memory allocations, the generated serializer still has no memory allocation.

Summary

The source generator is an excellent feature in .NET. By using it with System.Text.Json, we can significantly improve (de)serialization performance and the warm-up phase at application startup.

It’s great when you use application trimming. The source generator reduces the size of the application.

If you create a native AOT application, the source generator is the only option, as certain reflection APIs cannot be used in those applications.

This post is only an introduction to using source generation in the System.Text.Json serializer.

I constantly post on social media about new features in .NET, including Source Generation in System.Text.Json. The latest is the disabling of reflection mode by default and the WitAddedModifier extension method.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top