# 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](https://www.patreon.com/CodingDroplets).

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](https://aspnetcoreapi.codingdroplets.com/) 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](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

## 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](https://codingdroplets.com/system-text-json-vs-newtonsoft-json-aspnet-core-enterprise-2026) 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](https://learn.microsoft.com/en-us/aspnet/core/web-api/jsonpatch?view=aspnetcore-10.0) spells out the differences. If you are still weighing patch semantics generally, our guide on [JSON Patch vs nullable DTOs for partial updates](https://codingdroplets.com/aspnet-core-partial-update-json-patch-vs-nullable-dto-enterprise) 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.

```csharp
// 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.

```csharp
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](https://codingdroplets.com/possible-object-cycle-detected-aspnet-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](https://codingdroplets.com/). 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.

*   GitHub: [codingdroplets](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
