JSON Serialization in .NET

Dated Jul 5, 2025; last modified on Sat, 05 Jul 2025

Serialization converts the state of an object (the value of its properties) into a form that can be stored/transmitted. The serialized form doesn’t include any information about an object’s associated methods.

System.Text.Json emphasizes high performance and low memory allocation over an extensive feature set. It has built-in UTF-8 support because UTF-8 is the most prevalent encoding for data on the web and files on disk.

How to Serialize and Deserialize .NET Objects

To write JSON to a string, call JsonSerializer.Serialize. To read from a JSON string, call JsonSerializer.Deserialize<T>. For example:

Foo foo = new(1, "two");

var jsonStr = JsonSerializer.Serialize(foo);
Console.WriteLine(jsonStr); // {"s":"two","n":1}

var roundTripSuccess = JsonSerializer.Deserialize<Foo>(jsonStr) == foo;
Console.WriteLine(roundTripSuccess); // True 

abstract record Base(int n);
record Foo(int n, string s): Base(n);

JsonSerializer.Serialize<Base>(foo) only serializes properties in Base, i.e., {"n":1}. When deserializing, any properties not represented in your T are ignored by default.

Using JsonSerializer.Serialize<T>(foo) where Foo cannot be converted to a T leads to a compiler error.

One can also serialize to and deserialize from a file using JsonSerializer.SerializeAsync and JsonSerializer.DeserializeAsync<T>, respectively, e.g.,

string fileName = "my-data.json";

await using FileStream outputStream = File.Create(fileName);
await JsonSerializer.SerializeAsync(outputStream, foo);

await using FileStream inputStream = File.OpenRead(fileName);
Foo? foo = await JsonSerializer.DeserializeAsync<Foo>(inputStream);

There exist synchronous APIs as well, e.g., File.WriteAllText and File.ReadAllText. When would one choose the synchronous versions over the asynchronous versions? I’ve been under the impression that I/O should be done async whenever possible.

If you don’t have a T to deserialize into, you can use the Utf8JsonReader directly, or deserialize into a JsonNode DOM, which lets you navigate to any subsection of a JSON payload.

Types supported out-of-the-box include: .NET primitives that map to JavaScript primitives; user-defined plain old CLR objects; T[] and T[][], collections and dictionaries from System.Collections.

Because bytes (as UTF-8) don’t need to be converted to strings (UTF-16), it is 5-10% faster to use JsonSerializer.SerializeToUtf8Bytes instead of JsonSerializer.Serialize . There is a JsonSerializer.Deserialize<T> overload that takes in a Utf8JsonReader or a ReadOnlySpan<byte> to deserialize the bytes.

JsonSerializer.Serialize accepts a JsonSerializerOptions to configure the output, e.g.,

var options = new JsonSerializerOptions { WriteIndented = true };
string jsonString = JsonSerializer.Serialize(foo, options);

How to Deserialize with Required Properties

If there are any required properties of T missing from the JSON payload, then deserialization throws a JsonException at runtime.

public class Foo
{
    // Option 1: `s` is required in all contexts, even outside of serialization.
    public required string s { get; set; }

    // Option 2: `n` is required only in a serialization context.
    [JsonRequired]
    public int n {get; set; }
}

From a deserialization perspective, the required keyword is equivalent to the JsonRequired attribute. The latter is useful when using source generation because at compile time, the required constraint can’t be satisfied.

The required constraint is checked at runtime. It’s possible to control this through the TypeInfoResolver passed to the JsonSerializerOptions, e.g., by setting JsonPropertyInfo.IsRequired.

With JsonSerializerOptions.RespectRequiredConstructorParameters set, non-optional constructor parameters, e.g., Name in record Person(string Name, int? Age = null), are treated as required.

How to Customize Property Names and Values

By default, properties are serialized with the same name and in the order in which they are defined. JsonNameProperty and JsonPropertyOrder can modify this, e.g.,

Foo foo = new Foo {};
// Output: {"nTwo":0,"n_One":0,"tatu":0}
Console.WriteLine(JsonSerializer.Serialize(foo));

public class Foo
{
    public int n_One { get; set; }

    [JsonPropertyOrder(-3)]
    public int nTwo { get; set; }

    [JsonPropertyName("tatu")]
    public int n_three { get; set; }
}

Without the attributes, the serialization would have been {"n_One":0,"nTwo":0,"n_three":0}.

JsonSerializerOptions has a PropertyNamingPolicy that takes in a policy for generating names when serializing. The JsonPropertyName overrides this though:

Foo foo = new Foo {};

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = new ContosoNamingPolicy()
};
// Output: {"contoso_N_ONE":0,"contoso_NTWO":0,"tatu":0}
Console.WriteLine(JsonSerializer.Serialize(foo, options));

