Start Debugging

Migrate from Newtonsoft.Json 13 to System.Text.Json in a large .NET 11 codebase

A version-pinned playbook for swapping Newtonsoft.Json 13.0.4 for the in-box System.Text.Json on .NET 11: the attribute and settings mappings, the defaults that silently change your wire format, a staged rollout strategy, verification, and the gotchas that bite large codebases.

Swapping Newtonsoft.Json for System.Text.Json across a large codebase is rarely a find-and-replace job. The two libraries disagree on defaults in ways that change your serialized output and silently break deserialization, so a naive swap ships a contract change to every consumer of your JSON. Budget a few days for a small service and two to four weeks for a sprawling codebase with custom converters, polymorphic payloads, and dynamic/JObject parsing. The win is real: System.Text.Json ships in-box with the runtime, serializes roughly twice as fast with a fraction of the allocations, and is the only one of the two that runs under Native AOT. This post pins Newtonsoft.Json 13.0.4 (the current stable, released 2025-12-30) as the source and the in-box System.Text.Json on the .NET 11 SDK with C# 14 as the target. If you are still deciding whether to move at all, read System.Text.Json vs Newtonsoft.Json in 2026 first; this post assumes you have decided to migrate.

Why migrate now

What breaks

The danger in this migration is not the code that fails to compile. It is the code that compiles fine and changes your wire format. This table is the one to read twice.

AreaChangeSeverity
Property name matchingNewtonsoft.Json is case-insensitive on read by default; System.Text.Json is case-sensitivehigh
Comments and trailing commasAccepted by default in Newtonsoft.Json, throw JsonException in System.Text.Jsonhigh
Single-quoted / unquoted JSONAccepted by Newtonsoft.Json, rejected by design in System.Text.Jsonhigh
Non-string into string propertyNewtonsoft.Json coerces 1 or true; System.Text.Json throwshigh
Numbers in quotesNewtonsoft.Json reads "23" into an int; System.Text.Json needs NumberHandlingmedium
Character escapingSystem.Text.Json escapes more aggressively, so output bytes differ for non-ASCII and HTMLmedium
[JsonProperty("name")]Becomes [JsonPropertyName("name")]; no combined ignore/required options on one attributemedium
TypeNameHandling.AllNo equivalent, by design. Polymorphism uses [JsonDerivedType] insteadhigh
JObject / JToken / dynamicReplaced by JsonNode / JsonDocument / JsonElement with a different APImedium
JsonConvert.PopulateObjectNo built-in equivalent; needs a custom converter or manual mergemedium
ReferenceLoopHandling.IgnoreNo “silently drop the loop” mode; you get ReferenceHandler.Preserve or you redesign the graphmedium
DateFormatString, DateTimeZoneHandlingNo global date format knob; needs a custom JsonConverter<DateTime>medium
Converter registration precedenceConverters collection now overrides a type-level attribute (reversed from Newtonsoft.Json)low

The authoritative reference for every row here is the Microsoft migration guide, which also lists the handful of features (JsonPath queries, TypeNameHandling.All, single-quote parsing) that have no workaround.

Pre-flight checklist

Do all of this before you delete a single using Newtonsoft.Json.

  1. Pin the contract. If your JSON crosses a process boundary (a public API, a message queue, a persisted column), capture golden samples of the current output. Serialize a representative set of objects with Newtonsoft.Json 13.0.4 and store the strings as test fixtures. These are your regression oracle.
  2. Inventory the surface. Grep for everything that touches the old library so you know the size of the job:
    # run from the repo root; counts the call sites you have to touch
    grep -rEl "Newtonsoft\.Json|JsonConvert|JObject|JArray|JToken|JsonProperty|JsonSerializerSettings" --include="*.cs" .
    Verify: the file list matches your mental model of where serialization lives. Surprises here (a converter buried in a logging helper) are exactly what you want to find now.
  3. Flag the no-workaround features. Search specifically for TypeNameHandling, SelectToken, SelectTokens, and single-quote test fixtures. If you find TypeNameHandling.All or .Auto, stop and design the polymorphism replacement before continuing, because there is no drop-in for it.
  4. Confirm the target. Run dotnet --version and confirm 11.0.x. System.Text.Json is in-box, so there is no package to add for the core scenarios; you only add System.Text.Json as an explicit PackageReference if you need a newer out-of-band version than the SDK ships.

