Skip to content
datahacks.dev
JSON

Reading JSON in C Sharp With System Text Json Defaults

What System.Text.Json does by default in .NET 9: exact-case names, JsonSerializerDefaults.Web, enum surprises, the 64 level depth limit and duplicate keys.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

The .NET answer to JSON is System.Text.Json, in the box since .NET Core 3. It is fast and strict, and its defaults differ from Newtonsoft.Json, which most older code used. People who move between the two, or who come from Go or Java, hit the same handful of surprises. I ran the same order document through it plus a set of small cases for each, on .NET 9.

The first surprise: exact case

C#
record Slim { public string? Status { get; init; } }

JsonSerializer.Deserialize<Slim>("{\"status\":\"paid\",\"surprise\":1}")
// Status == null

The key status did not bind to the property Status. Property names are matched exactly, and the property is PascalCase while JSON is almost always camelCase or snake_case. There was no exception: Status was null, and the unknown key surprise was ignored as well. An API client that deserializes with default options will, on the first day, show every field empty.

There are two ways out. Set a naming policy, which also controls what is written:

C#
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };

Or use the preset meant for web APIs, which turns on camelCase and case-insensitive reading:

C#
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
JsonSerializer.Deserialize<Slim>("{\"STATUS\":\"x\"}", web)    // Status == "x"

With the snake case policy, the sample order read correctly: id was 9007199254740993, the customer email was null, there were two items, extra held {"gift": true, "wrap": "blue"} as a JsonElement and the timestamp parsed as a DateTimeOffset. Records and required members both work. The Web preset is not just a convenience, though; it changes other behavior, covered next.

Web defaults relax more than the case

C#
JsonSerializer.Deserialize<IntBox>("{\"n\":\"5\"}", new JsonSerializerOptions(JsonSerializerDefaults.Web))
// IntBox { N = 5 }

JsonSerializer.Deserialize<IntBox>("{\"n\":\"5\"}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
// JsonException: The JSON value could not be converted to System.Int32. Path: $.n

The Web preset sets NumberHandling to AllowReadingFromString, so a JSON string containing digits is accepted as a number. Plain options with case-insensitive names rejected the same input. A client that sends "5" is now accepted on a service that deserializes with Web and rejected on one that does not. Choose and test on purpose. For a public API I prefer to set the two things I want (naming policy and case rule) and leave number handling strict, so a client that sends a string for a number finds out.

Errors you should expect

Every error was a JsonException carrying a path, which is useful for a 400 response:

Text
{"n":"abc"}          The JSON value could not be converted to System.Int32. Path: $.n
{"n":null}           ... could not be converted to System.Int32. Path: $.n
{"n":3000000000}     ... could not be converted to System.Int32. Path: $.n
{"n":1.5}            ... could not be converted to System.Int32. Path: $.n
{}  (missing n)      IntBox { N = 0 }

A missing member leaves the default (0), a JSON null for a non-nullable value type throws, and the message does not say which of the several reasons applied. Mark members required to make absence an error (the message then names the property: was missing required properties including: 'Name'), and use int? where null is legal. For reference types, {"status":null} produced Status == null on a string? property without complaint. Do not treat the compiler's nullable annotations as validation of what arrives.

Unknown members are ignored by default. To reject them set UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow; for the surprise key it threw The JSON property 'surprise' could not be mapped to any .NET member. As in other languages, decide this per use (handling unknown JSON fields in Go goes through the same choice).

Enums accept more than they declare

C#
enum PlanKind { Free, Pro }

Plain options rejected the string "Pro" for an enum, since strings need a converter. With JsonStringEnumConverter(JsonNamingPolicy.CamelCase), "pro" read as Pro and "gold" threw. But a number goes straight through:

C#
{"plan": 7}      // PlanKind = 7

The value 7 is not defined in the enum, and it was stored anyway, because an enum in .NET is an integer underneath. Code that switches on the enum and does not expect 7 falls through to its default. Validate with Enum.IsDefined after reading. The string enum converter also has an allowIntegerValues parameter for refusing numbers, which I did not test.

Numbers

The sample's 16 digit id read into a long exactly. In the DOM, JsonNode kept 9007199254740993 and GetValue<long>() returned it, while GetValue<double>() returned 9007199254740992. A decimal property kept the first 28 digits of a long fraction: 0.1000000000000000055511151231 from an input with 34. Use decimal for money and expect rounding past 28 significant digits.

Depth, duplicates and strictness

MaxDepth is 64 by default. A document nested 64 deep parsed and 65 threw JsonReaderException: The maximum configured depth of 64 has been exceeded. Setting MaxDepth = 3000 on JsonDocumentOptions let a 2,000 level document through. Raise it only for trusted input (limiting depth and size of incoming JSON). A parser that refuses at 65 levels by default is doing a service, and it is stricter than the defaults of most of the others I tested (JSON security mistakes in apps and pipelines).

Duplicate keys are not an error in .NET 9. JsonDocument returned admin for {"role":"user","role":"admin"}, and JsonNode.Parse kept both entries when printed back, which is an odd result because a later consumer may take either. The option to reject duplicates (AllowDuplicateProperties) is not in .NET 9; I could not compile it, and I understand it arrives in .NET 10. If the body makes a security decision, check for duplicates yourself or use a stricter parser.

The reader is otherwise strict. A trailing comma, a comment, NaN and a second value after the first all threw: The JSON object contains a trailing comma at the end which is not supported in this mode, '/' is invalid after a value, 'N' is an invalid start of a value, and '{' is invalid after a single JSON value. JsonDocumentOptions with AllowTrailingCommas and CommentHandling.Skip turns the first two on, for config files.

Writing

Serializing a record with the snake case policy gave {"id":5,"customer":{"name":"A","email":null},"items":[...],"status":"paid","extra":null,"created_at":"2026-01-02T03:04:05+00:00"}. Dates are written as ISO 8601 with an offset, unlike Jackson's default of a number (reading JSON in Java with Jackson). null members are written, which you can change with DefaultIgnoreCondition.

For generated classes there is the JSON to C# converter. The reference is Microsoft's System.Text.Json overview, and the options are listed in How to customize property names and values.

When Newtonsoft or a source generator is the better fit

An existing codebase uses Newtonsoft.Json. Its defaults are more lenient (case-insensitive, converts types freely), and rewriting call sites has a cost. Moving has to be a decision with tests, not a find and replace.

You need features it lacks. JsonPath queries, JObject merging, flexible converters for unusual shapes and LINQ over JSON are easier there.

Startup time or trimming matters. System.Text.Json has a source generator that avoids reflection (JsonSerializerContext) and works with native AOT. If you publish a small trimmed binary, use it, and add each type to the context.

You only need to inspect. JsonDocument gives read-only access with low allocation. Use JsonNode when you need to modify, and a typed model when the shape is fixed.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.