You have a REST API that mostly speaks camelCase, but a handful of properties need to match an external system's PascalCase contract. Or you have a DTO where every nullable property should be omitted when null, but you have been decorating each one with [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] because there is no way to say it once at the type level. These are the kinds of paper cuts that System.Text.Json has carried since its introduction, and .NET 11 Preview 3 finally addresses them.

Three additions landed in this preview: JsonNamingPolicy.PascalCase, a [JsonNamingPolicy] attribute for per-member naming overrides, and support for [JsonIgnore] at the type level with a Condition. None of them is individually revolutionary, but together they eliminate a surprising amount of boilerplate and workaround code.

The PascalCase naming policy

Since .NET 8, System.Text.Json has shipped built-in naming policies for camelCase, snake_case (upper and lower), and kebab-case (upper and lower). PascalCase was conspicuously absent. The workaround was to set PropertyNamingPolicy to null, which left property names unchanged. That works when your C# properties are already PascalCase, but it falls apart in two scenarios: dictionary keys and properties that come from base types with non-standard casing.

Setting the policy to null means "do nothing" rather than "enforce PascalCase." If a dictionary key arrives as "userAge", a null policy leaves it as "userAge" in the output. A proper PascalCase policy converts it to "UserAge".

.NET 11 Preview 3 adds JsonNamingPolicy.PascalCase alongside the existing policies:

Example.cs
var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.PascalCase,
    DictionaryKeyPolicy = JsonNamingPolicy.PascalCase,
    WriteIndented = true
};

var payload = new Dictionary<string, object>
{
    ["userAge"] = 30,
    ["accountStatus"] = "active"
};

string json = JsonSerializer.Serialize(payload, options);

The output now reads "UserAge" and "AccountStatus", regardless of how the keys were originally cased. This matters when you are producing JSON for systems that expect PascalCase and your data sources do not guarantee it.

The corresponding JsonKnownNamingPolicy.PascalCase enum value is also available for use with source generators:

Example.cs
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.PascalCase)]
[JsonSerializable(typeof(OrderSummary))]
internal partial class AppJsonContext : JsonSerializerContext;

// TIP

If you have been using PropertyNamingPolicy = null as a "PascalCase" stand-in, consider switching to the explicit policy. It makes intent clear to anyone reading your configuration, and it actively converts non-PascalCase inputs rather than silently passing them through.

Per-member naming with [JsonNamingPolicy]

Before this preview, you had two levels of control over property names: global (via JsonSerializerOptions.PropertyNamingPolicy) and per-property (via [JsonPropertyName("exactName")]). The gap between them was noticeable. If your API uses camelCase globally but one section of a response must match a legacy system's snake_case contract, you had to hard-code every property name with [JsonPropertyName].

The new [JsonNamingPolicy] attribute sits between these two extremes. It lets you override the global naming convention for a specific property by referencing a JsonKnownNamingPolicy value:

Models/PaymentEvent.cs
public class PaymentEvent
{
    public string TransactionId { get; set; } = "";

    [JsonNamingPolicy(JsonKnownNamingPolicy.SnakeCaseLower)]
    public string PaymentMethod { get; set; } = "";

    public decimal Amount { get; set; }

    [JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)]
    public string EventType { get; set; } = "";
}

With a global CamelCase policy, this serialises as:

data.json
{
  "transactionId": "txn_9f2a",
  "payment_method": "credit_card",
  "amount": 49.99,
  "event-type": "payment.completed"
}

The TransactionId and Amount properties follow the global camelCase rule. PaymentMethod uses the per-member snake_case override, and EventType uses kebab-case.

This is particularly useful when a single DTO must satisfy multiple downstream consumers with different naming expectations. Instead of splitting the type into separate models or manually specifying every [JsonPropertyName], you declare the policy and let the serialiser handle the conversion.

How it interacts with [JsonPropertyName]

[JsonPropertyName] still takes the highest precedence. If both attributes are present on the same property, [JsonPropertyName] wins. The hierarchy is:

  1. [JsonPropertyName("exact")] — hard-coded name, always used
  2. [JsonNamingPolicy(JsonKnownNamingPolicy.X)] — applies the specified policy to the C# property name
  3. JsonSerializerOptions.PropertyNamingPolicy — the global fallback

This layered approach means you can set a sensible default globally, override by convention for groups of properties, and still pin specific names where an exact string is required.

