Migrating from Newtonsoft.Json to System.Text.Json

When Microsoft introduced System.Text.Json in .NET Core 3.0, it offered a high-performance, low-allocation alternative to the long-reigning Newtonsoft.Json. Six years on, many codebases still carry the Newtonsoft dependency — sometimes deliberately, sometimes through inertia. If you're considering the switch, here's what you need to know.

Why migrate?

System.Text.Json ships in-box with the .NET runtime. It's faster, allocates less memory, and is the default serializer for ASP.NET Core, Minimal APIs, and the configuration system. Staying on Newtonsoft means pulling in a third-party package and potentially double-serializing in certain ASP.NET Core pipelines.

That said, Newtonsoft remains more feature-rich in edge cases — polymorphic serialization was only fully addressed in System.Text.Json from .NET 7 onwards, and some advanced JsonConverter patterns are still simpler with Newtonsoft.

Key differences at a glance

Behaviour Newtonsoft.Json System.Text.Json
Default naming PascalCase camelCase (in ASP.NET Core)
Property matching Case-insensitive Case-sensitive by default
Comments in JSON Allowed Disallowed by default
Trailing commas Allowed Disallowed by default
$type discriminator Built-in [JsonPolymorphic] from .NET 7
Missing members Ignored Ignored (configurable in .NET 9)

An incremental migration strategy

Rather than a big-bang rewrite, migrate one serialization boundary at a time. Start with DTOs that have simple shapes — flat objects, primitive types, and collections.

Step 1: Configure compatible options

Create a shared JsonSerializerOptions instance that mirrors Newtonsoft defaults:

Example.cs
var options = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    ReadCommentHandling = JsonCommentHandling.Skip,
    AllowTrailingCommas = true,
    NumberHandling = JsonNumberHandling.AllowReadingFromString,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

// IMPORTANT

Always reuse your JsonSerializerOptions instance. Creating one per call is expensive because the first use triggers reflection and caching.

Step 2: Replace serialization calls

Swap out JsonConvert.SerializeObject and JsonConvert.DeserializeObject for their System.Text.Json equivalents:

Example.cs
// Before (Newtonsoft)
var json = JsonConvert.SerializeObject(order);
var order = JsonConvert.DeserializeObject<Order>(json);

// After (System.Text.Json)
var json = JsonSerializer.Serialize(order, options);
var order = JsonSerializer.Deserialize<Order>(json, options);

Step 3: Migrate custom converters

Newtonsoft's JsonConverter<T> maps to System.Text.Json.Serialization.JsonConverter<T>, but the API surface differs. Here's a date-only converter as an example:

Example.cs
public class DateOnlyJsonConverter : JsonConverter<DateOnly>
{
    private const string Format = "yyyy-MM-dd";

    public override DateOnly Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return DateOnly.ParseExact(reader.GetString()!, Format, CultureInfo.InvariantCulture);
    }

    public override void Write(
        Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
    }
}

Register it on your options:

Example.cs
options.Converters.Add(new DateOnlyJsonConverter());

Step 4: Handle polymorphic types

If you rely on Newtonsoft's TypeNameHandling for polymorphic serialization, switch to the .NET 7+ [JsonPolymorphic] attribute:

Example.cs
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(CreditCardPayment), "credit-card")]
[JsonDerivedType(typeof(BankTransferPayment), "bank-transfer")]
public abstract class Payment
{
    public decimal Amount { get; set; }
}

This is considerably safer than Newtonsoft's TypeNameHandling.All, which has well-documented deserialization vulnerabilities.

What to watch out for

Enums are serialized as numbers by default. Add JsonStringEnumConverter to your options if you want string representation:

Example.cs
options.Converters.Add(new JsonStringEnumConverter());

Private setters are not populated by default. Use [JsonInclude] on properties with private setters that need deserializing.

Dictionary key types must be strings or implement IParsable<T> (from .NET 7). Newtonsoft happily serializes Dictionary<int, string> with integer keys; System.Text.Json will throw unless you provide a converter.

JObject and JToken have no direct equivalent. Use JsonDocument for read-only access or JsonNode (from .NET 6) for mutable DOM manipulation.

When to stay on Newtonsoft

If your codebase heavily uses JObject manipulation, dynamic deserialization, or complex ContractResolver customisation, the migration cost may outweigh the benefits. Both libraries can coexist — ASP.NET Core lets you configure either as the default serializer.

Wrapping up

The migration is straightforward for most DTOs and gets progressively harder with custom converters and polymorphic hierarchies. Start with your simplest models, verify behaviour with integration tests, and work outwards. The performance gains and reduced dependency footprint make it worthwhile for the majority of projects.