Skip to main content

System.Text.Json vs Newtonsoft: .NET 10 Migration Gotchas

Last updated: October 8, 2026 · Tested with .NET 10 (SDK 10.0.401, runtime 10.0.12), Newtonsoft.Json 13.0.4, BenchmarkDotNet 0.15.8, Windows 11

Short answer: on .NET 10, use System.Text.Json for new code and for most migrations. It ships with the runtime, it is what ASP.NET Core uses by default, and in my benchmark it serialized a 20-line order about twice as fast as Newtonsoft.Json with a quarter of the allocations. Keep Newtonsoft.Json where you depend on its leniency: input with comments or non-ISO dates you can't change, JObject/dynamic-heavy code, existing TypeNameHandling payloads, or dictionaries keyed by custom types. Most migration bugs come from different defaults, not missing features. Below is every difference I hit, with the exact exception messages from real runs.

Case Sensitivity, Quoted Numbers and Comments

This one test covers the three defaults that break the most code. The same three JSON strings go through Newtonsoft.Json, JsonSerializer with default options, and JsonSerializer with JsonSerializerOptions.Web:

using System.Text.Json;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

string camel = """{"name":"Ada","age":36}""";
string quoted = """{"Name":"Ada","Age":"36"}""";
string relaxed = """
    {
      "Name": "Ada", // comment
      "Age": 36,
    }
    """;

foreach (var (label, json) in new[] { ("camelCase", camel), ("quoted number", quoted), ("comment + comma", relaxed) })
{
    Console.WriteLine($"--- {label}");
    Show("Newtonsoft", () => JsonConvert.DeserializeObject<Person>(json));
    Show("STJ default", () => JsonSerializer.Deserialize<Person>(json));
    Show("STJ Web", () => JsonSerializer.Deserialize<Person>(json, JsonSerializerOptions.Web));
}

var person = new Person { Name = "Ada", Age = 36 };
Console.WriteLine("--- serialize");
Console.WriteLine($"Newtonsoft   {JsonConvert.SerializeObject(person)}");
Console.WriteLine($"STJ default  {JsonSerializer.Serialize(person)}");
Console.WriteLine($"STJ Web      {JsonSerializer.Serialize(person, JsonSerializerOptions.Web)}");

static void Show(string label, Func<object?> action)
{
    try { Console.WriteLine($"{label,-12} {action()}"); }
    catch (Exception ex) { Console.WriteLine($"{label,-12} {ex.GetType().Name}: {ex.Message}"); }
}

public class Person
{
    public string? Name { get; set; }
    public int Age { get; set; }
    public override string ToString() => $"Name={Name ?? "null"}, Age={Age}";
}
--- camelCase
Newtonsoft   Name=Ada, Age=36
STJ default  Name=null, Age=0
STJ Web      Name=Ada, Age=36
--- quoted number
Newtonsoft   Name=Ada, Age=36
STJ default  JsonException: The JSON value could not be converted to System.Int32. Path: $.Age | LineNumber: 0 | BytePositionInLine: 24.
STJ Web      Name=Ada, Age=36
--- comment + comma
Newtonsoft   Name=Ada, Age=36
STJ default  JsonException: '/' is an invalid start of a property name. Expected a '"'. Path: $ | LineNumber: 1 | BytePositionInLine: 17.
STJ Web      JsonException: '/' is an invalid start of a property name. Expected a '"'. Path: $ | LineNumber: 1 | BytePositionInLine: 17.
--- serialize
Newtonsoft   {"Name":"Ada","Age":36}
STJ default  {"Name":"Ada","Age":36}
STJ Web      {"name":"Ada","age":36}

Three things to notice:

  • camelCase input silently returns empty objects. Default System.Text.Json matches property names case-sensitively, so "name" never reaches Name. There is no exception, just null and 0. This is the bug that ships to production, because nothing fails.
  • "36" into an int throws The JSON value could not be converted to System.Int32. Newtonsoft converts it. The Web defaults accept it, because they include JsonNumberHandling.AllowReadingFromString.
  • Comments and trailing commas fail even with the Web defaults. They are opt-in.