Type-level [JsonIgnore] with conditions

The [JsonIgnore] attribute has supported a Condition property since .NET 5, but it could only be applied to individual properties and fields. If you wanted every nullable property on a type to be omitted when null, you had two options: set DefaultIgnoreCondition on JsonSerializerOptions (which affects all types), or decorate every single property.

.NET 11 Preview 3 extends [JsonIgnore] to work at the type level. When applied to a class or struct, it sets the default ignore condition for all properties and fields on that type:

Models/EventData.cs
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public sealed class EventData
{
    public string EventName { get; set; } = "";
    public string? Source { get; set; }
    public string? CorrelationId { get; set; }
    public DateTimeOffset Timestamp { get; set; }
    public Dictionary<string, string>? Metadata { get; set; }
}

When Source, CorrelationId, or Metadata is null, they are simply omitted from the output. No repetitive attribute decoration required.

Individual properties can still override the type-level default:

Models/AuditEntry.cs
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public sealed class AuditEntry
{
    public string Action { get; set; } = "";
    public string? UserId { get; set; }

    [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
    public string? Reason { get; set; }  // Always included, even when null
}

Here, Reason is always serialised because the property-level [JsonIgnore(Condition = JsonIgnoreCondition.Never)] overrides the type-level WhenWritingNull default.

// NOTE

The type-level [JsonIgnore] only sets a default condition. It does not unconditionally hide the type from serialisation. If you apply [JsonIgnore] without specifying a Condition at the type level, the default is JsonIgnoreCondition.Always, which would suppress all properties — almost certainly not what you want.

Putting it all together

Here is a more realistic example combining all three features. Imagine an API that publishes event data to two downstream systems: one expects camelCase with nulls omitted, and one legacy system expects certain fields in snake_case.

Models/IntegrationEvent.cs
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public sealed class IntegrationEvent
{
    public string EventId { get; set; } = Guid.NewGuid().ToString("N");
    public string EventType { get; set; } = "";
    public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;

    [JsonNamingPolicy(JsonKnownNamingPolicy.SnakeCaseLower)]
    public string SourceSystem { get; set; } = "";

    [JsonNamingPolicy(JsonKnownNamingPolicy.SnakeCaseLower)]
    public string TargetQueue { get; set; } = "";

    public string? CorrelationId { get; set; }
    public Dictionary<string, object>? Properties { get; set; }
}
Services/EventPublisher.cs
public class EventPublisher(HttpClient httpClient)
{
    private static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = false
    };

    public async Task PublishAsync(IntegrationEvent evt, CancellationToken ct)
    {
        string json = JsonSerializer.Serialize(evt, Options);
        await httpClient.PostAsync("/events", new StringContent(json, Encoding.UTF8, "application/json"), ct);
    }
}

The result: most fields serialise as camelCase, SourceSystem and TargetQueue become source_system and target_queue, and any null optional properties are silently dropped. One type, one set of options, no boilerplate.

Common pitfalls

Forgetting the Condition on type-level [JsonIgnore]. Applying [JsonIgnore] to a class without a Condition defaults to JsonIgnoreCondition.Always, which suppresses every property. You almost certainly want to specify WhenWritingNull or WhenWritingDefault explicitly.

Assuming [JsonNamingPolicy] overrides [JsonPropertyName]. It does not. [JsonPropertyName] always wins. If you have both on the same member, the hard-coded name from [JsonPropertyName] is used and the naming policy is ignored entirely.

Confusing PascalCase policy with null policy. Setting PropertyNamingPolicy to null leaves names as-is. Setting it to JsonNamingPolicy.PascalCase actively converts names. For simple C# properties this produces the same output, but for dictionary keys and properties inherited from types with unconventional casing, the behaviour differs.

Not accounting for deserialisation. Both [JsonNamingPolicy] and JsonNamingPolicy.PascalCase affect deserialisation as well as serialisation. If your incoming JSON uses a different convention than the one you configure, deserialisation will fail to match properties unless you also enable PropertyNameCaseInsensitive.

Source generator compatibility. The [JsonNamingPolicy] attribute works with the System.Text.Json source generator, but you need to verify your generator version targets .NET 11. Older generator versions will silently ignore the attribute.

Summary

These are available in .NET 11 Preview 3. Install the latest preview SDK from the .NET downloads page and set your TargetFramework to net11.0 to start using them.