Skip to content

Developers should be able to pass state to custom converters. #63795

Open

Description

@eiriktsarpalis

Background and Motivation

The current model for authoring custom converters in System.Text.Json is general-purpose and powerful enough to address most serialization customization requirements. Where it falls short currently is in the ability to accept user-provided state scoped to the current serialization operation; this effectively blocks a few relatively common scenaria:

  1. Custom converters requiring dependency injection scoped to the current serialization. A lack of a reliable state passing mechanism can prompt users to rebuild the converter cache every time a serialization operation is performed.

  2. Custom converters do not currently support streaming serialization. Built-in converters avail of the internal "resumable converter" abstraction, a pattern which allows partial serialization and deserialization by marshalling the serialization state/stack into a state object that gets passed along converters. It lets converters suspend and resume serialization as soon as the need to flush the buffer or read more data arises. This pattern is implemented using the internal JsonConveter<T>.TryWrite and JsonConverter<T>.TryRead methods.

    Since resumable converters are an internal implementation detail, custom converters cannot support resumable serialization. This can create performance problems in both serialization and deserialization:

    • In async serialization, System.Text.Json will delay flushing the buffer until the custom converter (and any child converters) have completed writing.
    • In async deserialization, System.Text.Json will need to read ahead all JSON data at the current depth to ensure that the custom converter has access to all required data at the first read attempt.

    We should consider exposing a variant of that abstraction to advanced custom converter authors.

  3. Custom converters are not capable of passing internal serialization state, often resulting in functional bugs when custom converters are encountered in an object graph (cf. ReferenceHandler.IgnoreCycles doesn't work with Custom Converters #51715, Incorrect JsonException.Path when using non-leaf custom JsonConverters #67403, Keep LineNumber, BytePositionInLine and Path when calling JsonSerializer.Deserialize<TValue>(ref reader) #77345)

Proposal

Here is a rough sketch of how this API could look like:

namespace System.Text.Json.Serialization;

public struct JsonWriteState
{
    public CancellationToken { get; }
    public Dictionary<string, object> UserState { get; }
}

public struct JsonReadState
{
    public CancellationToken { get; }
    public Dictionary<string, object> UserState { get; }
}

public abstract class JsonStatefulConverter<T> : JsonConverter<T>
{
     public abstract void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options, ref JsonWriteState state);
     public abstract T? Read(ref Utf8JsonReader writer, Type typeToConvert, JsonSerializerOptions options, ref JsonReadState state);

      // Override the base methods: implemented in terms of the new virtuals and marked as sealed
      public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) {}
      public sealed override T? Read(ref Utf8JsonReader writer, Type typeToConvert, JsonSerializerOptions options) {}
}

public abstract class JsonResumableConverter<T> : JsonConverter<T>
{
     public abstract bool TryWrite(Utf8JsonWriter writer, T value, JsonSerializerOptions options, ref JsonWriteState state);
     public abstract bool TryRead(ref Utf8JsonReader writer, Type typeToConvert, JsonSerializerOptions options, ref JsonReadState state, out T? result);

      // Override the base methods: implemented in terms of the new virtuals and marked as sealed
      public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) {}
      public sealed override T? Read(ref Utf8JsonReader writer, Type typeToConvert, JsonSerializerOptions options) {}
}

public partial class JsonSerializer
{
     // Overloads to existing methods accepting state
     public static string Serialize<T>(T value, JsonSerializerOptions options, ref JsonWriteState state);
     public static string Serialize<T>(T value, JsonTypeInfo<T> typeInfo, ref JsonWriteState state);
     public static T? Deserialize<T>(string json, JsonSerializerOptions options, ref JsonReadState state);
     public static T? Deserialize<T>(string json, JsonTypeInfo<T> typeInfo, ref JsonReadState state);