Also note the output side: default System.Text.Json writes PascalCase like Newtonsoft, while JsonSerializerOptions.Web writes camelCase. If a console app or worker serializes with default options and an ASP.NET Core API reads the result, the names still match (the Web defaults are case-insensitive), but the reverse direction is where data goes missing. To get Newtonsoft-like reading behavior, set the options explicitly:

using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true,
    NumberHandling = JsonNumberHandling.AllowReadingFromString,
    ReadCommentHandling = JsonCommentHandling.Skip,
    AllowTrailingCommas = true,
};

string json = """
    {
      "name": "Ada", // comment
      "age": "36",
    }
    """;

var person = JsonSerializer.Deserialize<Person>(json, options)!;
Console.WriteLine($"Name={person.Name}, Age={person.Age}");

public class Person
{
    public string? Name { get; set; }
    public int Age { get; set; }
}

That prints Name=Ada, Age=36. Create the options object once and reuse it; System.Text.Json caches metadata per options instance.

Enums, Fields, Private Setters and Constructors

using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

var order = new Order { Id = 7, Status = OrderStatus.Shipped, Note = "fragile" };
Console.WriteLine($"Newtonsoft   {JsonConvert.SerializeObject(order)}");
Console.WriteLine($"STJ default  {JsonSerializer.Serialize(order)}");

var options = new JsonSerializerOptions
{
    IncludeFields = true,
    Converters = { new JsonStringEnumConverter() },
};
Console.WriteLine($"STJ options  {JsonSerializer.Serialize(order, options)}");

string input = """{"Id":7,"Status":"Shipped","Note":"fragile","Total":12.5}""";
Show("Newtonsoft", () => JsonConvert.DeserializeObject<Order>(input));
Show("STJ default", () => JsonSerializer.Deserialize<Order>(input));
Show("STJ options", () => JsonSerializer.Deserialize<Order>(input, options));

string account = """{"Name":"Ada","Total":12.5}""";
Show("Newtonsoft", () => JsonConvert.DeserializeObject<Account>(account));
Show("STJ", () => JsonSerializer.Deserialize<Account>(account));
Show("STJ fixed", () => JsonSerializer.Deserialize<FixedAccount>(account));

static void Show(string label, Func<object?> action)
{
    try { Console.WriteLine($"{label,-12} {action()}"); }
    catch (Exception ex) { Console.WriteLine($"{label,-12} {ex.GetType().Name}: {ex.Message}"); }
}

public enum OrderStatus { Pending, Shipped }

public class Order
{
    public int Id { get; set; }
    public OrderStatus Status { get; set; }
    public string? Note;                          // public field
    public decimal Total { get; private set; }    // private setter
    public override string ToString() => $"Id={Id}, Status={Status}, Note={Note ?? "null"}, Total={Total}";
}

public class Account
{
    private Account() { }
    public string? Name { get; set; }
    public decimal Total { get; private set; }
    public override string ToString() => $"Name={Name}, Total={Total}";
}

public class FixedAccount
{
    [System.Text.Json.Serialization.JsonConstructor]
    private FixedAccount() { }
    public string? Name { get; set; }
    [JsonInclude]
    public decimal Total { get; private set; }
    public override string ToString() => $"Name={Name}, Total={Total}";
}
Newtonsoft   {"Note":"fragile","Id":7,"Status":1,"Total":0.0}
STJ default  {"Id":7,"Status":1,"Total":0}
STJ options  {"Id":7,"Status":"Shipped","Total":0,"Note":"fragile"}
Newtonsoft   Id=7, Status=Shipped, Note=fragile, Total=0
STJ default  JsonException: The JSON value could not be converted to OrderStatus. Path: $.Status | LineNumber: 0 | BytePositionInLine: 26.
STJ options  Id=7, Status=Shipped, Note=fragile, Total=0
Newtonsoft   Name=Ada, Total=0
STJ          NotSupportedException: Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'Account'. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
STJ fixed    Name=Ada, Total=12.5
  • Enums: both libraries write numbers by default. Newtonsoft reads "Shipped" anyway; System.Text.Json throws The JSON value could not be converted to OrderStatus until you add JsonStringEnumConverter. If your API used to accept enum names, register the converter globally before you switch.
  • Public fields: Newtonsoft serializes them, System.Text.Json skips them without a warning. IncludeFields = true or [JsonInclude] on the field fixes it.
  • Private setters: neither library sets them by default (Total=0 in both). In System.Text.Json, [JsonInclude] on the property makes it work.
  • Private constructor: Newtonsoft used it; System.Text.Json threw the long Deserialization of types without a parameterless constructor... message. [JsonConstructor] on the private constructor fixed it on .NET 10.