Migration steps

  1. Map the global settings.

    Newtonsoft.Json centralizes behavior in JsonSerializerSettings. System.Text.Json uses JsonSerializerOptions. Translate your existing settings object field by field; do not accept the System.Text.Json defaults blindly, because they differ from what your code has been emitting for years.

    // .NET 11, C# 14
    // BEFORE: Newtonsoft.Json 13.0.4
    var settings = new JsonSerializerSettings
    {
        ContractResolver = new CamelCasePropertyNamesContractResolver(),
        NullValueHandling = NullValueHandling.Ignore,
        ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
    };
    
    // AFTER: System.Text.Json (in-box on .NET 11)
    var options = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true,                       // restore Newtonsoft read behavior
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        ReferenceHandler = ReferenceHandler.IgnoreCycles,         // closest match to ReferenceLoopHandling.Ignore
        // AllowTrailingCommas = true,                            // uncomment only if your inputs have them
        // ReadCommentHandling = JsonCommentHandling.Skip,        // uncomment only if your inputs have comments
    };

    Verify: serialize your golden-sample objects with these options and diff against the fixtures from pre-flight step 1. The diff should be empty or explained. PropertyNameCaseInsensitive = true is the single most important line for large codebases, because countless deserialization paths quietly depend on Newtonsoft.Json’s case-insensitive matching.

  2. Replace the attributes.

    The attribute rename is mechanical, but [JsonProperty] packed several concerns into one attribute that System.Text.Json splits apart.

    // .NET 11, C# 14
    // BEFORE
    public class Order
    {
        [JsonProperty("order_id")]
        public int Id { get; set; }
    
        [JsonProperty("notes", NullValueHandling = NullValueHandling.Ignore)]
        public string? Notes { get; set; }
    
        [JsonProperty(Required = Required.Always)]
        public string Customer { get; set; } = "";
    
        [JsonIgnore]
        public string Internal { get; set; } = "";
    }
    
    // AFTER
    public class Order
    {
        [JsonPropertyName("order_id")]
        public int Id { get; set; }
    
        [JsonPropertyName("notes")]
        [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
        public string? Notes { get; set; }
    
        [JsonRequired]                       // or the C# `required` modifier
        public string Customer { get; set; } = "";
    
        [JsonIgnore]
        public string Internal { get; set; } = "";
    }

    Verify: the project compiles with zero Newtonsoft.Json using directives in the model assembly, and a round-trip test (Deserialize(Serialize(order))) preserves every field.

  3. Port the custom converters.

    This is where the hours go. The shapes are similar but the contracts differ: System.Text.Json converters work over Utf8JsonReader (a ref struct) and Utf8JsonWriter, and Read is called positioned on the first token.

    // .NET 11, C# 14 -- a converter that reads/writes DateTime in a fixed format,
    // replacing Newtonsoft's DateFormatString / DateTimeZoneHandling settings.
    public sealed class Iso8601DateTimeConverter : JsonConverter<DateTime>
    {
        public override DateTime Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions o)
            => DateTime.ParseExact(reader.GetString()!, "yyyy-MM-dd'T'HH:mm:ss'Z'",
                                   CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
    
        public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions o)
            => writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'",
                                       CultureInfo.InvariantCulture));
    }

    Register it on options.Converters, not just as a type attribute, and note the precedence change: in System.Text.Json a converter in the Converters collection overrides a type-level [JsonConverter] attribute, the reverse of Newtonsoft.Json. The detailed mechanics are in how to write a custom JsonConverter in System.Text.Json. Verify each ported converter against its own fixture, not just the end-to-end payload, so a precedence surprise does not hide behind a passing integration test.

  4. Replace polymorphism and TypeNameHandling.

    If you used TypeNameHandling to round-trip a class hierarchy, there is no equivalent, and that is deliberate: TypeNameHandling.All is a well-known remote-code-execution vector. System.Text.Json does discriminated polymorphism with attributes on the base type.

    // .NET 11, C# 14
    [JsonDerivedType(typeof(Dog), typeDiscriminator: "dog")]
    [JsonDerivedType(typeof(Cat), typeDiscriminator: "cat")]
    public abstract class Animal { public string Name { get; set; } = ""; }
    
    public sealed class Dog : Animal { public bool GoodBoy { get; set; } }
    public sealed class Cat : Animal { public int Lives { get; set; } }

    This emits a "$type": "dog" discriminator and reads it back to the right subtype. Verify: serialize a List<Animal> of mixed subtypes, deserialize it, and assert the runtime types survive. Note the wire format changed (an explicit discriminator string instead of Newtonsoft.Json’s assembly-qualified $type), so any external consumer must be updated in lockstep.

  5. Convert dynamic and JObject parsing.

    Code that pokes at untyped JSON via JObject/JToken/dynamic moves to JsonNode (mutable) or JsonDocument/JsonElement (read-only, pooled).

    // .NET 11, C# 14
    // BEFORE: JObject o = JObject.Parse(json); var name = (string)o["user"]!["name"]!;
    JsonNode root = JsonNode.Parse(json)!;
    string name = root["user"]!["name"]!.GetValue<string>();

    The one trap: JsonDocument owns a pooled buffer and is IDisposable, unlike JObject. Wrap it in using or you leak the rented buffer. Prefer JsonNode when you need a JObject-like mutable tree. Verify: every former JObject access path has a unit test that exercises the same key lookups.

  6. Switch the ASP.NET Core integration.

    If the codebase calls AddNewtonsoftJson() in Program.cs, removing it flips the whole pipeline to System.Text.Json. ASP.NET Core’s web defaults already enable camelCase, case-insensitive matching, and quoted-number reading, so a lot of your manual options become redundant in the MVC path.

    // .NET 11, C# 14
    // BEFORE: builder.Services.AddControllers().AddNewtonsoftJson();
    builder.Services.AddControllers().AddJsonOptions(o =>
    {
        o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
        // camelCase + case-insensitive are already on by ASP.NET Core web defaults
    });

    Watch the depth limit: ASP.NET Core caps System.Text.Json MaxDepth at 32, not the library default of 64. Deeply nested payloads that worked under AddNewtonsoftJson() can start throwing. Verify: run the controller integration tests and confirm no payload trips the depth limit.

Verification

Run this smoke-test list after each PR, not just at the end:

Rollback plan

This migration is reversible per project but not trivially so, because step 1 changes your wire format. The clean strategy is a strangler approach: migrate one assembly or one endpoint at a time, keep Newtonsoft.Json referenced until the last consumer is moved, and gate risky endpoints behind a feature flag that can route back to the Newtonsoft.Json formatter. Once you have deleted the PackageReference and shipped the new wire format to external consumers, rolling back means re-adding the package and reverting the format change everywhere at once, which is a coordinated release, not a git revert. Do not delete the package reference until the golden-sample diffs have been green in production telemetry for at least one release cycle.

Gotchas we hit

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back