Skip to main content

Command Palette

Search for a command to run...

Migrating from Newtonsoft.Json to System.Text.Json in ASP.NET Core: A Step-by-Step Guide

Updated
9 min readView as Markdown
Migrating from Newtonsoft.Json to System.Text.Json in ASP.NET Core: A Step-by-Step Guide

For years the honest advice on how to migrate Newtonsoft.Json to System.Text.Json in ASP.NET Core was "wait". Two features kept teams pinned to AddNewtonsoftJson(): JSON Patch and polymorphic serialization. Both are now solved on .NET 10, which means the migration finally has no permanent blockers - only a list of behavioural differences you have to work through deliberately. I have run this migration on APIs ranging from a handful of endpoints to a few hundred, and the pattern that fails is always the same: someone deletes the package, the build goes green, and a subtle contract change reaches a client three weeks later.

The mechanical part of this migration is easy. The risky part is knowing which defaults changed underneath you, which is exactly the part a search-and-replace will not surface. If you want the full before-and-after codebase with the converters, the contract tests, and the compatibility shims wired together, the complete worked version is on Patreon.

A serializer swap is really a change to your API's public contract, so it is worth being clear about where that contract is actually defined. Chapter 2 of the Zero to Production course covers request and response DTOs and Problem Details (RFC 7807) inside a running API, which is the layer that decides what your JSON looks like long before the serializer gets involved.

ASP.NET Core Web API: Zero to Production

Why Migrate at All?

If your API works today, "faster" alone is a weak reason to touch a serializer. These are the reasons that actually justify the work:

  • Performance and allocations. System.Text.Json is meaningfully faster with markedly lower allocations, and the gap widens on large payloads and high request rates. On read-heavy endpoints this shows up as reduced GC pressure, not just a better benchmark number.

  • One fewer third-party dependency. System.Text.Json ships in the box. That is one less package in your supply chain and one less thing to patch.

  • Source generation and Native AOT. Reflection-based serialization is a hard blocker for trimming and AOT. JsonSerializerContext unlocks both, and there is no Newtonsoft equivalent.

  • It is the default everywhere else. Minimal APIs, HttpClient JSON extensions, ASP.NET Core's built-in OpenAPI, and Microsoft.Extensions.AI all assume System.Text.Json. Running Newtonsoft for MVC while everything else uses the built-in serializer means two sets of rules in one process.

We compared the two libraries head to head in System.Text.Json vs Newtonsoft.Json for ASP.NET Core if you are still deciding rather than executing.

What Changed in .NET 10 That Makes This Viable

Two long-standing blockers are gone.

JSON Patch now runs on System.Text.Json. Starting with .NET 10, JSON Patch support in ASP.NET Core is based on System.Text.Json via the Microsoft.AspNetCore.JsonPatch.SystemTextJson package. You still get JsonPatchDocument<T> and ApplyTo(...), without dragging Newtonsoft back in for one endpoint. Be aware it is deliberately not a drop-in replacement: it does not support dynamic types such as ExpandoObject. Microsoft's JSON Patch documentation spells out the differences. If you are still weighing patch semantics generally, our guide on JSON Patch vs nullable DTOs for partial updates covers the design question underneath it.

Polymorphism has a supported story. Newtonsoft's TypeNameHandling was the old answer, and it was also a well-known deserialization attack vector. System.Text.Json replaced it with [JsonDerivedType] attributes: explicit, allow-listed, and safe by construction.

// Explicit and allow-listed - no arbitrary type resolution
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransfer), "bank")]
public abstract class Payment { }

If you were relying on TypeNameHandling.Auto, treat this as a security upgrade rather than a chore.

The Step-by-Step Migration Path

Do this in the order below. The sequencing matters more than the individual steps.

Step 1 - pin the current contract with tests. Before changing anything, write serialization snapshot tests over your real DTOs: nulls, empty collections, enums, dates, decimals, and inheritance. This is the only mechanism that will catch a silent contract change, and it takes an afternoon.

Step 2 - inventory every Newtonsoft touch point. Search for JsonConvert, JObject, JToken, JsonProperty, JsonIgnore, JsonConverter, and Newtonsoft in using directives. Also check what your dependencies pull in. A transitive Newtonsoft reference from an SDK is fine to leave alone; the goal is to remove it from your own contract surface.

Step 3 - remove AddNewtonsoftJson() and configure the built-in serializer. This is where behaviour changes, so make the options explicit rather than relying on defaults you have not read.

builder.Services.AddControllers().AddJsonOptions(o =>
{
    o.JsonSerializerOptions.DefaultIgnoreCondition =
        JsonIgnoreCondition.WhenWritingNull;          // was NullValueHandling.Ignore
    o.JsonSerializerOptions.ReferenceHandler =
        ReferenceHandler.IgnoreCycles;                // was ReferenceLoopHandling.Ignore
});

Step 4 - translate attributes and converters. Mostly mechanical: [JsonProperty("x")] becomes [JsonPropertyName("x")], and Newtonsoft's JsonConverter<T> becomes the System.Text.Json.Serialization one with Read/Write instead of ReadJson/WriteJson.

Step 5 - replace JObject and dynamic usage. JObject maps to JsonNode for mutable trees, or JsonDocument and JsonElement for read-only parsing. This is the step that surfaces the most surprises, because dynamic JSON hides a lot of assumptions.