One compile-time gotcha when both packages are referenced during a migration: JsonConstructor, JsonRequired, JsonIgnore and JsonConverter exist in both namespaces. My first build of the next test failed with error CS0104: 'JsonRequired' is an ambiguous reference between 'Newtonsoft.Json.JsonRequiredAttribute' and 'System.Text.Json.Serialization.JsonRequiredAttribute'. Use the full name, as in [System.Text.Json.Serialization.JsonConstructor] above, or an alias.

Unknown Members, Required Properties and Nulls

Both libraries ignore JSON properties that have no matching .NET member. Both can be made strict, and since .NET 8 System.Text.Json has JsonUnmappedMemberHandling for this:

using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

var strict = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
var njStrict = new JsonSerializerSettings { MissingMemberHandling = MissingMemberHandling.Error };

string extra = """{"Sku":"A-1","Qty":2,"Colour":"red"}""";
Show("extra, Newtonsoft", () => JsonConvert.DeserializeObject<Line>(extra, njStrict));
Show("extra, STJ", () => JsonSerializer.Deserialize<Line>(extra, strict));

string missing = """{"Qty":2}""";
Show("missing, Newtonsoft", () => JsonConvert.DeserializeObject<Line>(missing));
Show("missing, STJ", () => JsonSerializer.Deserialize<Line>(missing));

string nullQty = """{"Sku":"A-1","Qty":null}""";
Show("null int, Newtonsoft", () => JsonConvert.DeserializeObject<Line>(nullQty));
Show("null int, STJ", () => JsonSerializer.Deserialize<Line>(nullQty));

var noNulls = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
Console.WriteLine(JsonSerializer.Serialize(new Line { Sku = "A-1", Qty = 2 }, noNulls));

static void Show(string label, Func<object?> action)
{
    try { Console.WriteLine($"{label,-21} {action()}"); }
    catch (Exception ex) { Console.WriteLine($"{label,-21} {ex.GetType().Name}: {ex.Message}"); }
}

public class Line
{
    [System.Text.Json.Serialization.JsonRequired]
    [JsonProperty(Required = Required.Always)]
    public string Sku { get; set; } = "";
    public int Qty { get; set; }
    public string? Comment { get; set; }
    public override string ToString() => $"Sku={Sku}, Qty={Qty}";
}
extra, Newtonsoft     JsonSerializationException: Could not find member 'Colour' on object of type 'Line'. Path 'Colour', line 1, position 30.
extra, STJ            JsonException: The JSON property 'Colour' could not be mapped to any .NET member contained in type 'Line'.
missing, Newtonsoft   JsonSerializationException: Required property 'Sku' not found in JSON. Path '', line 1, position 9.
missing, STJ          JsonException: JSON deserialization for type 'Line' was missing required properties including: 'Sku'.
null int, Newtonsoft  JsonSerializationException: Error converting value {null} to type 'System.Int32'. Path 'Qty', line 1, position 23.
null int, STJ         JsonException: The JSON value could not be converted to System.Int32. Path: $.Qty | LineNumber: 0 | BytePositionInLine: 23.
{"Sku":"A-1","Qty":2}

