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
System.Text.Jsonis part of the shared framework on .NET 11. Dropping theNewtonsoft.JsonPackageReferenceremoves a transitive dependency that the runtime, ASP.NET Core, and the test platform have all been actively shedding.- Throughput. On typical POCO payloads
System.Text.Jsonserializes around 2x faster thanNewtonsoft.Jsonwith markedly lower allocations, because it works directly over UTF-8 bytes withUtf8JsonReaderandUtf8JsonWriterrather than going throughstringandTextReader. - Native AOT and trimming.
Newtonsoft.Jsonrelies on reflection and does not work under Native AOT.System.Text.Jsonhas a source-generator mode (JsonSerializerContext) that emits trim-safe, AOT-compatible serialization metadata at compile time. If Native AOT is on your roadmap, this migration is a prerequisite, not an optimization. - Security posture.
System.Text.Jsonis strict by default (RFC 8259), escapes HTML-sensitive and non-ASCII characters on output, and does not interpret malformed JSON. That removes a class of injection and parsing surprises thatNewtonsoft.Json’s permissive defaults allow.
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.
| Area | Change | Severity |
|---|---|---|
| Property name matching | Newtonsoft.Json is case-insensitive on read by default; System.Text.Json is case-sensitive | high |
| Comments and trailing commas | Accepted by default in Newtonsoft.Json, throw JsonException in System.Text.Json | high |
| Single-quoted / unquoted JSON | Accepted by Newtonsoft.Json, rejected by design in System.Text.Json | high |
Non-string into string property | Newtonsoft.Json coerces 1 or true; System.Text.Json throws | high |
| Numbers in quotes | Newtonsoft.Json reads "23" into an int; System.Text.Json needs NumberHandling | medium |
| Character escaping | System.Text.Json escapes more aggressively, so output bytes differ for non-ASCII and HTML | medium |
[JsonProperty("name")] | Becomes [JsonPropertyName("name")]; no combined ignore/required options on one attribute | medium |
TypeNameHandling.All | No equivalent, by design. Polymorphism uses [JsonDerivedType] instead | high |
JObject / JToken / dynamic | Replaced by JsonNode / JsonDocument / JsonElement with a different API | medium |
JsonConvert.PopulateObject | No built-in equivalent; needs a custom converter or manual merge | medium |
ReferenceLoopHandling.Ignore | No “silently drop the loop” mode; you get ReferenceHandler.Preserve or you redesign the graph | medium |
DateFormatString, DateTimeZoneHandling | No global date format knob; needs a custom JsonConverter<DateTime> | medium |
| Converter registration precedence | Converters 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.
- 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.Json13.0.4 and store the strings as test fixtures. These are your regression oracle. - Inventory the surface. Grep for everything that touches the old library so you know the size of the job:
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.# 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" . - Flag the no-workaround features. Search specifically for
TypeNameHandling,SelectToken,SelectTokens, and single-quote test fixtures. If you findTypeNameHandling.Allor.Auto, stop and design the polymorphism replacement before continuing, because there is no drop-in for it. - Confirm the target. Run
dotnet --versionand confirm11.0.x.System.Text.Jsonis in-box, so there is no package to add for the core scenarios; you only addSystem.Text.Jsonas an explicitPackageReferenceif you need a newer out-of-band version than the SDK ships.
Migration steps
-
Map the global settings.
Newtonsoft.Jsoncentralizes behavior inJsonSerializerSettings.System.Text.JsonusesJsonSerializerOptions. Translate your existing settings object field by field; do not accept theSystem.Text.Jsondefaults 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
optionsand diff against the fixtures from pre-flight step 1. The diff should be empty or explained.PropertyNameCaseInsensitive = trueis the single most important line for large codebases, because countless deserialization paths quietly depend onNewtonsoft.Json’s case-insensitive matching. -
Replace the attributes.
The attribute rename is mechanical, but
[JsonProperty]packed several concerns into one attribute thatSystem.Text.Jsonsplits 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.Jsonusingdirectives in the model assembly, and a round-trip test (Deserialize(Serialize(order))) preserves every field. -
Port the custom converters.
This is where the hours go. The shapes are similar but the contracts differ:
System.Text.Jsonconverters work overUtf8JsonReader(aref struct) andUtf8JsonWriter, andReadis 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: inSystem.Text.Jsona converter in theConverterscollection overrides a type-level[JsonConverter]attribute, the reverse ofNewtonsoft.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. -
Replace polymorphism and TypeNameHandling.
If you used
TypeNameHandlingto round-trip a class hierarchy, there is no equivalent, and that is deliberate:TypeNameHandling.Allis a well-known remote-code-execution vector.System.Text.Jsondoes 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 aList<Animal>of mixed subtypes, deserialize it, and assert the runtime types survive. Note the wire format changed (an explicit discriminator string instead ofNewtonsoft.Json’s assembly-qualified$type), so any external consumer must be updated in lockstep. -
Convert dynamic and JObject parsing.
Code that pokes at untyped JSON via
JObject/JToken/dynamicmoves toJsonNode(mutable) orJsonDocument/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:
JsonDocumentowns a pooled buffer and isIDisposable, unlikeJObject. Wrap it inusingor you leak the rented buffer. PreferJsonNodewhen you need aJObject-like mutable tree. Verify: every formerJObjectaccess path has a unit test that exercises the same key lookups. -
Switch the ASP.NET Core integration.
If the codebase calls
AddNewtonsoftJson()inProgram.cs, removing it flips the whole pipeline toSystem.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.JsonMaxDepthat 32, not the library default of 64. Deeply nested payloads that worked underAddNewtonsoftJson()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:
- The solution builds with zero
Newtonsoft.Jsonreferences in the migrated projects (grep -r "Newtonsoft" --include="*.csproj"returns nothing for those projects). - The golden-sample diff from pre-flight step 1 is empty or every difference is documented and intended.
- The full test suite passes:
dotnet testreports zero failures. - A round-trip test (
Deserialize(Serialize(x))) holds for every model with a custom converter or polymorphic hierarchy. - For hot paths, run a quick
BenchmarkDotNetcomparison and confirm the throughput and allocation numbers moved the right way rather than regressing because of an accidental per-callnew JsonSerializerOptions()(always cache and reuse the options instance; constructing it per call is the single most common performance regression in this migration).
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
- Silent case-sensitivity data loss. A config object deserialized from a file with
PascalCasekeys came back with every property at its default becauseSystem.Text.Jsonmatched case-sensitively against camelCased members. Nothing threw. The fix wasPropertyNameCaseInsensitive = true, and the lesson was to assert on values, not just on “did it parse”. DateTimeround-trips drifted.Newtonsoft.Json’sDateTimeZoneHandlinghad been quietly normalizing timestamps.System.Text.Jsonreads ISO 8601 round-trip format and preserves the offset, so stored timestamps came back with a different kind. The custom converter in step 3 plus the JSON value could not be converted to System.DateTime fix resolved it.- Object cycles threw instead of being dropped.
ReferenceLoopHandling.Ignorehad been masking a genuine circular reference in an EF Core navigation property.System.Text.Jsonsurfaced it as a possible object cycle was detected.ReferenceHandler.IgnoreCyclesis the bridge, but the better fix was a projection DTO that did not have the loop at all. - A per-request
new JsonSerializerOptions()tanked throughput. Constructing the options object inside a hot handler defeats the internal metadata cache and was slower than theNewtonsoft.Jsoncode it replaced. Cache onestatic readonly JsonSerializerOptionsand reuse it.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.