Step 6 - run the snapshot tests from step 1 and diff. Every failure here is a real contract change you were about to ship.

Step 7 - consider source generation last. Once behaviour is correct, add a JsonSerializerContext for your hot DTOs. Doing this first just means debugging two things at once.

Which Default Behaviours Actually Change?

This is the section worth printing. ASP.NET Core configures System.Text.Json with web defaults (JsonSerializerDefaults.Web), which differ both from Newtonsoft and from bare System.Text.Json used elsewhere in your app.

Behaviour Newtonsoft (ASP.NET Core) System.Text.Json (web defaults)
Property name casing camelCase camelCase
Property name matching on read Case-insensitive Case-insensitive
Quoted numbers ("42" into int) Accepted Accepted under web defaults, rejected by bare defaults
Comments in JSON Accepted Rejected unless ReadCommentHandling is set
Trailing commas Accepted Rejected unless AllowTrailingCommas is set
Null handling on write Configured via NullValueHandling Configured via DefaultIgnoreCondition
Reference loops ReferenceLoopHandling.Ignore ReferenceHandler.IgnoreCycles
Polymorphic type info TypeNameHandling [JsonDerivedType] allow-list
Non-string dictionary keys Supported broadly Supported for common types, stricter overall

The trap I see most often: a background worker or a message handler serializes with JsonSerializer.Serialize(obj) and no options, gets PascalCase and case-sensitive reads, and produces JSON that does not match what the controller emits for the same type. Define one shared JsonSerializerOptions instance built from JsonSerializerDefaults.Web and use it everywhere outside MVC.

Common Migration Pitfalls

  • Silent casing drift outside MVC. Covered above, and it is the number one cause of "it works in the API but breaks in the worker".

  • Cycles that used to be swallowed. Newtonsoft with ReferenceLoopHandling.Ignore hid EF Core navigation-property cycles. System.Text.Json throws instead, which is how most teams first meet this error. We wrote up that exact failure in A Possible Object Cycle Was Detected in ASP.NET Core. The real fix is projecting to DTOs, not turning cycle detection off.

  • Enums as strings. Newtonsoft codebases often use StringEnumConverter globally. Without JsonStringEnumConverter registered, you will start emitting integers, and clients will not notice until something breaks in a way that looks unrelated.

  • Private setters and non-public constructors. System.Text.Json is stricter about what it will populate. Immutable DTOs with a single public constructor bind fine; anything cleverer needs attention.

  • DateTime round-tripping. System.Text.Json is strict about ISO 8601. Newtonsoft was more forgiving of odd formats produced by older clients.

  • Deleting the package too early. Keep Microsoft.AspNetCore.Mvc.NewtonsoftJson installed but unregistered until the snapshot tests pass. Removing it last makes rollback a one-line change.

Verification Checklist Before You Ship

  • Snapshot tests cover nulls, enums, dates, decimals, collections, and polymorphic types, and all pass

  • Every JSON Patch endpoint is exercised against the System.Text.Json implementation

  • One shared JsonSerializerOptions is used by workers, message handlers, and outbound HttpClient calls

  • Error responses still serialize as valid Problem Details

  • OpenAPI output is diffed against the previous version and reviewed

  • A canary or staged rollout is in place, because the failure mode is a client-side parse error, not a 500 on your side

FAQ

Is System.Text.Json a drop-in replacement for Newtonsoft.Json?

No, and it was never intended to be. System.Text.Json prioritises performance, security, and standards compliance over feature parity. Most APIs migrate with modest changes, but anything relying on TypeNameHandling, dynamic JSON trees, or very permissive parsing needs deliberate rework rather than a package swap.

Can I use JSON Patch without Newtonsoft.Json in ASP.NET Core?

Yes, from .NET 10 onwards. Install Microsoft.AspNetCore.JsonPatch.SystemTextJson and keep using JsonPatchDocument<T> and ApplyTo(...). The one documented gap is dynamic types such as ExpandoObject, which the System.Text.Json implementation does not support.

How do I keep the same JSON output after migrating from Newtonsoft.Json?

Write serialization snapshot tests against your real DTOs before you change anything, then configure JsonSerializerOptions until those tests pass again. The usual settings you need are DefaultIgnoreCondition, ReferenceHandler, and a JsonStringEnumConverter. Guessing at options without a test suite is how contract regressions reach production.

What replaces JObject and dynamic JSON in System.Text.Json?

Use JsonNode when you need a mutable document you can navigate and edit, and JsonDocument with JsonElement when you only need to read. JsonDocument is pooled and disposable, so scope it carefully rather than holding JsonElement values past its lifetime.

Should I migrate to System.Text.Json if my API still targets .NET 8?

You can, but the JSON Patch story is the deciding factor. On .NET 8 and .NET 9, JSON Patch still requires the Newtonsoft-based package, so an API that uses PATCH endpoints cannot fully remove the dependency. If that describes you, migrate everything else now and finish the job when you move to .NET 10.

Does migrating to System.Text.Json break my OpenAPI or Swagger documents?

It can, in small ways that matter. Schema generation reads the serializer's configuration, so naming policies, enum handling, and polymorphic annotations all flow through into the generated document. Diff your OpenAPI output before and after and treat any change as a client-facing change until proven otherwise.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

311 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.