[JsonRequired] (or the C# required modifier) is the replacement for Required.Always. Note that a JSON null for an int fails in both libraries, with different messages. For writing, JsonIgnoreCondition.WhenWritingNull replaces NullValueHandling.Ignore: the null Comment was left out of the last line.

A Possible Object Cycle Was Detected

EF Core entities with navigation properties in both directions are the usual source of this one:

using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

var customer = new Customer { Name = "Ada" };
customer.Orders.Add(new Order { Id = 1, Customer = customer });

Show("Newtonsoft", () => JsonConvert.SerializeObject(customer));
Show("STJ", () => JsonSerializer.Serialize(customer));
Show("IgnoreCycles", () => JsonSerializer.Serialize(customer,
    new JsonSerializerOptions { ReferenceHandler = ReferenceHandler.IgnoreCycles }));
Show("Preserve", () => JsonSerializer.Serialize(customer,
    new JsonSerializerOptions { ReferenceHandler = ReferenceHandler.Preserve }));

static void Show(string label, Func<object?> action)
{
    try { Console.WriteLine($"{label,-12} {action()}"); }
    catch (Exception ex) { Console.WriteLine($"{label,-12} {ex.GetType().Name}: {ex.Message}"); }
}

public class Customer
{
    public string Name { get; set; } = "";
    public List<Order> Orders { get; } = [];
}

public class Order
{
    public int Id { get; set; }
    public Customer? Customer { get; set; }
}
Newtonsoft   JsonSerializationException: Self referencing loop detected for property 'Customer' with type 'Customer'. Path 'Orders[0]'.
STJ          JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Orders.Customer.Name.
IgnoreCycles {"Name":"Ada","Orders":[{"Id":1,"Customer":null}]}
Preserve     {"$id":"1","Name":"Ada","Orders":{"$id":"2","$values":[{"$id":"3","Id":1,"Customer":{"$ref":"1"}}]}}

The two fixes are not equivalent. IgnoreCycles writes null where the cycle would start, which is close to Newtonsoft's ReferenceLoopHandling.Ignore and readable by any client. Preserve keeps the full graph but adds $id/$ref metadata and wraps collections in $values, which a JavaScript client will not understand without extra code. For API responses, a DTO without the back-reference is still the cleanest fix.

Interfaces, Abstract Types and Polymorphism

using System.Text.Json;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

Show("interface", () => JsonSerializer.Deserialize<IShape>("""{"Radius":2}"""));

Shape[] shapes = [new Circle { Radius = 2 }, new Square { Side = 3 }];
string json = JsonSerializer.Serialize(shapes);
Console.WriteLine($"STJ          {json}");
var back = JsonSerializer.Deserialize<Shape[]>(json)!;
Console.WriteLine($"STJ back     {string.Join(", ", back.Select(s => s.GetType().Name))}");

var nj = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
Console.WriteLine($"Newtonsoft   {JsonConvert.SerializeObject(shapes, nj)}");

static void Show(string label, Func<object?> action)
{
    try { Console.WriteLine($"{label,-12} {action()}"); }
    catch (Exception ex) { Console.WriteLine($"{label,-12} {ex.GetType().Name}: {ex.Message}"); }
}

public interface IShape;

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Circle), "circle")]
[JsonDerivedType(typeof(Square), "square")]
public abstract class Shape : IShape;

public class Circle : Shape { public double Radius { get; set; } }
public class Square : Shape { public double Side { get; set; } }
interface    NotSupportedException: Deserialization of interface or abstract types is not supported. Type 'IShape'. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
STJ          [{"$type":"circle","Radius":2},{"$type":"square","Side":3}]
STJ back     Circle, Square
Newtonsoft   [{"$type":"Circle, pc-poly","Radius":2.0},{"$type":"Square, pc-poly","Side":3.0}]

Newtonsoft's TypeNameHandling writes .NET type and assembly names into the payload and, on reading, creates whatever type the JSON names. With untrusted input that is a known remote code execution risk unless you lock it down with a SerializationBinder. System.Text.Json only accepts the discriminators you list with [JsonDerivedType], and they are your own strings ("circle"), not type names. The catch: existing stored payloads with "Circle, pc-poly" discriminators won't deserialize after the switch, so plan a data migration or keep Newtonsoft on that path. Deserializing to an interface or abstract type without these attributes throws in both libraries. Microsoft's polymorphism guide covers unknown discriminators and nested hierarchies.

Dates and Dictionary Keys

using System.Globalization;
using System.Text.Json;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

Console.WriteLine($"Culture: {CultureInfo.CurrentCulture.Name}");
foreach (string value in new[] { "2026-10-08T14:30:00", "2026-10-08 14:30", "10/08/2026" })
{
    string json = $"\"{value}\"";
    Show($"Newtonsoft {value}", () => JsonConvert.DeserializeObject<DateTime>(json).ToString("yyyy-MM-dd HH:mm"));
    Show($"STJ        {value}", () => JsonSerializer.Deserialize<DateTime>(json).ToString("yyyy-MM-dd HH:mm"));
}