public class Foo
{
    public int n_One { get; set; }

    public int nTwo { get; set; }

    [JsonPropertyName("tatu")]
    public int n_three { get; set; }
}

public class ContosoNamingPolicy : JsonNamingPolicy
{
    public override string ConvertName(string name) =>
        $"contoso_{name.ToUpper()}";
}

There are several in-built JsonNamingPolicy.* classes, e.g., CamelCase (fooBar), KebabCaseLower (foo-bar), KebabCaseUpper (FOO-BAR), SnakeCaseLower (foo_bar) and SnakeCaseUpper (FOO_BAR).

If serializing a Dictionary<string, TValue>, then one can supply a JsonNamingPolicy to JsonSerializerOptions’s DictionaryKeyPolicy to specify how the keys will be serialized. However, unlike PropertyNamingPolicy, JsonNamingPolicy only applies during serialization and not during deserialization.

If JsonNamingPolicy does not support round-tripping data, then what use is it? Seems like a risky API…

By default, enums are serialized as numbers. One can specify attributes on enums to serialize them differently, e.g.,

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Foo
{
  Bar, // Output: Bar
  [JsonStringEnumMemberName("AlternateBazName")]
  Baz, // Output: AlternateBazName
  Qux  // Output: Qux
}

One can also apply converters to the JsonSerializerOptions, e.g.,

var options = new JsonSerializerOptions
{
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
Console.WriteLine(JsonSerializer.Serialize(Foo.Baz, options)); // Output: baz

public enum Foo { Bar, Baz, Qux }

Of the various modifications to JsonSerializerOptions, serializing enums as strings is my favorite. Helps a ton with readability when debugging data flows because I don’t need to maintain a map of what enumerator corresponds with a given number. It also makes it easy to re-order enums because their underlying numerical values are inconsequential.

How to Exclude Properties

By default, all public properties are serialized. The JsonIgnore attribute allows us to ignore individual properties, e.g.,

public class Foo
{
    [JsonIgnore(Condition = JsonIgnoreCondition.Always)]
    public string s1 { get; set; } = "Paukwa"; // Will not be serialized.
    public string s2 { get; set; } = "Pakawa";
}

JsonIgnoreCondition values include Always (default), Never, WhenWritingDefault, and WhenWritingNull.

Read-only properties are ones with a public getter but a non-public setter, e.g., public string s { get; private set; } = "Top Secret". JsonSerializerOptions’s IgnoreReadOnlyProperties can be set to true to override the default behavior where such properties get serialized.

JsonSerializerOptions’s DefaultIgnoreCondition can be set to ignore properties based on a criteria, e.g., WhenWritingNull to drop nulls, WhenWritingDefault to drop defaults and nulls.

How to Include Fields

A field defines a storage location.

public class Person
{
  public string? FirstName;
}

… while a property is an outward-facing declaration:

public class Person
{
  // The compiler generates a hidden backing field, and implements the body of
  // the get and set accessors.
  public string FirstName { get; set; } = string.Empty;
}

Think of properties as smart fields. You can provide validation, lazy evaluation, different accessibility, etc.

By default, fields are not serialized. Set JsonSerializerOptions.IncludeFields or use the [JsonInclude] attribute on them to include them. Set JsonSerializerOptions.IgnoreReadOnlyFields to ignore fields marked with readonly.

Reflection vs. Source Generation

By default, System.Text.Json gathers the metadata needed to access properties of objects for serialization at run time using reflection .

Alternatively, System.Text.Json can use source generation to improve performance, reduce private memory, and facilitate assembly trimming, which reduces app size.

References

  1. Serialize and deserialize JSON using C# - .NET | Microsoft Learn. learn.microsoft.com . Accessed Jul 5, 2025.
  2. How to serialize JSON in C# - .NET | Microsoft Learn. learn.microsoft.com . Accessed Jul 5, 2025.
  3. JsonSerializerOptions Class (System.Text.Json) | Microsoft Learn. learn.microsoft.com . Accessed Jul 5, 2025.
  4. How to customize property names and values with System.Text.Json - .NET | Microsoft Learn. learn.microsoft.com . Accessed Jul 5, 2025.
  5. How to ignore properties with System.Text.Json - .NET | Microsoft Learn. learn.microsoft.com . Accessed Jul 5, 2025.
  6. Properties - C# | Microsoft Learn. learn.microsoft.com . Accessed Jul 5, 2025.
  7. Include fields in serialization - .NET | Microsoft Learn. learn.microsoft.com . Accessed Jul 5, 2025.
  8. How to deserialize JSON in C# - .NET | Microsoft Learn. learn.microsoft.com . Accessed Jul 6, 2025.
  9. Require properties for deserialization - .NET | Microsoft Learn. learn.microsoft.com . Accessed Jul 6, 2025.