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 reachesName. There is no exception, justnulland0. This is the bug that ships to production, because nothing fails. "36"into anintthrowsThe JSON value could not be converted to System.Int32. Newtonsoft converts it. The Web defaults accept it, because they includeJsonNumberHandling.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 throwsThe JSON value could not be converted to OrderStatusuntil you addJsonStringEnumConverter. 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 = trueor[JsonInclude]on the field fixes it. - Private setters: neither library sets them by default (
Total=0in 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:
| Method | Mean (3 runs) | Allocated |
|---|---|---|
| Newtonsoft serialize | 6.5–6.7 µs | 9.21 KB |
| System.Text.Json serialize | 2.8–3.9 µs | 2.53 KB |
| System.Text.Json source-gen serialize | 2.1–3.3 µs | 2.23 KB |
| Newtonsoft deserialize | 8.9–12.5 µs | 6.14 KB |
| System.Text.Json deserialize | 6.1–6.7 µs | 3.34 KB |
| System.Text.Json source-gen deserialize | 5.7–5.9 µs | 3.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.Json | System.Text.Json | Checked |
|---|---|---|
| Case-insensitive by default | PropertyNameCaseInsensitive = true or JsonSerializerOptions.Web | Ran |
CamelCasePropertyNamesContractResolver | PropertyNamingPolicy = JsonNamingPolicy.CamelCase | Docs |
| Numbers in quotes accepted | NumberHandling = JsonNumberHandling.AllowReadingFromString | Ran |
| Comments, trailing commas accepted | ReadCommentHandling = Skip, AllowTrailingCommas = true | Ran |
StringEnumConverter | JsonStringEnumConverter | Ran |
| Public fields serialized | IncludeFields = 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.Error | UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow | Ran |
Required = Required.Always | [JsonRequired] or the required modifier | Ran |
NullValueHandling.Ignore | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | Ran |
ReferenceLoopHandling.Ignore | ReferenceHandler.IgnoreCycles (writes null) | Ran |
PreserveReferencesHandling.All | ReferenceHandler.Preserve | Ran |
TypeNameHandling | [JsonPolymorphic] + [JsonDerivedType] | Ran |
DateFormatString, non-ISO dates | Custom JsonConverter<DateTime> | Docs |
JObject, JArray, JToken | JsonNode (mutable), JsonDocument (read-only) | Ran |
AddNewtonsoftJson() in MVC | Remove it; configure AddJsonOptions() instead | Docs |
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
Post a Comment