Enums, Naming and Nulls
Write enums as stable strings, choose a property naming policy, and decide when null or default values are left out of responses.
json.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, allowIntegerValues: false));
json.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
[JsonPropertyName("sku")] string ProductSkuEnums, property names and null handling are the parts of a JSON contract clients notice first, and the parts that are hardest to change after release.
Enums as strings
By default an enum is written as its number: "status":1. That is fragile, because inserting a member in the middle renumbers everything after it, and clients see magic numbers. Add JsonStringEnumConverter to write names. Its reading rules matter as much as its writing:
- Names are matched case-insensitively, and both the original member name and the policy-converted name are accepted.
- Integers are accepted by default, including undefined ones:
"status":42binds(OrderStatus)42and the handler runs. PassallowIntegerValues: falseso only names are accepted. - The naming policy argument controls written names:
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)writesawaiting_payment.[JsonStringEnumMemberName("next-day")]renames a single member. [Flags]values are written as"Gift, Fragile"; an undefined value is written as a number, or throwsJsonExceptionon write whenallowIntegerValuesis false, so a bad cast in your own code becomes a 500.- The non-generic converter needs reflection. For Native AOT use
JsonStringEnumConverter<TEnum>on the enum type.
Adding a member is a breaking change for any client that deserialises strictly. Document that clients must tolerate unknown values, and do the same yourself when you consume another team's enums.
Property names
The web default is camelCase. JsonNamingPolicy also offers SnakeCaseLower, SnakeCaseUpper, KebabCaseLower and KebabCaseUpper; CustomerID becomes customer_id. [JsonPropertyName("sku")] overrides the policy for one member and is used exactly as written. Case-insensitive reading does not bridge styles: under a snake_case policy a client sending customerEmail is not matched, and the value is silently dropped. Dictionary keys are not touched by the property policy; set DictionaryKeyPolicy if you need that.
Nulls and defaults
| JsonIgnoreCondition | Leaves out | Use |
|---|---|---|
Never (default) | nothing | clients always see every key |
WhenWritingNull | null references and null Nullable<T> | smaller payloads with optional fields |
WhenWritingDefault | null, 0, false, the enum member whose value is 0 (default(TEnum)) | rarely right for an API |
Always | the member, always | per property only; global use throws |
The production failure is WhenWritingDefault set globally to trim payloads. A quantity of 0, isDiscontinued: false and an order in Pending (enum value 0) vanish from responses, and clients that treat a missing key as "unknown" show wrong data. Use WhenWritingNull globally if you omit anything, and [JsonIgnore(Condition = ...)] on a property for exceptions; the attribute wins over the global setting.
Omitting nulls is a contract decision: some clients treat an absent key and an explicit null differently, and in PATCH bodies the difference carries meaning.
Example
When to use it
- Expose order status as "awaiting_payment" instead of 1 so inserting a new status cannot change what existing numbers mean.
- Reject {"status":42} on POST /orders/7/status instead of saving an enum value the code has no branch for.
- Call a snake_case shipping carrier from a camelCase API by giving the typed client its own static JsonSerializerOptions.
More examples
A snake_case partner API without changing your own contract
A naming policy belongs to a contract, not to the process. Keeping a separate static options instance for the carrier means its snake_case never leaks into your public API, and <code>[JsonPropertyName("zip")]</code> handles the one field that follows neither convention. The client stays lenient about unknown response fields, which is right when you consume an API someone else evolves.
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class CarrierClient(HttpClient http)
{
// The carrier speaks snake_case; the shop's own API stays camelCase.
private static readonly JsonSerializerOptions CarrierJson = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower, allowIntegerValues: false) },
};
public async Task<ShipmentLabel> CreateLabelAsync(ShipmentRequest request, CancellationToken ct)
{
using var response = await http.PostAsJsonAsync("/v2/shipments", request, CarrierJson, ct);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync<ShipmentLabel>(CarrierJson, ct))!;
}
}
public enum ServiceLevel { Ground, TwoDay, NextDayAir }
// Serialized as {"order_reference":"ORD-2026-0042","weight_grams":1250,"service_level":"next_day_air","zip":"94107"}
public record ShipmentRequest(
string OrderReference,
int WeightGrams,
ServiceLevel ServiceLevel,
[property: JsonPropertyName("zip")] string PostalCode,
string? DeliveryInstructions);
public record ShipmentLabel(string TrackingNumber, Uri LabelUrl);
Per-property overrides instead of WhenWritingDefault
With <code>WhenWritingDefault</code> this item would serialize as <code>{"sku":"KB-104"}</code> and a client would not know it is out of stock. <code>WhenWritingNull</code> keeps the zero and the false. A property-level <code>[JsonIgnore]</code> condition overrides the global one, so <code>barcode</code> is always present and <code>InternalCostCode</code> never leaves the server.
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull);
var app = builder.Build();
app.MapGet("/inventory/{sku}", (string sku) =>
TypedResults.Ok(new InventoryItem(sku, QuantityOnHand: 0, IsDiscontinued: false, Barcode: null, InternalCostCode: "CC-17")));
app.Run();
// Output: {"sku":"KB-104","quantityOnHand":0,"isDiscontinued":false,"barcode":null}
public record InventoryItem(
string Sku,
int QuantityOnHand,
bool IsDiscontinued,
// The mobile app expects the key even when there is no barcode.
[property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] string? Barcode,
[property: JsonIgnore] string InternalCostCode);
Enum query parameters do not use the JSON converter
Route and query values are not JSON, so <code>JsonStringEnumConverter</code> never sees them. A plain <code>OrderStatus</code> query parameter is parsed with <code>Enum.TryParse</code>, which is case-sensitive, rejects <code>awaiting_payment</code> and accepts <code>42</code>. A small wrapper with a static <code>TryParse</code> makes the query string follow the same names as the JSON body, and anything else gets a 400.
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// GET /orders?status=awaiting_payment -> binds; ?status=AwaitingPayment, ?status=1, ?status=42 -> 400
app.MapGet("/orders", (StatusFilter status) => TypedResults.Ok(new { status = status.Value.ToString() }));
app.Run();
public enum OrderStatus { Pending, AwaitingPayment, Paid, Shipped }
public readonly record struct StatusFilter(OrderStatus Value)
{
private static readonly Dictionary<string, OrderStatus> ByName =
Enum.GetValues<OrderStatus>().ToDictionary(s => JsonNamingPolicy.SnakeCaseLower.ConvertName(s.ToString()));
// Minimal APIs call a static TryParse for route, query and header values.
public static bool TryParse(string? value, out StatusFilter result)
{
var found = ByName.TryGetValue(value ?? "", out var status);
result = new StatusFilter(status);
return found;
}
}
Discussion