Writing Custom JsonConverters in System.Text.Json
The built-in converters in System.Text.Json handle most common types, but real-world APIs frequently return data in shapes that don't map cleanly to your domain models. Custom JsonConverter<T> implementations let you take full control of how a type is read and written.
When you need a custom converter
Common scenarios include:
- Non-standard date formats — an API returns
"dd/MM/yyyy"instead of ISO 8601 - Union types — a JSON property can be either a string or an object
- Flattening or restructuring — the wire format differs from your domain model
- Enums with custom string mappings — values that don't match the C# member names
Anatomy of a JsonConverter
Every custom converter inherits from JsonConverter<T> and implements two methods:
public class MoneyConverter : JsonConverter<Money>
{
public override Money Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Read from JSON
}
public override void Write(
Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
{
// Write to JSON
}
}
The Utf8JsonReader is a forward-only, low-allocation reader over UTF-8 bytes. The Utf8JsonWriter writes directly to a buffer. Both operate at a lower level than Newtonsoft's JsonReader/JsonWriter, which means more explicit token management but better performance.
Example: a value converter
Suppose an API returns monetary values as strings like "GBP:49.99". You want to map this to a Money record:
public record Money(string Currency, decimal Amount);
public class MoneyConverter : JsonConverter<Money>
{
public override Money Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString()
?? throw new JsonException("Expected a non-null string for Money.");
var parts = raw.Split(':');
if (parts.Length != 2 || !decimal.TryParse(parts[1], CultureInfo.InvariantCulture, out var amount))
throw new JsonException($"Invalid money format: '{raw}'");
return new Money(parts[0], amount);
}
public override void Write(
Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
{
writer.WriteStringValue(
$"{value.Currency}:{value.Amount.ToString(CultureInfo.InvariantCulture)}");
}
}
Register it globally or per-property:
// Global registration
options.Converters.Add(new MoneyConverter());
// Per-property
public class Invoice
{
[JsonConverter(typeof(MoneyConverter))]
public Money Total { get; set; }
}
Example: an object converter
When the JSON shape doesn't match your C# model, you need to read and write individual properties. Consider an API that returns a flat structure you want to map to a nested model:
{
"name": "Alice",
"address_line1": "10 Downing Street",
"address_city": "London"
}
public record Customer(string Name, Address Address);
public record Address(string Line1, string City);
public class CustomerConverter : JsonConverter<Customer>
{
public override Customer Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? name = null, line1 = null, city = null;
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException();
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
var prop = reader.GetString();
reader.Read();
switch (prop)
{
case "name": name = reader.GetString(); break;
case "address_line1": line1 = reader.GetString(); break;
case "address_city": city = reader.GetString(); break;
}
}
return new Customer(
name ?? throw new JsonException("Missing 'name'"),
new Address(line1 ?? "", city ?? ""));
}
public override void Write(
Utf8JsonWriter writer, Customer value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("name", value.Name);
writer.WriteString("address_line1", value.Address.Line1);
writer.WriteString("address_city", value.Address.City);
writer.WriteEndObject();
}
}
Using JsonConverterFactory for generic types
If you need a converter for an open generic type — say Result<T> — use JsonConverterFactory:
public class ResultConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert.IsGenericType
&& typeToConvert.GetGenericTypeDefinition() == typeof(Result<>);
}
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var innerType = typeToConvert.GetGenericArguments()[0];
var converterType = typeof(ResultConverter<>).MakeGenericType(innerType);
return (JsonConverter)Activator.CreateInstance(converterType)!;
}
}
Then implement ResultConverter<T> as a standard JsonConverter<Result<T>>.
Tips and gotchas
Always advance the reader. The Utf8JsonReader is a struct passed by reference. If your Read method doesn't consume all the tokens for your type, the deserializer will be left in a corrupt state.
Don't call JsonSerializer.Deserialize inside a converter for the same type. This creates infinite recursion. If you need to delegate to the default behaviour for nested objects, use the options parameter to deserialize child types only.
Handle nulls explicitly. If your type is a reference type, System.Text.Json calls the converter even for null JSON values by default. Override HandleNull to return true if you want to handle nulls yourself, or let the framework handle them.
Test round-trip serialization. Always verify that Serialize(Deserialize(json)) produces the original JSON. One-directional testing misses subtle bugs in property ordering or missing fields.
Wrapping up
Custom converters are the escape hatch when the built-in serialization doesn't fit your wire format. Start with value converters for simple transformations, graduate to object converters for structural reshaping, and reach for JsonConverterFactory when generics are involved. Keep them well-tested — they're effectively hand-written parsers, and parser bugs are notoriously tricky to diagnose.