var byId = new Dictionary<int, string> { [1] = "one" };
var byPoint = new Dictionary<Point, string> { [new Point(1, 2)] = "a" };
Show("STJ int keys", () => JsonSerializer.Serialize(byId));
Show("STJ record keys", () => JsonSerializer.Serialize(byPoint));
Show("Newtonsoft write", () => JsonConvert.SerializeObject(byPoint));
Show("Newtonsoft read", () => JsonConvert.DeserializeObject<Dictionary<Point, string>>(
    JsonConvert.SerializeObject(byPoint)));

static void Show(string label, Func<object?> action)
{
    try { Console.WriteLine($"{label,-31} {action()}"); }
    catch (Exception ex) { Console.WriteLine($"{label,-31} {ex.GetType().Name}: {ex.Message}"); }
}

public record Point(int X, int Y);
Culture: en-IN
Newtonsoft 2026-10-08T14:30:00  2026-10-08 14:30
STJ        2026-10-08T14:30:00  2026-10-08 14:30
Newtonsoft 2026-10-08 14:30     2026-10-08 14:30
STJ        2026-10-08 14:30     JsonException: The JSON value could not be converted to System.DateTime. Path: $ | LineNumber: 0 | BytePositionInLine: 18.
Newtonsoft 10/08/2026           2026-10-08 00:00
STJ        10/08/2026           JsonException: The JSON value could not be converted to System.DateTime. Path: $ | LineNumber: 0 | BytePositionInLine: 12.
STJ int keys                    {"1":"one"}
STJ record keys                 NotSupportedException: The type 'Point' is not a supported dictionary key using converter of type 'System.Text.Json.Serialization.Converters.SmallObjectWithParameterizedConstructorConverter`5[Point,System.Int32,System.Int32,System.Object,System.Object]'. Custom converters can add support for dictionary key serialization by overriding the 'ReadAsPropertyName' and 'WriteAsPropertyName' methods. The unsupported member type is located on type 'System.String'. Path: $.
Newtonsoft write                {"Point { X = 1, Y = 2 }":"a"}
Newtonsoft read                 JsonSerializationException: Could not convert string 'Point { X = 1, Y = 2 }' to dictionary key type 'Point'. Create a TypeConverter to convert from the string to the key type object. Path '['Point { X = 1, Y = 2 }']', line 1, position 26.

System.Text.Json accepts ISO 8601 only. A space instead of T fails, and so does 10/08/2026. Newtonsoft parsed all three, and it read 10/08/2026 as October 8 even though this machine runs the en-IN culture, where that string means 10 August. That was silent before the migration; afterwards it at least throws. If you must accept other formats, write a JsonConverter<DateTime> that calls DateTime.ParseExact with the formats you expect.

Dictionaries with int, Guid or enum keys work. A record as a key fails in System.Text.Json at write time; Newtonsoft writes it using ToString() and then cannot read it back. Neither result is useful: convert to a list of key/value objects or write a converter that overrides WriteAsPropertyName/ReadAsPropertyName.

JObject and dynamic: JsonNode and JsonDocument

using System.Text.Json;
using System.Text.Json.Nodes;
using Newtonsoft.Json.Linq;

string json = """{"id":7,"customer":{"name":"Ada"},"lines":[{"sku":"A-1","qty":2}]}""";

// Newtonsoft
JObject jo = JObject.Parse(json);
string? name1 = (string?)jo["customer"]?["name"];
jo["status"] = "shipped";
dynamic d = jo;
int qty1 = d.lines[0].qty;
Console.WriteLine($"JObject   {name1} {qty1} {jo.ToString(Newtonsoft.Json.Formatting.None)}");

// System.Text.Json: mutable DOM
JsonNode node = JsonNode.Parse(json)!;
string? name2 = (string?)node["customer"]?["name"];
node["status"] = "shipped";
int qty2 = node["lines"]![0]!["qty"]!.GetValue<int>();
Console.WriteLine($"JsonNode  {name2} {qty2} {node.ToJsonString()}");

// System.Text.Json: read-only, pooled memory
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
Console.WriteLine($"JsonDoc   {root.GetProperty("customer").GetProperty("name").GetString()} " +
                  $"{root.GetProperty("lines")[0].GetProperty("qty").GetInt32()}");
Console.WriteLine(root.TryGetProperty("missing", out _));