     // New method groups enabling low-level streaming serialization
     public static bool TrySerialize(T value, JsonTypeInfo<T> typeInfo, ref JsonWriteState state);
     public static bool TryDeserialize(string json, JsonTypeInfo<T> typeInfo, ref JsonReadState state);
}

Users should be able to author custom converters that can take full advantage of async serialization, and compose correctly with the contextual serialization state. This is particularly important in the case of library authors, who might want to extend async serialization support for custom sets of types. It could also be used to author top-level async serialization methods that target other data sources (e.g. using System.IO.Pipelines cf. #29902)

Usage Examples

MyPoco value = new() { Value = "value" };
JsonWriteState state = new() { UserState = { ["sessionId"] = "myId" }};
JsonSerializer.Serialize(value, options, state); // { "sessionId" : "myId", "value" : "value" }

public class MyConverter : JsonStatefulConverter<MyPoco>
{
    public override void Write(Utf8JsonWriter writer, MyPoco value, JsonSerializerOptions options, ref JsonWriteState state)
    {
         writer.WriteStartObject();
         writer.WriteString("sessionId", (string)state.UserState["sessionId"]);
         writer.WriteString("value", value.Value);
     }
}

Alternative designs

We might want to consider the viability attaching the state values as properties on Utf8JsonWriter and Utf8JsonReader. It would avoid the need of introducing certain overloads, but on the flip side it could break scenaria where the writer/reader objects are being passed to nested serialization operations.

Goals

Progress

  • Author prototype
  • API proposal & review
  • Implementation & tests
  • Conceptual documentation & blog posts.

Activity

  1. added
    User StoryA single user-facing feature. Can be grouped under an epic.
    Priority:2Work that is important, but not critical for the release
    Cost:MWork that requires one engineer up to 2 weeks
    on Jan 14, 2022
  2. added this to the 7.0.0 milestone on Jan 14, 2022
  3. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Jan 14, 2022
  4. eiriktsarpalis commented on Jan 14, 2022

    @eiriktsarpalis
    MemberAuthor

    Tagging @steveharter who might be interested in this.

  5. jeffhandley commented on Apr 6, 2022

    @jeffhandley
    Member

    I'm updating this feature's milestone to Future as it is not likely to make it into .NET 7.

  6. modified the milestones: 7.0.0, Future on Apr 6, 2022
  7. modified the milestones: Future, 8.0.0 on Jul 6, 2022
  8. 19 remaining items

  9. eiriktsarpalis commented on May 31, 2024

    @eiriktsarpalis
    MemberAuthor

    The types already exist as internal implementation detail and hold additional state which wouldn't be visible to users.

  10. JustDre commented on Nov 11, 2024

    @JustDre

    Given that #64182 was closed in deference to this issue, I don't know why the solution needs to involve async converters when DeserializeAsyncEnumerable does not and yet provides significant memory savings over DeserializeAsync (whenever streaming deserialization is appropriate). It seems the ability to call DeserializeAsyncEnumerable at any arbitrary point of an incoming stream, and leaving the stream open at the appropriate byte location upon exit, would be immensely useful. While the solutions discussed above are optimal from a resource perspective, forcing all developers to fully support async converters seems like a bigger lift than necessary, simply to support non-root array deserialization. If an overload can be implemented to achieve this, then once async converters become available, developers can opt in to implement them as appropriate.

  11. JustDre commented on Nov 11, 2024

    @JustDre

    Maybe it's not important, but issue #77018 (that tracks this one) is currently closed. I don't see it being tracked by an equivalent issue for .NET 9, but since it's days (hours?) away from release, it probably needs to be tracked by the equivalent for .NET 10.

  12. Harpush commented on Apr 3, 2025

    @Harpush

    Any chance for non root deserialize to IAsyncEnumerable? Many APIs return the array inside an object thus eliminating the possibility to use IAsyncEnumerable

  13. eiriktsarpalis commented on Apr 4, 2025

    @eiriktsarpalis
    MemberAuthor

    Any chance for non root deserialize to IAsyncEnumerable? Many APIs return the array inside an object thus eliminating the possibility to use IAsyncEnumerable

    How would this work? Assuming you were trying to stream a large JSON object like so

    { "prop1" : 1, "largeProp" : [1,2,3,4,5,....], "prop2": 2 }

    into the type

    record Poco(int prop1, IAsyncEnumerable<int> largeProp, int prop2);
    

    the returned value should have been hydrated with data for all its properties. How could this be reconciled with streaming?

  14. airbreather commented on Apr 4, 2025

    @airbreather

    I'm guessing the idea (because I have a similar use case to this) would be that we have a root object that is essentially nothing more than a container for an array? GeoJSON FeatureCollection comes to mind.

    Edit to elaborate: the implication being that we would deserialize only the inner array for a given property as though it were top-level, and all other properties would be ignored (except maybe for the purposes of raising format errors or something).

    IMO not worth significant investment because it only works for narrow use cases, but not fundamentally incompatible or useless either.

  15. eiriktsarpalis commented on Apr 4, 2025

    @eiriktsarpalis
    MemberAuthor

    Edit to elaborate: the implication being that we would deserialize only the inner array for a given property as though it were top-level, and all other properties would be ignored (except maybe for the purposes of raising format errors or something).

    That is definitely achievable. The potential shape we've been thinking about is a JsonSerializer.DeserializeAsyncEnumerable overload that accepts a JSON pointer to the nested location that needs to be streamed. It means that any other contextual information would be discarded.

  16. Harpush commented on Apr 4, 2025

    @Harpush

    Discarding is the way to go. Let's put aside streaming non array jsons as I am really not sure there is any way to do it except for tokens.
    The idea is for cases like { "data": [/* huge array */] }. I have zero interest in data wrapper and I would be happy with the service returning just an array. So ideally I would expect a way to specify path or even property where the array exists.

    Another more low level oriented approach can be to allow getting a tokens stream from http stream skipping whatever you want and then an option to say deserialize async enumerable from here.
    This will allow to easily even parse (as one big concatenated array) a json like {data1: [], data2: []}.

  17. eiriktsarpalis commented on Apr 4, 2025

    @eiriktsarpalis
    MemberAuthor

    Makes sense. I would say though that this is out of scope for this particular issue. Could you create a new one please?

  18. Harpush commented on Apr 4, 2025

    @Harpush

    Makes sense. I would say though that this is out of scope for this particular issue. Could you create a new one please?

    #114265

  19. thomasnalix commented on Nov 27, 2025

    @thomasnalix

    Any update on this?
    This has been pushed back across several releases, so it’s hard to understand what’s still blocking it or whether there’s a chance for .NET 11

  20. eiriktsarpalis commented on Nov 27, 2025

    @eiriktsarpalis
    MemberAuthor

    It's a resourcing issue.

  21. NWalker1208 commented on Sep 30, 2026

    @NWalker1208

    It's been over four years since this issue was opened, and I'm a bit disappointed that it hasn't seen much progress since then. The fact that state can't be propagated through custom converters has been a consistent source of frustration in the projects I've worked on. Specifically, the fact that a custom converter deserializing a JSON object truncates the JSON paths of any exceptions that occur when deserializing values deeper within the object (see #67403). I've had to resort to various workarounds as a result. It would be great to see this aspect of System.Text.Json improved.

  22. bkoelman commented on Oct 1, 2026

    @bkoelman

    @NWalker1208 Totally agree. This is the biggest limitation in STJ, as far as I'm concerned.

  23. andreascaesar commented on Oct 1, 2026

    @andreascaesar

    We are working a lot with STJ and custom converters, and this is really the only thing we are missing in STJ...
    So, by far the biggest limitation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Cost:LWork that requires one engineer up to 4 weeksPriority:2Work that is important, but not critical for the releaseTeam:LibrariesUser StoryA single user-facing feature. Can be grouped under an epic.area-System.Text.Json

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions