Handle malformed JSON in .NET with System.Text.Json
What ReadFromJsonAsync does with broken and awkward JSON, which cases don't throw, and a reader that turns each into a clear error.
Examples last run against the API on
An API can answer 200 OKwith a body that isn't valid JSON: a response cut off by a proxy, an empty body, an HTML error page, a value of the wrong type. How your .NET code reacts decides whether users see a clear error or a crash with a JsonException deep in a log. To test that, you need responses that are broken on purpose. https://api.testingapis.comhas a set of them; they're for signed-in members, and free. This guide shows exactly what System.Text.Json and ReadFromJsonAsyncdo with each, the cases that don't throw but should, a safe reader, and xUnit tests. Everything was run on .NET 10.
$ curl -i https://api.testingapis.com/api/formats/malformed-json
HTTP/1.1 200 OK
content-length: 61
content-type: application/json; charset=utf-8
{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": truTwo things are wrong in those 61 bytes: a trailing comma after "b" and a body cut off in the middle of true. The status is still 200 and the content type still says JSON.
Broken and awkward JSON, on demand
| Request | What comes back |
|---|---|
GET /api/formats/malformed-jsonsign in to see | 200, application/json, invalid and truncated JSON |
GET /api/formats/emptysign in to see | 200 with an empty body |
GET /api/formats/no-contentsign in to see | 204 No Content |
GET /api/formats/wrong-content-typesign in to see | Valid JSON labelled text/html |
GET /api/formats/bomsign in to see | Valid JSON with a UTF-8 byte order mark in front |
GET /api/edge-cases/duplicate-keyssign in to see | Valid JSON with the same key twice |
GET /api/edge-cases/numberssign in to see | int64 limits, numbers beyond JavaScript's safe range, a 30-digit integer, numbers as strings |
GET /api/edge-cases/top-level/stringsign in to see | A JSON string where an object is expected |
GET /api/edge-cases/mixed-arraysign in to see | One array holding numbers, strings, booleans, null, an object and an array |
What ReadFromJsonAsync does with each
Each response read into a simple record. Invalid JSON throws a JsonException whose Pathpoints at the problem; three of the cases don't throw at all:
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
async Task Read<T>(string path, JsonSerializerOptions? options = null)
{
try
{
using var response = await http.GetAsync(path);
var value = await response.Content.ReadFromJsonAsync<T>(options);
Console.WriteLine($"{path,-38} OK {JsonSerializer.Serialize(value)}");
}
catch (JsonException ex)
{
Console.WriteLine($"{path,-38} JsonException at {ex.Path}: {ex.Message[..ex.Message.IndexOf(". ")]}");
}
}
await Read<Person>("/api/formats/malformed-json");
await Read<Person>("/api/formats/empty");
await Read<Person>("/api/formats/no-content");
await Read<Person>("/api/formats/wrong-content-type");
await Read<Person>("/api/formats/bom");
await Read<Person>("/api/edge-cases/duplicate-keys");
await Read<Person>("/api/edge-cases/top-level/string");
await Read<List<int>>("/api/edge-cases/mixed-array");
record Person(int Id, string Name);/api/formats/malformed-json JsonException at $.tags: The JSON array contains a trailing comma at the end which is not supported in this mode
/api/formats/empty JsonException at $: The input does not contain any JSON tokens
/api/formats/no-content JsonException at $: The input does not contain any JSON tokens
/api/formats/wrong-content-type OK {"Id":1,"Name":"Alice Johnson"}
/api/formats/bom OK {"Id":1,"Name":"Alice Johnson"}
/api/edge-cases/duplicate-keys OK {"Id":2,"Name":"second value"}
/api/edge-cases/top-level/string JsonException at $: The JSON value could not be converted to Person
/api/edge-cases/mixed-array JsonException at $[1]: The JSON value could not be converted to System.Int32| Response | Result | Why it matters |
|---|---|---|
| Truncated or invalid JSON | JsonException, with Path, LineNumber and BytePositionInLine | Catch it and report which call broke, or it surfaces as an unexplained crash |
| Empty 200, or 204 | JsonException: no JSON tokens | Check for an empty body before parsing when the API might send one |
| JSON labelled text/html | Parsed without complaint | ReadFromJsonAsync doesn't check Content-Type. A real HTML error page fails later, as invalid JSON |
| Byte order mark | Parsed | Handled for you |
| The same key twice | Parsed: the last value wins, silently | id became 2 and name "second value". Make it an error with AllowDuplicateProperties = false (.NET 10) |
| Wrong shape (a string, a mixed array) | JsonException at the first bad value | Path says where: $ for the root, $[1] for the second element |
Numbers
C# handles large integers better than JavaScript: a longkeeps 9007199254740993 exactly, where JavaScript rounds it. But a 30-digit integer doesn't fit a long, and numbers sent as strings depend on your options. ReadFromJsonAsync uses the web defaults, which accept them; options without NumberHandling.AllowReadingFromString do not.
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
using var response = await http.GetAsync("/api/edge-cases/numbers");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(JsonSerializer.Serialize(
JsonSerializer.Deserialize<Numbers>(json, JsonSerializerOptions.Web)));
// The same field, read with the web defaults and without them.
var strict = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
foreach (var (label, options) in new[] { ("Web defaults", JsonSerializerOptions.Web), ("Without web defaults", strict) })
{
try
{
var value = JsonSerializer.Deserialize<AsString>(json, options);
Console.WriteLine($"{label}: \"9223372036854775807\" read as {value!.Int64MaxAsString}");
}
catch (JsonException ex) { Console.WriteLine($"{label}: JsonException at {ex.Path}"); }
}
try { JsonSerializer.Deserialize<TooBig>(json, JsonSerializerOptions.Web); }
catch (JsonException ex) { Console.WriteLine($"bigInteger into long: JsonException at {ex.Path}"); }
record Numbers(long Int64Max, long BeyondSafeInteger, double PointOnePlusPointTwo);
record AsString(long Int64MaxAsString);
record TooBig(long BigInteger);{"Int64Max":9223372036854775807,"BeyondSafeInteger":9007199254740993,"PointOnePlusPointTwo":0.30000000000000004}
Web defaults: "9223372036854775807" read as 9223372036854775807
Without web defaults: JsonException at $.int64MaxAsString
bigInteger into long: JsonException at $.bigIntegerA safe reader: one clear error for every bad body
Read the body as a string first, so an error can quote it, then parse. Every failure becomes an InvalidDataExceptionnaming the URL, the content type, where parsing stopped and the start of the body. That's what you need in a log at 3 a.m.
using System.Text.Json;
public static class JsonResponses
{
// Web defaults (camelCase, case-insensitive, numbers in strings allowed), but a repeated
// property is an error instead of the last value silently winning (.NET 10).
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
AllowDuplicateProperties = false,
};
/// Reads a JSON body, or throws an InvalidDataException that says what came back and where it broke.
public static async Task<T> ReadJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
{
response.EnsureSuccessStatusCode();
var url = response.RequestMessage?.RequestUri;
var contentType = response.Content.Headers.ContentType?.ToString() ?? "no content type";
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
throw new InvalidDataException($"{(int)response.StatusCode} from {url} had an empty body ({contentType}).");
}
try
{
return JsonSerializer.Deserialize<T>(body, Options)
?? throw new InvalidDataException($"{url} returned JSON null.");
}
catch (JsonException ex)
{
var preview = body.Length > 120 ? body[..120] + "..." : body;
throw new InvalidDataException(
$"Invalid JSON from {url} ({contentType}) at {ex.Path}, line {ex.LineNumber}, byte {ex.BytePositionInLine}: {preview}", ex);
}
}
}using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
foreach (var path in new[] { "/api/Products/1", "/api/formats/malformed-json", "/api/formats/empty", "/api/edge-cases/duplicate-keys" })
{
try
{
using var response = await http.GetAsync(path);
var item = await response.ReadJsonAsync<Item>();
Console.WriteLine($"OK: {item}");
}
catch (InvalidDataException ex)
{
Console.WriteLine(ex.Message);
}
}
record Item(int Id);OK: Item { Id = 1 }
Invalid JSON from https://api.testingapis.com/api/formats/malformed-json (application/json; charset=utf-8) at $.tags, line 0, byte 45: {"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru
200 from https://api.testingapis.com/api/formats/empty had an empty body (no content type).
Invalid JSON from https://api.testingapis.com/api/edge-cases/duplicate-keys (application/json; charset=utf-8) at $.id, line 5, byte 7: {
"id": 1,
"name": "first value",
"name": "second value",
"nested": { "flag": true, "flag": false },
"id": 2
}The duplicate is reported at $.id, line 5: Item only has an Id, so that's the repeated property it cares about. Keys the type doesn't map are skipped either way.
xUnit tests
One theory covers every broken response; a fact checks that good JSON still reads. They pass with xUnit 2.9 on .NET 10, using the JsonResponses class above.
using Xunit;
public class JsonResponseTests
{
private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.testingapis.com") };
private record Item(int Id);
[Theory]
[InlineData("/api/formats/malformed-json")]
[InlineData("/api/formats/empty")]
[InlineData("/api/edge-cases/duplicate-keys")]
[InlineData("/api/edge-cases/top-level/string")]
public async Task Bad_json_becomes_one_clear_error(string path)
{
using var response = await Http.GetAsync(path);
var ex = await Assert.ThrowsAsync<InvalidDataException>(() => response.ReadJsonAsync<Item>());
Assert.Contains(path, ex.Message); // the message says which call broke
}
[Fact]
public async Task Good_json_still_reads()
{
using var response = await Http.GetAsync("/api/Products/1");
var item = await response.ReadJsonAsync<Item>();
Assert.Equal(1, item.Id);
}
}What to check in your client
- Invalid JSON with a 200 status is an error, not a crash: caught, logged with the URL, shown to the user.
- An empty body or a 204 is handled before parsing where the API can send one.
- Duplicate keys are rejected where a silent "last one wins" would hide a bug.
- Large IDs fit their type:
long, orstringwhen they can exceed it. - Errors quote enough of the body to diagnose it, without logging secrets.
The same responses help in other languages: the JSON edge cases reference and formats reference list them all, and errors on demand covers failing status codes.