All three printed Ada 2, and JObject and JsonNode wrote the identical string, {"id":7,"customer":{"name":"Ada"},"lines":[{"sku":"A-1","qty":2}],"status":"shipped"}. JsonNode is the mutable replacement for JObject/JArray; the indexer syntax is almost the same, but there is no dynamic support, so d.lines[0].qty becomes node["lines"]![0]!["qty"]!.GetValue<int>(). JsonDocument is read-only and rents pooled buffers, so it must be disposed. GetProperty throws KeyNotFoundException for a missing property; use TryGetProperty, which returned False in the test, when a field is optional. Code that walks JToken trees heavily is usually the most expensive part of a migration, which is a fair reason to leave it on Newtonsoft for now.

ASP.NET Core: Web Defaults and AddNewtonsoftJson

ASP.NET Core does not use the default JsonSerializerOptions. I built a minimal API app, printed the options for minimal APIs and MVC, then started it on a random port, posted one request and stopped it:

using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddControllers();
var app = builder.Build();
app.MapPost("/echo", (Person p) => p);

var minimal = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>()
    .Value.SerializerOptions;
var mvc = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Mvc.JsonOptions>>()
    .Value.JsonSerializerOptions;
foreach (var (name, o) in new[] { ("Minimal APIs", minimal), ("MVC", mvc) })
    Console.WriteLine($"{name}: naming={o.PropertyNamingPolicy?.GetType().Name}, " +
                      $"caseInsensitive={o.PropertyNameCaseInsensitive}, numbers={o.NumberHandling}");

await app.StartAsync();
string url = app.Services.GetRequiredService<IServer>().Features
    .Get<IServerAddressesFeature>()!.Addresses.First();
using var http = new HttpClient();
var response = await http.PostAsync($"{url}/echo",
    new StringContent("""{"NAME":"Ada","age":"36"}""", System.Text.Encoding.UTF8, "application/json"));
Console.WriteLine(await response.Content.ReadAsStringAsync());
await app.StopAsync();

public class Person
{
    public string? Name { get; set; }
    public int Age { get; set; }
}
Minimal APIs: naming=JsonCamelCaseNamingPolicy, caseInsensitive=True, numbers=AllowReadingFromString
MVC: naming=JsonCamelCaseNamingPolicy, caseInsensitive=True, numbers=AllowReadingFromString
{"name":"Ada","age":36}

Both pipelines use the Web defaults: camelCase output, case-insensitive input, numbers in quotes accepted. "NAME" and "36" were bound. This is why a migration can pass all API tests and still break a background service: the same model read with JsonSerializer.Deserialize<T>(json) outside ASP.NET Core uses the strict defaults. Pass JsonSerializerOptions.Web there too. When the error does happen inside a request, global error handling in ASP.NET Core shows how to return a consistent problem response.

To keep Newtonsoft for controllers, add the Microsoft.AspNetCore.Mvc.NewtonsoftJson package (10.0.12 here) and call .AddNewtonsoftJson() after AddControllers(). Printing MvcOptions afterwards showed NewtonsoftJsonPatchInputFormatter, NewtonsoftJsonInputFormatter as input formatters and NewtonsoftJsonOutputFormatter as the JSON output formatter. It only changes MVC. In a test app with NullValueHandling.Ignore configured that way, a controller returned {"name":"Ada"} while a minimal API endpoint returning the same object gave {"name":"Ada","note":null}: minimal APIs keep using System.Text.Json, so a mixed app has two serializers with different settings.

Source Generation, Trimming and Native AOT

With a JsonSerializerContext, the serializer metadata is generated at compile time instead of discovered with reflection. That is required for trimmed and Native AOT apps, and Newtonsoft.Json has no equivalent. I set <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault> in the project file to see what happens to code that still uses the reflection overload:

using System.Text.Json;
using System.Text.Json.Serialization;

var order = new Order(7, "Ada", 12.5m);
string json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
Console.WriteLine(json);
Console.WriteLine(JsonSerializer.Deserialize(json, AppJsonContext.Default.Order));

try
{
    JsonSerializer.Serialize(order);   // reflection-based overload
}
catch (Exception ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}

public record Order(int Id, string Customer, decimal Total);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Order))]
internal partial class AppJsonContext : JsonSerializerContext;
{"id":7,"customer":"Ada","total":12.5}
Order { Id = 7, Customer = Ada, Total = 12.5 }
InvalidOperationException: Reflection-based serialization has been disabled for this application. Either use the source generator APIs or explicitly configure the 'JsonSerializerOptions.TypeInfoResolver' property.

Publishing the same project with -p:PublishTrimmed=true also flags the reflection call at build time with warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. Treat those warnings as a to-do list. If you are new to generators, the C# source generators tutorial explains what runs at build time, and Microsoft's source generation guide lists the supported options.

Benchmark: Serialize and Deserialize

The object is an order with a customer name, a DateTime, 20 line items and a two-entry dictionary. I used BenchmarkDotNet's ShortRunJob to keep each run under a minute:

using System.Text.Json;
using System.Text.Json.Serialization;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

BenchmarkRunner.Run<JsonBench>();

[MemoryDiagnoser, ShortRunJob]
public class JsonBench
{
    private readonly PurchaseOrder _order = PurchaseOrder.Sample();
    private string _json = "";

    [GlobalSetup]
    public void Setup() => _json = JsonSerializer.Serialize(_order, BenchContext.Default.PurchaseOrder);

    [Benchmark] public string Newtonsoft_Serialize() => JsonConvert.SerializeObject(_order);
    [Benchmark] public string Stj_Serialize() => JsonSerializer.Serialize(_order);
    [Benchmark] public string StjSourceGen_Serialize() => JsonSerializer.Serialize(_order, BenchContext.Default.PurchaseOrder);

    [Benchmark] public PurchaseOrder? Newtonsoft_Deserialize() => JsonConvert.DeserializeObject<PurchaseOrder>(_json);
    [Benchmark] public PurchaseOrder? Stj_Deserialize() => JsonSerializer.Deserialize<PurchaseOrder>(_json);
    [Benchmark] public PurchaseOrder? StjSourceGen_Deserialize() => JsonSerializer.Deserialize(_json, BenchContext.Default.PurchaseOrder);
}

public class PurchaseOrder
{
    public int Id { get; set; }
    public string Customer { get; set; } = "";
    public DateTime CreatedAt { get; set; }
    public List<OrderLine> Lines { get; set; } = [];
    public Dictionary<string, string> Tags { get; set; } = [];

    public static PurchaseOrder Sample() => new()
    {
        Id = 1042,
        Customer = "Ada Lovelace",
        CreatedAt = new DateTime(2026, 10, 8, 14, 30, 0, DateTimeKind.Utc),
        Lines = Enumerable.Range(1, 20)
            .Select(i => new OrderLine { Sku = $"SKU-{i:D4}", Quantity = i % 5 + 1, UnitPrice = 9.99m + i })
            .ToList(),
        Tags = new() { ["channel"] = "web", ["priority"] = "normal" },
    };
}

public class OrderLine
{
    public string Sku { get; set; } = "";
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

[JsonSerializable(typeof(PurchaseOrder))]
internal partial class BenchContext : JsonSerializerContext;

BenchmarkDotNet generates code that refers to a BenchmarkDotNet.Order namespace, so my first version with a class named Order failed with error CS0118: 'Order' is a namespace but is used like a type. Hence PurchaseOrder. The first of three runs, Release build:

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9457/25H2/2025Update/HudsonValley2)
Intel Core Ultra 5 125U 1.30GHz, 1 CPU, 14 logical and 12 physical cores
.NET SDK 10.0.401
  [Host]   : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3
  ShortRun : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=ShortRun  IterationCount=3  LaunchCount=1  
WarmupCount=3  

| Method                   | Mean      | Error     | StdDev    | Gen0   | Gen1   | Allocated |
|------------------------- |----------:|----------:|----------:|-------:|-------:|----------:|
| Newtonsoft_Serialize     |  6.609 us |  4.677 us | 0.2564 us | 1.5030 | 0.0229 |   9.21 KB |
| Stj_Serialize            |  3.345 us |  3.694 us | 0.2025 us | 0.4120 |      - |   2.53 KB |
| StjSourceGen_Serialize   |  3.284 us |  3.083 us | 0.1690 us | 0.3624 |      - |   2.23 KB |
| Newtonsoft_Deserialize   | 12.520 us | 16.881 us | 0.9253 us | 0.9918 |      - |   6.14 KB |
| Stj_Deserialize          |  6.118 us |  7.807 us | 0.4279 us | 0.5417 |      - |   3.34 KB |
| StjSourceGen_Deserialize |  5.742 us |  8.764 us | 0.4804 us | 0.5417 |      - |   3.34 KB |

Across three runs on the same laptop (Intel Core Ultra 5 125U, .NET 10.0.12, X64 RyuJIT), the means ranged:

MethodMean (3 runs)Allocated
Newtonsoft serialize6.5–6.7 µs9.21 KB
System.Text.Json serialize2.8–3.9 µs2.53 KB
System.Text.Json source-gen serialize2.1–3.3 µs2.23 KB
Newtonsoft deserialize8.9–12.5 µs6.14 KB
System.Text.Json deserialize6.1–6.7 µs3.34 KB
System.Text.Json source-gen deserialize5.7–5.9 µs3.34 KB

A short job on a laptop CPU is noisy (look at the Error column), so read this as "roughly 2x faster to serialize, 1.3–2x faster to deserialize, far fewer allocations", not as exact numbers. Allocations were identical in every run, and they matter more for a busy API than a few microseconds.

Migration Checklist: Newtonsoft to System.Text.Json

"Ran" means I verified it in the tests above; "Docs" rows come from Microsoft's migration guide, which has the full list.

Newtonsoft.JsonSystem.Text.JsonChecked
Case-insensitive by defaultPropertyNameCaseInsensitive = true or JsonSerializerOptions.WebRan
CamelCasePropertyNamesContractResolverPropertyNamingPolicy = JsonNamingPolicy.CamelCaseDocs
Numbers in quotes acceptedNumberHandling = JsonNumberHandling.AllowReadingFromStringRan
Comments, trailing commas acceptedReadCommentHandling = Skip, AllowTrailingCommas = trueRan
StringEnumConverterJsonStringEnumConverterRan
Public fields serializedIncludeFields = true or [JsonInclude]Ran
[JsonProperty] on a private setter[JsonInclude]Ran
[JsonProperty("name")][JsonPropertyName("name")]Docs
[JsonConstructor] on a non-public constructor[JsonConstructor] (System.Text.Json.Serialization)Ran
MissingMemberHandling.ErrorUnmappedMemberHandling = JsonUnmappedMemberHandling.DisallowRan
Required = Required.Always[JsonRequired] or the required modifierRan
NullValueHandling.IgnoreDefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNullRan
ReferenceLoopHandling.IgnoreReferenceHandler.IgnoreCycles (writes null)Ran
PreserveReferencesHandling.AllReferenceHandler.PreserveRan
TypeNameHandling[JsonPolymorphic] + [JsonDerivedType]Ran
DateFormatString, non-ISO datesCustom JsonConverter<DateTime>Docs
JObject, JArray, JTokenJsonNode (mutable), JsonDocument (read-only)Ran
AddNewtonsoftJson() in MVCRemove it; configure AddJsonOptions() insteadDocs

My order of work for a real migration: switch one boundary at a time (a single API, a single queue consumer), run the existing tests with the strict options first so that the silent failures (camelCase, fields, private setters) show up as wrong values in assertions, and grep for JObject, dynamic and TypeNameHandling before estimating the work.

FAQ

Is Newtonsoft.Json deprecated? No. Version 13.0.4 is the current NuGet release and it runs fine on .NET 10. It is simply not where new features land, and it has no source generator: a trimmed publish of a small Newtonsoft test app warned IL2104: Assembly 'Newtonsoft.Json' produced trim warnings.

Why do I get "The JSON value could not be converted to System.Int32"? In my tests it came from a number in quotes ("36") and from null for a non-nullable int. Use AllowReadingFromString for the first and int? for the second. The Path in the message tells you which property.

Can I use both libraries in one project? Yes, and during a migration you will. Watch for the CS0104 attribute name clashes and remember that a [JsonIgnore] from one namespace is ignored by the other serializer.

This article is part of the C# tutorials guide (language features, patterns, performance and testing).

References: Microsoft: Migrate from Newtonsoft.Json to System.Text.Json · Microsoft: System.Text.Json source generation · Microsoft: Polymorphic serialization

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...